From 38cd020c1fb8a1e88b7852160796f411926a6fac Mon Sep 17 00:00:00 2001
From: Josh Story
Date: Thu, 19 Feb 2026 12:29:21 -0800
Subject: [PATCH 1/2] Don't outline Suspense boundaries with suspensey CSS
during shell flush (#35824)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When flushing the shell, stylesheets with precedence are emitted in the
`` which blocks paint regardless. Outlining a boundary solely
because it has suspensey CSS provides no benefit during the shell flush
and causes a higher-level fallback to be shown unnecessarily (e.g.
"Middle Fallback" instead of "Inner Fallback").
This change passes a flushingInShell flag to hasSuspenseyContent so the
host config can skip stylesheet-only suspensey content when flushing the
shell. Suspensey images (used for ViewTransition animation reveals)
still trigger outlining during the shell since their motivation is
different.
When flushing streamed completions the behavior is unchanged — suspensey
CSS still causes outlining so the parent content can display sooner
while the stylesheet loads.
---
.../src/server/ReactFizzConfigDOM.js | 12 +-
.../src/server/ReactFizzConfigDOMLegacy.js | 5 +-
.../src/__tests__/ReactDOMFloat-test.js | 188 ++++++++++++++++++
.../react-markup/src/ReactFizzConfigMarkup.js | 5 +-
.../src/ReactNoopServer.js | 5 +-
packages/react-server/src/ReactFizzServer.js | 7 +-
6 files changed, 216 insertions(+), 6 deletions(-)
diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
index a93c32a947f1..e654ea88007d 100644
--- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
@@ -7041,7 +7041,17 @@ export function hoistHoistables(
}
}
-export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
+export function hasSuspenseyContent(
+ hoistableState: HoistableState,
+ flushingInShell: boolean,
+): boolean {
+ if (flushingInShell) {
+ // When flushing the shell, stylesheets with precedence are already emitted
+ // in the which blocks paint. There's no benefit to outlining for CSS
+ // alone during the shell flush. However, suspensey images (for ViewTransition
+ // animation reveals) should still trigger outlining even during the shell.
+ return hoistableState.suspenseyImages;
+ }
return hoistableState.stylesheets.size > 0 || hoistableState.suspenseyImages;
}
diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
index d48e9a8dd932..46fad3c39bf4 100644
--- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
@@ -326,7 +326,10 @@ export function writePreambleStart(
);
}
-export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
+export function hasSuspenseyContent(
+ hoistableState: HoistableState,
+ flushingInShell: boolean,
+): boolean {
// Never outline.
return false;
}
diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
index 1caa5ed8d6e7..21bf9684b285 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
@@ -9449,4 +9449,192 @@ background-color: green;
);
});
});
+
+ it('does not outline a boundary with suspensey CSS when flushing the shell', async () => {
+ // When flushing the shell, stylesheets with precedence are emitted in the
+ // which blocks paint anyway. So there's no benefit to outlining the
+ // boundary — it would just show a higher-level fallback unnecessarily.
+ // Instead, the boundary should be inlined so the innermost fallback is shown.
+ let streamedContent = '';
+ writable.on('data', chunk => (streamedContent += chunk));
+
+ await act(() => {
+ renderToPipeableStream(
+
+
+
+
+
+
+ Async Content
+
+
+
+
+ ,
+ ).pipe(writable);
+ });
+
+ // The middle boundary should have been inlined (not outlined) so the
+ // middle fallback text should never appear in the streamed HTML.
+ expect(streamedContent).not.toContain('Middle Fallback');
+
+ // The stylesheet is in the head (blocks paint), and the innermost
+ // fallback is visible.
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+ Inner Fallback
+ ,
+ );
+
+ // Resolve the async content — streams in without needing to load CSS
+ // since the stylesheet was already in the head.
+ await act(() => {
+ resolveText('content');
+ });
+
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+ Async Content
+ ,
+ );
+ });
+
+ it('outlines a boundary with suspensey CSS when flushing a streamed completion', async () => {
+ // When a boundary completes via streaming (not as part of the shell),
+ // suspensey CSS should cause the boundary to be outlined. The parent
+ // content can show sooner while the CSS loads separately.
+ let streamedContent = '';
+ writable.on('data', chunk => (streamedContent += chunk));
+
+ await act(() => {
+ renderToPipeableStream(
+
+
+
+
+
+
+
+
+ Async Content
+
+
+
+
+
+
+ ,
+ ).pipe(writable);
+ });
+
+ // Shell is showing root fallback
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
Root Fallback
+ ,
+ );
+
+ // Unblock the shell — content streams in. The middle boundary should
+ // be outlined because the CSS arrived via streaming, not in the shell head.
+ streamedContent = '';
+ await act(() => {
+ resolveText('shell');
+ });
+
+ // The middle fallback should appear in the streamed HTML because the
+ // boundary was outlined.
+ expect(streamedContent).toContain('Middle Fallback');
+
+ // The CSS needs to load before the boundary reveals. Until then
+ // the middle fallback is visible.
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+ {'Middle Fallback'}
+
+
+ ,
+ );
+
+ // Load the stylesheet — now the middle boundary can reveal
+ await act(() => {
+ loadStylesheets();
+ });
+ assertLog(['load stylesheet: style.css']);
+
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+ {'Inner Fallback'}
+
+
+ ,
+ );
+
+ // Resolve the async content
+ await act(() => {
+ resolveText('content');
+ });
+
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+ {'Async Content'}
+
+
+ ,
+ );
+ });
+
+ // @gate enableViewTransition
+ it('still outlines a boundary with a suspensey image inside a ViewTransition when flushing the shell', async () => {
+ // Unlike stylesheets (which block paint from the anyway), images
+ // inside ViewTransitions are outlined to enable animation reveals. This
+ // should happen even during the shell flush.
+ const ViewTransition = React.ViewTransition;
+
+ let streamedContent = '';
+ writable.on('data', chunk => (streamedContent += chunk));
+
+ await act(() => {
+ renderToPipeableStream(
+
+
+
+
+
+
+
Content
+
+
+
+ ,
+ ).pipe(writable);
+ });
+
+ // The boundary should be outlined because the suspensey image motivates
+ // outlining for animation reveals, even during the shell flush.
+ expect(streamedContent).toContain('Image Fallback');
+ });
});
diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js
index 7dbe5592f337..d12d72e69e02 100644
--- a/packages/react-markup/src/ReactFizzConfigMarkup.js
+++ b/packages/react-markup/src/ReactFizzConfigMarkup.js
@@ -242,7 +242,10 @@ export function writeCompletedRoot(
return true;
}
-export function hasSuspenseyContent(hoistableState: HoistableState): boolean {
+export function hasSuspenseyContent(
+ hoistableState: HoistableState,
+ flushingInShell: boolean,
+): boolean {
// Never outline.
return false;
}
diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js
index 1793180cc765..913e72d7fc4f 100644
--- a/packages/react-noop-renderer/src/ReactNoopServer.js
+++ b/packages/react-noop-renderer/src/ReactNoopServer.js
@@ -324,7 +324,10 @@ const ReactNoopServer = ReactFizzServer({
writeHoistablesForBoundary() {},
writePostamble() {},
hoistHoistables(parent: HoistableState, child: HoistableState) {},
- hasSuspenseyContent(hoistableState: HoistableState): boolean {
+ hasSuspenseyContent(
+ hoistableState: HoistableState,
+ flushingInShell: boolean,
+ ): boolean {
return false;
},
createHoistableState(): HoistableState {
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index d06d967b1f87..989f9184637d 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -479,7 +479,7 @@ function isEligibleForOutlining(
// outlining.
return (
(boundary.byteSize > 500 ||
- hasSuspenseyContent(boundary.contentState) ||
+ hasSuspenseyContent(boundary.contentState, /* flushingInShell */ false) ||
boundary.defer) &&
// For boundaries that can possibly contribute to the preamble we don't want to outline
// them regardless of their size since the fallbacks should only be emitted if we've
@@ -5593,7 +5593,7 @@ function flushSegment(
!flushingPartialBoundaries &&
isEligibleForOutlining(request, boundary) &&
(flushedByteSize + boundary.byteSize > request.progressiveChunkSize ||
- hasSuspenseyContent(boundary.contentState) ||
+ hasSuspenseyContent(boundary.contentState, flushingShell) ||
boundary.defer)
) {
// Inlining this boundary would make the current sequence being written too large
@@ -5826,6 +5826,7 @@ function flushPartiallyCompletedSegment(
}
let flushingPartialBoundaries = false;
+let flushingShell = false;
function flushCompletedQueues(
request: Request,
@@ -5885,7 +5886,9 @@ function flushCompletedQueues(
completedPreambleSegments,
skipBlockingShell,
);
+ flushingShell = true;
flushSegment(request, destination, completedRootSegment, null);
+ flushingShell = false;
request.completedRootSegment = null;
const isComplete =
request.allPendingTasks === 0 &&
From 2ba3065527cbabc9778363e78a411653cd4cd215 Mon Sep 17 00:00:00 2001
From: "Sebastian \"Sebbie\" Silbermann"
Date: Thu, 19 Feb 2026 15:50:34 -0800
Subject: [PATCH 2/2] [Flight] Add support for transporting `Error.cause`
(#35810)
---
.../react-client/src/ReactFlightClient.js | 22 +--
.../src/__tests__/ReactFlight-test.js | 133 ++++++++++++++++++
.../react-server/src/ReactFlightServer.js | 39 +++--
packages/shared/ReactTypes.js | 9 ++
4 files changed, 185 insertions(+), 18 deletions(-)
diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js
index fbf5190ed516..098a1a687e3a 100644
--- a/packages/react-client/src/ReactFlightClient.js
+++ b/packages/react-client/src/ReactFlightClient.js
@@ -8,6 +8,7 @@
*/
import type {
+ JSONValue,
Thenable,
ReactDebugInfo,
ReactDebugInfoEntry,
@@ -132,14 +133,6 @@ interface FlightStreamController {
type UninitializedModel = string;
-export type JSONValue =
- | number
- | null
- | boolean
- | string
- | {+[key: string]: JSONValue}
- | $ReadOnlyArray;
-
type ProfilingResult = {
track: number,
endTime: number,
@@ -3527,6 +3520,18 @@ function resolveErrorDev(
}
let error;
+ const errorOptions =
+ 'cause' in errorInfo
+ ? {
+ cause: reviveModel(
+ response,
+ // $FlowFixMe[incompatible-cast] -- Flow thinks `cause` in `cause?: JSONValue` can be undefined after `in` check.
+ (errorInfo.cause: JSONValue),
+ errorInfo,
+ 'cause',
+ ),
+ }
+ : undefined;
const callStack = buildFakeCallStack(
response,
stack,
@@ -3537,6 +3542,7 @@ function resolveErrorDev(
null,
message ||
'An error occurred in the Server Components render but no message was provided',
+ errorOptions,
),
);
diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js
index 45a8c74ee28e..e25b8c87a9bd 100644
--- a/packages/react-client/src/__tests__/ReactFlight-test.js
+++ b/packages/react-client/src/__tests__/ReactFlight-test.js
@@ -707,6 +707,139 @@ describe('ReactFlight', () => {
}
});
+ it('can transport Error.cause', async () => {
+ function renderError(error) {
+ if (!(error instanceof Error)) {
+ return `${JSON.stringify(error)}`;
+ }
+ return `
+ is error: ${error instanceof Error}
+ name: ${error.name}
+ message: ${error.message}
+ stack: ${normalizeCodeLocInfo(error.stack).split('\n').slice(0, 2).join('\n')}
+ environmentName: ${error.environmentName}
+ cause: ${'cause' in error ? renderError(error.cause) : 'no cause'}`;
+ }
+ function ComponentClient({error}) {
+ return renderError(error);
+ }
+ const Component = clientReference(ComponentClient);
+
+ function ServerComponent() {
+ const cause = new TypeError('root cause', {
+ cause: {type: 'object cause'},
+ });
+ const error = new Error('hello', {cause});
+ return ;
+ }
+
+ const transport = ReactNoopFlightServer.render(, {
+ onError(x) {
+ if (__DEV__) {
+ return 'a dev digest';
+ }
+ return `digest("${x.message}")`;
+ },
+ });
+
+ await act(() => {
+ ReactNoop.render(ReactNoopFlightClient.read(transport));
+ });
+
+ if (__DEV__) {
+ expect(ReactNoop).toMatchRenderedOutput(`
+ is error: true
+ name: Error
+ message: hello
+ stack: Error: hello
+ in ServerComponent (at **)
+ environmentName: Server
+ cause:
+ is error: true
+ name: TypeError
+ message: root cause
+ stack: TypeError: root cause
+ in ServerComponent (at **)
+ environmentName: Server
+ cause: {"type":"object cause"}`);
+ } else {
+ expect(ReactNoop).toMatchRenderedOutput(`
+ is error: true
+ name: Error
+ message: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.
+ stack: Error: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.
+ environmentName: undefined
+ cause: no cause`);
+ }
+ });
+
+ it('includes Error.cause in thrown errors', async () => {
+ function renderError(error) {
+ if (!(error instanceof Error)) {
+ return `${JSON.stringify(error)}`;
+ }
+ return `
+ is error: true
+ name: ${error.name}
+ message: ${error.message}
+ stack: ${normalizeCodeLocInfo(error.stack).split('\n').slice(0, 2).join('\n')}
+ environmentName: ${error.environmentName}
+ cause: ${'cause' in error ? renderError(error.cause) : 'no cause'}`;
+ }
+
+ function ServerComponent() {
+ const cause = new TypeError('root cause', {
+ cause: {type: 'object cause'},
+ });
+ const error = new Error('hello', {cause});
+ throw error;
+ }
+
+ const transport = ReactNoopFlightServer.render(, {
+ onError(x) {
+ if (__DEV__) {
+ return 'a dev digest';
+ }
+ return `digest("${x.message}")`;
+ },
+ });
+
+ let error;
+ try {
+ await act(() => {
+ ReactNoop.render(ReactNoopFlightClient.read(transport));
+ });
+ } catch (x) {
+ error = x;
+ }
+
+ if (__DEV__) {
+ expect(renderError(error)).toEqual(`
+ is error: true
+ name: Error
+ message: hello
+ stack: Error: hello
+ in ServerComponent (at **)
+ environmentName: Server
+ cause:
+ is error: true
+ name: TypeError
+ message: root cause
+ stack: TypeError: root cause
+ in ServerComponent (at **)
+ environmentName: Server
+ cause: {"type":"object cause"}`);
+ } else {
+ expect(renderError(error)).toEqual(`
+ is error: true
+ name: Error
+ message: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.
+ stack: Error: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.
+ environmentName: undefined
+ cause: no cause`);
+ }
+ });
+
it('can transport cyclic objects', async () => {
function ComponentClient({prop}) {
expect(prop.obj.obj.obj).toBe(prop.obj.obj);
diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js
index 3bafdcf40bc9..4c50f6a7d20a 100644
--- a/packages/react-server/src/ReactFlightServer.js
+++ b/packages/react-server/src/ReactFlightServer.js
@@ -467,14 +467,6 @@ function getCurrentStackInDEV(): string {
const ObjectPrototype = Object.prototype;
-type JSONValue =
- | string
- | boolean
- | number
- | null
- | {+[key: string]: JSONValue}
- | $ReadOnlyArray;
-
const stringify = JSON.stringify;
type ReactJSONValue =
@@ -498,6 +490,7 @@ export type ReactClientValue =
| React$Element
| React$Element & any>
| ReactComponentInfo
+ | ReactErrorInfo
| string
| boolean
| number
@@ -4171,6 +4164,11 @@ function serializeErrorValue(request: Request, error: Error): string {
stack = [];
}
const errorInfo: ReactErrorInfoDev = {name, message, stack, env};
+ if ('cause' in error) {
+ const cause: ReactClientValue = (error.cause: any);
+ const causeId = outlineModel(request, cause);
+ errorInfo.cause = serializeByValueID(causeId);
+ }
const id = outlineModel(request, errorInfo);
return '$Z' + id.toString(16);
} else {
@@ -4181,7 +4179,11 @@ function serializeErrorValue(request: Request, error: Error): string {
}
}
-function serializeDebugErrorValue(request: Request, error: Error): string {
+function serializeDebugErrorValue(
+ request: Request,
+ counter: {objectLimit: number},
+ error: Error,
+): string {
if (__DEV__) {
let name: string = 'Error';
let message: string;
@@ -4203,6 +4205,12 @@ function serializeDebugErrorValue(request: Request, error: Error): string {
stack = [];
}
const errorInfo: ReactErrorInfoDev = {name, message, stack, env};
+ if ('cause' in error) {
+ counter.objectLimit--;
+ const cause: ReactClientValue = (error.cause: any);
+ const causeId = outlineDebugModel(request, counter, cause);
+ errorInfo.cause = serializeByValueID(causeId);
+ }
const id = outlineDebugModel(
request,
{objectLimit: stack.length * 2 + 1},
@@ -4231,6 +4239,7 @@ function emitErrorChunk(
let message: string;
let stack: ReactStackTrace;
let env = (0, request.environmentName)();
+ let causeReference: null | string = null;
try {
if (error instanceof Error) {
name = error.name;
@@ -4243,6 +4252,13 @@ function emitErrorChunk(
// Keep the environment name.
env = errorEnv;
}
+ if ('cause' in error) {
+ const cause: ReactClientValue = (error.cause: any);
+ const causeId = debug
+ ? outlineDebugModel(request, {objectLimit: 5}, cause)
+ : outlineModel(request, cause);
+ causeReference = serializeByValueID(causeId);
+ }
} else if (typeof error === 'object' && error !== null) {
message = describeObjectForErrorMessage(error);
stack = [];
@@ -4258,6 +4274,9 @@ function emitErrorChunk(
const ownerRef =
owner == null ? null : outlineComponentInfo(request, owner);
errorInfo = {digest, name, message, stack, env, owner: ownerRef};
+ if (causeReference !== null) {
+ (errorInfo: ReactErrorInfoDev).cause = causeReference;
+ }
} else {
errorInfo = {digest};
}
@@ -4969,7 +4988,7 @@ function renderDebugModel(
return serializeDebugFormData(request, value);
}
if (value instanceof Error) {
- return serializeDebugErrorValue(request, value);
+ return serializeDebugErrorValue(request, counter, value);
}
if (value instanceof ArrayBuffer) {
return serializeDebugTypedArray(request, 'A', new Uint8Array(value));
diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js
index e58c36f0a0cb..c8658278a5bf 100644
--- a/packages/shared/ReactTypes.js
+++ b/packages/shared/ReactTypes.js
@@ -228,6 +228,14 @@ export type ReactErrorInfoProd = {
+digest: string,
};
+export type JSONValue =
+ | string
+ | boolean
+ | number
+ | null
+ | {+[key: string]: JSONValue}
+ | $ReadOnlyArray;
+
export type ReactErrorInfoDev = {
+digest?: string,
+name: string,
@@ -235,6 +243,7 @@ export type ReactErrorInfoDev = {
+stack: ReactStackTrace,
+env: string,
+owner?: null | string,
+ cause?: JSONValue,
};
export type ReactErrorInfo = ReactErrorInfoProd | ReactErrorInfoDev;