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-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 &&
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;