[Flight] Add support for transporting Error.cause (#35810)

This commit is contained in:
Sebastian "Sebbie" Silbermann
2026-02-19 15:50:34 -08:00
committed by GitHub
parent 38cd020c1f
commit 2ba3065527
4 changed files with 185 additions and 18 deletions

View File

@@ -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<JSONValue>;
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,
),
);

View File

@@ -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 <Component error={error} />;
}
const transport = ReactNoopFlightServer.render(<ServerComponent />, {
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(<ServerComponent />, {
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);

View File

@@ -467,14 +467,6 @@ function getCurrentStackInDEV(): string {
const ObjectPrototype = Object.prototype;
type JSONValue =
| string
| boolean
| number
| null
| {+[key: string]: JSONValue}
| $ReadOnlyArray<JSONValue>;
const stringify = JSON.stringify;
type ReactJSONValue =
@@ -498,6 +490,7 @@ export type ReactClientValue =
| React$Element<string>
| React$Element<ClientReference<any> & 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));

View File

@@ -228,6 +228,14 @@ export type ReactErrorInfoProd = {
+digest: string,
};
export type JSONValue =
| string
| boolean
| number
| null
| {+[key: string]: JSONValue}
| $ReadOnlyArray<JSONValue>;
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;