Skip to content

Commit 2ba3065

Browse files
authored
[Flight] Add support for transporting Error.cause (facebook#35810)
1 parent 38cd020 commit 2ba3065

File tree

4 files changed

+185
-18
lines changed

4 files changed

+185
-18
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

1010
import type {
11+
JSONValue,
1112
Thenable,
1213
ReactDebugInfo,
1314
ReactDebugInfoEntry,
@@ -132,14 +133,6 @@ interface FlightStreamController {
132133

133134
type UninitializedModel = string;
134135

135-
export type JSONValue =
136-
| number
137-
| null
138-
| boolean
139-
| string
140-
| {+[key: string]: JSONValue}
141-
| $ReadOnlyArray<JSONValue>;
142-
143136
type ProfilingResult = {
144137
track: number,
145138
endTime: number,
@@ -3527,6 +3520,18 @@ function resolveErrorDev(
35273520
}
35283521

35293522
let error;
3523+
const errorOptions =
3524+
'cause' in errorInfo
3525+
? {
3526+
cause: reviveModel(
3527+
response,
3528+
// $FlowFixMe[incompatible-cast] -- Flow thinks `cause` in `cause?: JSONValue` can be undefined after `in` check.
3529+
(errorInfo.cause: JSONValue),
3530+
errorInfo,
3531+
'cause',
3532+
),
3533+
}
3534+
: undefined;
35303535
const callStack = buildFakeCallStack(
35313536
response,
35323537
stack,
@@ -3537,6 +3542,7 @@ function resolveErrorDev(
35373542
null,
35383543
message ||
35393544
'An error occurred in the Server Components render but no message was provided',
3545+
errorOptions,
35403546
),
35413547
);
35423548

packages/react-client/src/__tests__/ReactFlight-test.js

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,139 @@ describe('ReactFlight', () => {
707707
}
708708
});
709709

710+
it('can transport Error.cause', async () => {
711+
function renderError(error) {
712+
if (!(error instanceof Error)) {
713+
return `${JSON.stringify(error)}`;
714+
}
715+
return `
716+
is error: ${error instanceof Error}
717+
name: ${error.name}
718+
message: ${error.message}
719+
stack: ${normalizeCodeLocInfo(error.stack).split('\n').slice(0, 2).join('\n')}
720+
environmentName: ${error.environmentName}
721+
cause: ${'cause' in error ? renderError(error.cause) : 'no cause'}`;
722+
}
723+
function ComponentClient({error}) {
724+
return renderError(error);
725+
}
726+
const Component = clientReference(ComponentClient);
727+
728+
function ServerComponent() {
729+
const cause = new TypeError('root cause', {
730+
cause: {type: 'object cause'},
731+
});
732+
const error = new Error('hello', {cause});
733+
return <Component error={error} />;
734+
}
735+
736+
const transport = ReactNoopFlightServer.render(<ServerComponent />, {
737+
onError(x) {
738+
if (__DEV__) {
739+
return 'a dev digest';
740+
}
741+
return `digest("${x.message}")`;
742+
},
743+
});
744+
745+
await act(() => {
746+
ReactNoop.render(ReactNoopFlightClient.read(transport));
747+
});
748+
749+
if (__DEV__) {
750+
expect(ReactNoop).toMatchRenderedOutput(`
751+
is error: true
752+
name: Error
753+
message: hello
754+
stack: Error: hello
755+
in ServerComponent (at **)
756+
environmentName: Server
757+
cause:
758+
is error: true
759+
name: TypeError
760+
message: root cause
761+
stack: TypeError: root cause
762+
in ServerComponent (at **)
763+
environmentName: Server
764+
cause: {"type":"object cause"}`);
765+
} else {
766+
expect(ReactNoop).toMatchRenderedOutput(`
767+
is error: true
768+
name: Error
769+
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.
770+
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.
771+
environmentName: undefined
772+
cause: no cause`);
773+
}
774+
});
775+
776+
it('includes Error.cause in thrown errors', async () => {
777+
function renderError(error) {
778+
if (!(error instanceof Error)) {
779+
return `${JSON.stringify(error)}`;
780+
}
781+
return `
782+
is error: true
783+
name: ${error.name}
784+
message: ${error.message}
785+
stack: ${normalizeCodeLocInfo(error.stack).split('\n').slice(0, 2).join('\n')}
786+
environmentName: ${error.environmentName}
787+
cause: ${'cause' in error ? renderError(error.cause) : 'no cause'}`;
788+
}
789+
790+
function ServerComponent() {
791+
const cause = new TypeError('root cause', {
792+
cause: {type: 'object cause'},
793+
});
794+
const error = new Error('hello', {cause});
795+
throw error;
796+
}
797+
798+
const transport = ReactNoopFlightServer.render(<ServerComponent />, {
799+
onError(x) {
800+
if (__DEV__) {
801+
return 'a dev digest';
802+
}
803+
return `digest("${x.message}")`;
804+
},
805+
});
806+
807+
let error;
808+
try {
809+
await act(() => {
810+
ReactNoop.render(ReactNoopFlightClient.read(transport));
811+
});
812+
} catch (x) {
813+
error = x;
814+
}
815+
816+
if (__DEV__) {
817+
expect(renderError(error)).toEqual(`
818+
is error: true
819+
name: Error
820+
message: hello
821+
stack: Error: hello
822+
in ServerComponent (at **)
823+
environmentName: Server
824+
cause:
825+
is error: true
826+
name: TypeError
827+
message: root cause
828+
stack: TypeError: root cause
829+
in ServerComponent (at **)
830+
environmentName: Server
831+
cause: {"type":"object cause"}`);
832+
} else {
833+
expect(renderError(error)).toEqual(`
834+
is error: true
835+
name: Error
836+
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.
837+
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.
838+
environmentName: undefined
839+
cause: no cause`);
840+
}
841+
});
842+
710843
it('can transport cyclic objects', async () => {
711844
function ComponentClient({prop}) {
712845
expect(prop.obj.obj.obj).toBe(prop.obj.obj);

packages/react-server/src/ReactFlightServer.js

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -467,14 +467,6 @@ function getCurrentStackInDEV(): string {
467467

468468
const ObjectPrototype = Object.prototype;
469469

470-
type JSONValue =
471-
| string
472-
| boolean
473-
| number
474-
| null
475-
| {+[key: string]: JSONValue}
476-
| $ReadOnlyArray<JSONValue>;
477-
478470
const stringify = JSON.stringify;
479471

480472
type ReactJSONValue =
@@ -498,6 +490,7 @@ export type ReactClientValue =
498490
| React$Element<string>
499491
| React$Element<ClientReference<any> & any>
500492
| ReactComponentInfo
493+
| ReactErrorInfo
501494
| string
502495
| boolean
503496
| number
@@ -4171,6 +4164,11 @@ function serializeErrorValue(request: Request, error: Error): string {
41714164
stack = [];
41724165
}
41734166
const errorInfo: ReactErrorInfoDev = {name, message, stack, env};
4167+
if ('cause' in error) {
4168+
const cause: ReactClientValue = (error.cause: any);
4169+
const causeId = outlineModel(request, cause);
4170+
errorInfo.cause = serializeByValueID(causeId);
4171+
}
41744172
const id = outlineModel(request, errorInfo);
41754173
return '$Z' + id.toString(16);
41764174
} else {
@@ -4181,7 +4179,11 @@ function serializeErrorValue(request: Request, error: Error): string {
41814179
}
41824180
}
41834181

4184-
function serializeDebugErrorValue(request: Request, error: Error): string {
4182+
function serializeDebugErrorValue(
4183+
request: Request,
4184+
counter: {objectLimit: number},
4185+
error: Error,
4186+
): string {
41854187
if (__DEV__) {
41864188
let name: string = 'Error';
41874189
let message: string;
@@ -4203,6 +4205,12 @@ function serializeDebugErrorValue(request: Request, error: Error): string {
42034205
stack = [];
42044206
}
42054207
const errorInfo: ReactErrorInfoDev = {name, message, stack, env};
4208+
if ('cause' in error) {
4209+
counter.objectLimit--;
4210+
const cause: ReactClientValue = (error.cause: any);
4211+
const causeId = outlineDebugModel(request, counter, cause);
4212+
errorInfo.cause = serializeByValueID(causeId);
4213+
}
42064214
const id = outlineDebugModel(
42074215
request,
42084216
{objectLimit: stack.length * 2 + 1},
@@ -4231,6 +4239,7 @@ function emitErrorChunk(
42314239
let message: string;
42324240
let stack: ReactStackTrace;
42334241
let env = (0, request.environmentName)();
4242+
let causeReference: null | string = null;
42344243
try {
42354244
if (error instanceof Error) {
42364245
name = error.name;
@@ -4243,6 +4252,13 @@ function emitErrorChunk(
42434252
// Keep the environment name.
42444253
env = errorEnv;
42454254
}
4255+
if ('cause' in error) {
4256+
const cause: ReactClientValue = (error.cause: any);
4257+
const causeId = debug
4258+
? outlineDebugModel(request, {objectLimit: 5}, cause)
4259+
: outlineModel(request, cause);
4260+
causeReference = serializeByValueID(causeId);
4261+
}
42464262
} else if (typeof error === 'object' && error !== null) {
42474263
message = describeObjectForErrorMessage(error);
42484264
stack = [];
@@ -4258,6 +4274,9 @@ function emitErrorChunk(
42584274
const ownerRef =
42594275
owner == null ? null : outlineComponentInfo(request, owner);
42604276
errorInfo = {digest, name, message, stack, env, owner: ownerRef};
4277+
if (causeReference !== null) {
4278+
(errorInfo: ReactErrorInfoDev).cause = causeReference;
4279+
}
42614280
} else {
42624281
errorInfo = {digest};
42634282
}
@@ -4969,7 +4988,7 @@ function renderDebugModel(
49694988
return serializeDebugFormData(request, value);
49704989
}
49714990
if (value instanceof Error) {
4972-
return serializeDebugErrorValue(request, value);
4991+
return serializeDebugErrorValue(request, counter, value);
49734992
}
49744993
if (value instanceof ArrayBuffer) {
49754994
return serializeDebugTypedArray(request, 'A', new Uint8Array(value));

packages/shared/ReactTypes.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,13 +228,22 @@ export type ReactErrorInfoProd = {
228228
+digest: string,
229229
};
230230

231+
export type JSONValue =
232+
| string
233+
| boolean
234+
| number
235+
| null
236+
| {+[key: string]: JSONValue}
237+
| $ReadOnlyArray<JSONValue>;
238+
231239
export type ReactErrorInfoDev = {
232240
+digest?: string,
233241
+name: string,
234242
+message: string,
235243
+stack: ReactStackTrace,
236244
+env: string,
237245
+owner?: null | string,
246+
cause?: JSONValue,
238247
};
239248

240249
export type ReactErrorInfo = ReactErrorInfoProd | ReactErrorInfoDev;

0 commit comments

Comments
 (0)