Skip to content

Commit efc6a08

Browse files
authored
[Flight] Implement error digests for Flight runtime and expose errorInfo in getDerivedStateFromError (#25302)
Similar to Fizz, Flight now supports a return value from the user provided onError option. If a value is returned from onError it will be serialized and provided to the client. The digest is stashed on the constructed Error on the client as .digest
1 parent c1d414d commit efc6a08

14 files changed

+413
-76
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ function createBlockedChunk<T>(response: Response): BlockedChunk<T> {
193193

194194
function createErrorChunk<T>(
195195
response: Response,
196-
error: Error,
196+
error: ErrorWithDigest,
197197
): ErroredChunk<T> {
198198
// $FlowFixMe Flow doesn't support functions as constructors
199199
return new Chunk(ERRORED, null, error, response);
@@ -628,21 +628,64 @@ export function resolveSymbol(
628628
chunks.set(id, createInitializedChunk(response, Symbol.for(name)));
629629
}
630630

631-
export function resolveError(
631+
type ErrorWithDigest = Error & {digest?: string};
632+
export function resolveErrorProd(
632633
response: Response,
633634
id: number,
635+
digest: string,
636+
): void {
637+
if (__DEV__) {
638+
// These errors should never make it into a build so we don't need to encode them in codes.json
639+
// eslint-disable-next-line react-internal/prod-error-codes
640+
throw new Error(
641+
'resolveErrorProd should never be called in development mode. Use resolveErrorDev instead. This is a bug in React.',
642+
);
643+
}
644+
const error = new Error(
645+
'An error occurred in the Server Components render. The specific message is omitted in production' +
646+
' builds to avoid leaking sensitive details. A digest property is included on this error instance which' +
647+
' may provide additional details about the nature of the error.',
648+
);
649+
error.stack = '';
650+
(error: any).digest = digest;
651+
const errorWithDigest: ErrorWithDigest = (error: any);
652+
const chunks = response._chunks;
653+
const chunk = chunks.get(id);
654+
if (!chunk) {
655+
chunks.set(id, createErrorChunk(response, errorWithDigest));
656+
} else {
657+
triggerErrorOnChunk(chunk, errorWithDigest);
658+
}
659+
}
660+
661+
export function resolveErrorDev(
662+
response: Response,
663+
id: number,
664+
digest: string,
634665
message: string,
635666
stack: string,
636667
): void {
668+
if (!__DEV__) {
669+
// These errors should never make it into a build so we don't need to encode them in codes.json
670+
// eslint-disable-next-line react-internal/prod-error-codes
671+
throw new Error(
672+
'resolveErrorDev should never be called in production mode. Use resolveErrorProd instead. This is a bug in React.',
673+
);
674+
}
637675
// eslint-disable-next-line react-internal/prod-error-codes
638-
const error = new Error(message);
676+
const error = new Error(
677+
message ||
678+
'An error occurred in the Server Components render but no message was provided',
679+
);
639680
error.stack = stack;
681+
(error: any).digest = digest;
682+
const errorWithDigest: ErrorWithDigest = (error: any);
640683
const chunks = response._chunks;
641684
const chunk = chunks.get(id);
642685
if (!chunk) {
643-
chunks.set(id, createErrorChunk(response, error));
686+
chunks.set(id, createErrorChunk(response, errorWithDigest));
644687
} else {
645-
triggerErrorOnChunk(chunk, error);
688+
triggerErrorOnChunk(chunk, errorWithDigest);
646689
}
647690
}
648691

packages/react-client/src/ReactFlightClientStream.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import {
1616
resolveModel,
1717
resolveProvider,
1818
resolveSymbol,
19-
resolveError,
19+
resolveErrorProd,
20+
resolveErrorDev,
2021
createResponse as createResponseBase,
2122
parseModelString,
2223
parseModelTuple,
@@ -62,7 +63,17 @@ function processFullRow(response: Response, row: string): void {
6263
}
6364
case 'E': {
6465
const errorInfo = JSON.parse(text);
65-
resolveError(response, id, errorInfo.message, errorInfo.stack);
66+
if (__DEV__) {
67+
resolveErrorDev(
68+
response,
69+
id,
70+
errorInfo.digest,
71+
errorInfo.message,
72+
errorInfo.stack,
73+
);
74+
} else {
75+
resolveErrorProd(response, id, errorInfo.digest);
76+
}
6677
return;
6778
}
6879
default: {

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,19 @@ describe('ReactFlight', () => {
4545
componentDidMount() {
4646
expect(this.state.hasError).toBe(true);
4747
expect(this.state.error).toBeTruthy();
48-
expect(this.state.error.message).toContain(this.props.expectedMessage);
48+
if (__DEV__) {
49+
expect(this.state.error.message).toContain(
50+
this.props.expectedMessage,
51+
);
52+
expect(this.state.error.digest).toBe('a dev digest');
53+
} else {
54+
expect(this.state.error.message).toBe(
55+
'An error occurred in the Server Components render. The specific message is omitted in production' +
56+
' builds to avoid leaking sensitive details. A digest property is included on this error instance which' +
57+
' may provide additional details about the nature of the error.',
58+
);
59+
expect(this.state.error.digest).toContain(this.props.expectedMessage);
60+
}
4961
}
5062
render() {
5163
if (this.state.hasError) {
@@ -371,8 +383,8 @@ describe('ReactFlight', () => {
371383
}
372384

373385
const options = {
374-
onError() {
375-
// ignore
386+
onError(x) {
387+
return __DEV__ ? 'a dev digest' : `digest("${x.message}")`;
376388
},
377389
};
378390
const event = ReactNoopFlightServer.render(<EventHandlerProp />, options);

packages/react-server-dom-relay/src/ReactFlightDOMRelayClient.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import {
1616
resolveModel,
1717
resolveModule,
1818
resolveSymbol,
19-
resolveError,
19+
resolveErrorDev,
20+
resolveErrorProd,
2021
close,
2122
getRoot,
2223
} from 'react-client/src/ReactFlightClient';
@@ -34,7 +35,20 @@ export function resolveRow(response: Response, chunk: RowEncoding): void {
3435
// $FlowFixMe: Flow doesn't support disjoint unions on tuples.
3536
resolveSymbol(response, chunk[1], chunk[2]);
3637
} else {
37-
// $FlowFixMe: Flow doesn't support disjoint unions on tuples.
38-
resolveError(response, chunk[1], chunk[2].message, chunk[2].stack);
38+
if (__DEV__) {
39+
resolveErrorDev(
40+
response,
41+
chunk[1],
42+
// $FlowFixMe: Flow doesn't support disjoint unions on tuples.
43+
chunk[2].digest,
44+
// $FlowFixMe: Flow doesn't support disjoint unions on tuples.
45+
chunk[2].message || '',
46+
// $FlowFixMe: Flow doesn't support disjoint unions on tuples.
47+
chunk[2].stack || '',
48+
);
49+
} else {
50+
// $FlowFixMe: Flow doesn't support disjoint unions on tuples.
51+
resolveErrorProd(response, chunk[1], chunk[2].digest);
52+
}
3953
}
4054
}

packages/react-server-dom-relay/src/ReactFlightDOMRelayProtocol.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ export type RowEncoding =
2626
'E',
2727
number,
2828
{
29-
message: string,
30-
stack: string,
29+
digest: string,
30+
message?: string,
31+
stack?: string,
3132
...
3233
},
3334
];

packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,16 +60,48 @@ export function resolveModuleMetaData<T>(
6060

6161
export type Chunk = RowEncoding;
6262

63-
export function processErrorChunk(
63+
export function processErrorChunkProd(
6464
request: Request,
6565
id: number,
66+
digest: string,
67+
): Chunk {
68+
if (__DEV__) {
69+
// These errors should never make it into a build so we don't need to encode them in codes.json
70+
// eslint-disable-next-line react-internal/prod-error-codes
71+
throw new Error(
72+
'processErrorChunkProd should never be called while in development mode. Use processErrorChunkDev instead. This is a bug in React.',
73+
);
74+
}
75+
76+
return [
77+
'E',
78+
id,
79+
{
80+
digest,
81+
},
82+
];
83+
}
84+
85+
export function processErrorChunkDev(
86+
request: Request,
87+
id: number,
88+
digest: string,
6689
message: string,
6790
stack: string,
6891
): Chunk {
92+
if (!__DEV__) {
93+
// These errors should never make it into a build so we don't need to encode them in codes.json
94+
// eslint-disable-next-line react-internal/prod-error-codes
95+
throw new Error(
96+
'processErrorChunkDev should never be called while in production mode. Use processErrorChunkProd instead. This is a bug in React.',
97+
);
98+
}
99+
69100
return [
70101
'E',
71102
id,
72103
{
104+
digest,
73105
message,
74106
stack,
75107
},

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,13 @@ describe('ReactFlightDOM', () => {
332332

333333
function MyErrorBoundary({children}) {
334334
return (
335-
<ErrorBoundary fallback={e => <p>{e.message}</p>}>
335+
<ErrorBoundary
336+
fallback={e => (
337+
<p>
338+
{__DEV__ ? e.message + ' + ' : null}
339+
{e.digest}
340+
</p>
341+
)}>
336342
{children}
337343
</ErrorBoundary>
338344
);
@@ -434,6 +440,7 @@ describe('ReactFlightDOM', () => {
434440
{
435441
onError(x) {
436442
reportedErrors.push(x);
443+
return __DEV__ ? 'a dev digest' : `digest("${x.message}")`;
437444
},
438445
},
439446
);
@@ -477,11 +484,14 @@ describe('ReactFlightDOM', () => {
477484
await act(async () => {
478485
rejectGames(theError);
479486
});
487+
const expectedGamesValue = __DEV__
488+
? '<p>Game over + a dev digest</p>'
489+
: '<p>digest("Game over")</p>';
480490
expect(container.innerHTML).toBe(
481491
'<div>:name::avatar:</div>' +
482492
'<p>(loading sidebar)</p>' +
483493
'<p>(loading posts)</p>' +
484-
'<p>Game over</p>', // TODO: should not have message in prod.
494+
expectedGamesValue,
485495
);
486496

487497
expect(reportedErrors).toEqual([theError]);
@@ -495,7 +505,7 @@ describe('ReactFlightDOM', () => {
495505
'<div>:name::avatar:</div>' +
496506
'<div>:photos::friends:</div>' +
497507
'<p>(loading posts)</p>' +
498-
'<p>Game over</p>', // TODO: should not have message in prod.
508+
expectedGamesValue,
499509
);
500510

501511
// Show everything.
@@ -506,7 +516,7 @@ describe('ReactFlightDOM', () => {
506516
'<div>:name::avatar:</div>' +
507517
'<div>:photos::friends:</div>' +
508518
'<div>:posts:</div>' +
509-
'<p>Game over</p>', // TODO: should not have message in prod.
519+
expectedGamesValue,
510520
);
511521

512522
expect(reportedErrors).toEqual([]);
@@ -611,6 +621,8 @@ describe('ReactFlightDOM', () => {
611621
{
612622
onError(x) {
613623
reportedErrors.push(x);
624+
const message = typeof x === 'string' ? x : x.message;
625+
return __DEV__ ? 'a dev digest' : `digest("${message}")`;
614626
},
615627
},
616628
);
@@ -626,7 +638,13 @@ describe('ReactFlightDOM', () => {
626638

627639
await act(async () => {
628640
root.render(
629-
<ErrorBoundary fallback={e => <p>{e.message}</p>}>
641+
<ErrorBoundary
642+
fallback={e => (
643+
<p>
644+
{__DEV__ ? e.message + ' + ' : null}
645+
{e.digest}
646+
</p>
647+
)}>
630648
<Suspense fallback={<p>(loading)</p>}>
631649
<App res={response} />
632650
</Suspense>
@@ -638,7 +656,13 @@ describe('ReactFlightDOM', () => {
638656
await act(async () => {
639657
abort('for reasons');
640658
});
641-
expect(container.innerHTML).toBe('<p>Error: for reasons</p>');
659+
if (__DEV__) {
660+
expect(container.innerHTML).toBe(
661+
'<p>Error: for reasons + a dev digest</p>',
662+
);
663+
} else {
664+
expect(container.innerHTML).toBe('<p>digest("for reasons")</p>');
665+
}
642666

643667
expect(reportedErrors).toEqual(['for reasons']);
644668
});
@@ -772,7 +796,8 @@ describe('ReactFlightDOM', () => {
772796
webpackMap,
773797
{
774798
onError(x) {
775-
reportedErrors.push(x);
799+
reportedErrors.push(x.message);
800+
return __DEV__ ? 'a dev digest' : `digest("${x.message}")`;
776801
},
777802
},
778803
);
@@ -789,15 +814,27 @@ describe('ReactFlightDOM', () => {
789814

790815
await act(async () => {
791816
root.render(
792-
<ErrorBoundary fallback={e => <p>{e.message}</p>}>
817+
<ErrorBoundary
818+
fallback={e => (
819+
<p>
820+
{__DEV__ ? e.message + ' + ' : null}
821+
{e.digest}
822+
</p>
823+
)}>
793824
<Suspense fallback={<p>(loading)</p>}>
794825
<App res={response} />
795826
</Suspense>
796827
</ErrorBoundary>,
797828
);
798829
});
799-
expect(container.innerHTML).toBe('<p>bug in the bundler</p>');
830+
if (__DEV__) {
831+
expect(container.innerHTML).toBe(
832+
'<p>bug in the bundler + a dev digest</p>',
833+
);
834+
} else {
835+
expect(container.innerHTML).toBe('<p>digest("bug in the bundler")</p>');
836+
}
800837

801-
expect(reportedErrors).toEqual([]);
838+
expect(reportedErrors).toEqual(['bug in the bundler']);
802839
});
803840
});

0 commit comments

Comments
 (0)