From 2ba3065527cbabc9778363e78a411653cd4cd215 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Thu, 19 Feb 2026 15:50:34 -0800 Subject: [PATCH] [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 fbf5190ed5..098a1a687e 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 45a8c74ee2..e25b8c87a9 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 3bafdcf40b..4c50f6a7d2 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 e58c36f0a0..c8658278a5 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;