[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
Josh Story committed
Sep 23, 2022 at 13:19 UTC
efc6a08e98ce6546f41f90ac519b2faf94ae04b3
14 files changed
+413
-76
packages/react-client/src/ReactFlightClient.js
+48
-5
@@ -193,7 +193,7 @@ function createBlockedChunk<T>(response: Response): BlockedChunk<T> {
193
194
function createErrorChunk<T>(
195
response: Response,
196
- error: Error,
196
+ error: ErrorWithDigest,
197
): ErroredChunk<T> {
198
// $FlowFixMe Flow doesn't support functions as constructors
199
return new Chunk(ERRORED, null, error, response);
@@ -628,21 +628,64 @@ export function resolveSymbol(
628
chunks.set(id, createInitializedChunk(response, Symbol.for(name)));
629
}
630
631
-export function resolveError(
631
+type ErrorWithDigest = Error & {digest?: string};
632
+export function resolveErrorProd(
633
response: Response,
634
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,
665
message: string,
666
stack: string,
667
): 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
+ }
675
// 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
+ );
680
error.stack = stack;
681
+ (error: any).digest = digest;
682
+ const errorWithDigest: ErrorWithDigest = (error: any);
683
const chunks = response._chunks;
684
const chunk = chunks.get(id);
685
if (!chunk) {
643
- chunks.set(id, createErrorChunk(response, error));
686
+ chunks.set(id, createErrorChunk(response, errorWithDigest));
687
} else {
645
- triggerErrorOnChunk(chunk, error);
688
+ triggerErrorOnChunk(chunk, errorWithDigest);
689
}
690
}
691
packages/react-client/src/ReactFlightClientStream.js
+13
-2
@@ -16,7 +16,8 @@ import {
16
resolveModel,
17
resolveProvider,
18
resolveSymbol,
19
- resolveError,
19
+ resolveErrorProd,
20
+ resolveErrorDev,
21
createResponse as createResponseBase,
22
parseModelString,
23
parseModelTuple,
@@ -62,7 +63,17 @@ function processFullRow(response: Response, row: string): void {
63
}
64
case 'E': {
65
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
+ }
77
return;
78
}
79
default: {
packages/react-client/src/__tests__/ReactFlight-test.js
+15
-3
@@ -45,7 +45,19 @@ describe('ReactFlight', () => {
45
componentDidMount() {
46
expect(this.state.hasError).toBe(true);
47
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
+ }
61
}
62
render() {
63
if (this.state.hasError) {
@@ -371,8 +383,8 @@ describe('ReactFlight', () => {
383
}
384
385
const options = {
374
- onError() {
375
- // ignore
386
+ onError(x) {
387
+ return __DEV__ ? 'a dev digest' : `digest("${x.message}")`;
388
},
389
};
390
const event = ReactNoopFlightServer.render(<EventHandlerProp />, options);
packages/react-server-dom-relay/src/ReactFlightDOMRelayClient.js
+17
-3
@@ -16,7 +16,8 @@ import {
16
resolveModel,
17
resolveModule,
18
resolveSymbol,
19
- resolveError,
19
+ resolveErrorDev,
20
+ resolveErrorProd,
21
close,
22
getRoot,
23
} from 'react-client/src/ReactFlightClient';
@@ -34,7 +35,20 @@ export function resolveRow(response: Response, chunk: RowEncoding): void {
35
// $FlowFixMe: Flow doesn't support disjoint unions on tuples.
36
resolveSymbol(response, chunk[1], chunk[2]);
37
} 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
+ }
53
}
54
}
packages/react-server-dom-relay/src/ReactFlightDOMRelayProtocol.js
+3
-2
@@ -26,8 +26,9 @@ export type RowEncoding =
26
'E',
27
number,
28
{
29
- message: string,
30
- stack: string,
29
+ digest: string,
30
+ message?: string,
31
+ stack?: string,
32
...
33
},
34
];
packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js
+33
-1
@@ -60,16 +60,48 @@ export function resolveModuleMetaData<T>(
60
61
export type Chunk = RowEncoding;
62
63
-export function processErrorChunk(
63
+export function processErrorChunkProd(
64
request: Request,
65
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,
89
message: string,
90
stack: string,
91
): 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
+
100
return [
101
'E',
102
id,
103
{
104
+ digest,
105
message,
106
stack,
107
},
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+47
-10
@@ -332,7 +332,13 @@ describe('ReactFlightDOM', () => {
332
333
function MyErrorBoundary({children}) {
334
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
+ )}>
342
{children}
343
</ErrorBoundary>
344
);
@@ -434,6 +440,7 @@ describe('ReactFlightDOM', () => {
440
{
441
onError(x) {
442
reportedErrors.push(x);
443
+ return __DEV__ ? 'a dev digest' : `digest("${x.message}")`;
444
},
445
},
446
);
@@ -477,11 +484,14 @@ describe('ReactFlightDOM', () => {
484
await act(async () => {
485
rejectGames(theError);
486
});
487
+ const expectedGamesValue = __DEV__
488
+ ? '<p>Game over + a dev digest</p>'
489
+ : '<p>digest("Game over")</p>';
490
expect(container.innerHTML).toBe(
491
'<div>:name::avatar:</div>' +
492
'<p>(loading sidebar)</p>' +
493
'<p>(loading posts)</p>' +
484
- '<p>Game over</p>', // TODO: should not have message in prod.
494
+ expectedGamesValue,
495
);
496
497
expect(reportedErrors).toEqual([theError]);
@@ -495,7 +505,7 @@ describe('ReactFlightDOM', () => {
505
'<div>:name::avatar:</div>' +
506
'<div>:photos::friends:</div>' +
507
'<p>(loading posts)</p>' +
498
- '<p>Game over</p>', // TODO: should not have message in prod.
508
+ expectedGamesValue,
509
);
510
511
// Show everything.
@@ -506,7 +516,7 @@ describe('ReactFlightDOM', () => {
516
'<div>:name::avatar:</div>' +
517
'<div>:photos::friends:</div>' +
518
'<div>:posts:</div>' +
509
- '<p>Game over</p>', // TODO: should not have message in prod.
519
+ expectedGamesValue,
520
);
521
522
expect(reportedErrors).toEqual([]);
@@ -611,6 +621,8 @@ describe('ReactFlightDOM', () => {
621
{
622
onError(x) {
623
reportedErrors.push(x);
624
+ const message = typeof x === 'string' ? x : x.message;
625
+ return __DEV__ ? 'a dev digest' : `digest("${message}")`;
626
},
627
},
628
);
@@ -626,7 +638,13 @@ describe('ReactFlightDOM', () => {
638
639
await act(async () => {
640
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
+ )}>
648
<Suspense fallback={<p>(loading)</p>}>
649
<App res={response} />
650
</Suspense>
@@ -638,7 +656,13 @@ describe('ReactFlightDOM', () => {
656
await act(async () => {
657
abort('for reasons');
658
});
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
+ }
666
667
expect(reportedErrors).toEqual(['for reasons']);
668
});
@@ -772,7 +796,8 @@ describe('ReactFlightDOM', () => {
796
webpackMap,
797
{
798
onError(x) {
775
- reportedErrors.push(x);
799
+ reportedErrors.push(x.message);
800
+ return __DEV__ ? 'a dev digest' : `digest("${x.message}")`;
801
},
802
},
803
);
@@ -789,15 +814,27 @@ describe('ReactFlightDOM', () => {
814
815
await act(async () => {
816
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
+ )}>
824
<Suspense fallback={<p>(loading)</p>}>
825
<App res={response} />
826
</Suspense>
827
</ErrorBoundary>,
828
);
829
});
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
+ }
837
801
- expect(reportedErrors).toEqual([]);
838
+ expect(reportedErrors).toEqual(['bug in the bundler']);
839
});
840
});
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+60
-10
@@ -173,11 +173,27 @@ describe('ReactFlightDOMBrowser', () => {
173
}
174
}
175
176
+ let errorBoundaryFn;
177
+ if (__DEV__) {
178
+ errorBoundaryFn = e => (
179
+ <p>
180
+ {e.message} + {e.digest}
181
+ </p>
182
+ );
183
+ } else {
184
+ errorBoundaryFn = e => {
185
+ expect(e.message).toBe(
186
+ 'An error occurred in the Server Components render. The specific message is omitted in production' +
187
+ ' builds to avoid leaking sensitive details. A digest property is included on this error instance which' +
188
+ ' may provide additional details about the nature of the error.',
189
+ );
190
+ return <p>{e.digest}</p>;
191
+ };
192
+ }
193
+
194
function MyErrorBoundary({children}) {
195
return (
178
- <ErrorBoundary fallback={e => <p>{e.message}</p>}>
179
- {children}
180
- </ErrorBoundary>
196
+ <ErrorBoundary fallback={errorBoundaryFn}>{children}</ErrorBoundary>
197
);
198
}
199
@@ -251,6 +267,7 @@ describe('ReactFlightDOMBrowser', () => {
267
{
268
onError(x) {
269
reportedErrors.push(x);
270
+ return __DEV__ ? `a dev digest` : `digest("${x.message}")`;
271
},
272
},
273
);
@@ -293,11 +310,16 @@ describe('ReactFlightDOMBrowser', () => {
310
await act(async () => {
311
rejectGames(theError);
312
});
313
+
314
+ const gamesExpectedValue = __DEV__
315
+ ? '<p>Game over + a dev digest</p>'
316
+ : '<p>digest("Game over")</p>';
317
+
318
expect(container.innerHTML).toBe(
319
'<div>:name::avatar:</div>' +
320
'<p>(loading sidebar)</p>' +
321
'<p>(loading posts)</p>' +
300
- '<p>Game over</p>', // TODO: should not have message in prod.
322
+ gamesExpectedValue,
323
);
324
325
expect(reportedErrors).toEqual([theError]);
@@ -311,7 +333,7 @@ describe('ReactFlightDOMBrowser', () => {
333
'<div>:name::avatar:</div>' +
334
'<div>:photos::friends:</div>' +
335
'<p>(loading posts)</p>' +
314
- '<p>Game over</p>', // TODO: should not have message in prod.
336
+ gamesExpectedValue,
337
);
338
339
// Show everything.
@@ -322,7 +344,7 @@ describe('ReactFlightDOMBrowser', () => {
344
'<div>:name::avatar:</div>' +
345
'<div>:photos::friends:</div>' +
346
'<div>:posts:</div>' +
325
- '<p>Game over</p>', // TODO: should not have message in prod.
347
+ gamesExpectedValue,
348
);
349
350
expect(reportedErrors).toEqual([]);
@@ -489,6 +511,24 @@ describe('ReactFlightDOMBrowser', () => {
511
it('should be able to complete after aborting and throw the reason client-side', async () => {
512
const reportedErrors = [];
513
514
+ let errorBoundaryFn;
515
+ if (__DEV__) {
516
+ errorBoundaryFn = e => (
517
+ <p>
518
+ {e.message} + {e.digest}
519
+ </p>
520
+ );
521
+ } else {
522
+ errorBoundaryFn = e => {
523
+ expect(e.message).toBe(
524
+ 'An error occurred in the Server Components render. The specific message is omitted in production' +
525
+ ' builds to avoid leaking sensitive details. A digest property is included on this error instance which' +
526
+ ' may provide additional details about the nature of the error.',
527
+ );
528
+ return <p>{e.digest}</p>;
529
+ };
530
+ }
531
+
532
class ErrorBoundary extends React.Component {
533
state = {hasError: false, error: null};
534
static getDerivedStateFromError(error) {
@@ -514,7 +554,9 @@ describe('ReactFlightDOMBrowser', () => {
554
{
555
signal: controller.signal,
556
onError(x) {
557
+ const message = typeof x === 'string' ? x : x.message;
558
reportedErrors.push(x);
559
+ return __DEV__ ? 'a dev digest' : `digest("${message}")`;
560
},
561
},
562
);
@@ -529,7 +571,7 @@ describe('ReactFlightDOMBrowser', () => {
571
572
await act(async () => {
573
root.render(
532
- <ErrorBoundary fallback={e => <p>{e.message}</p>}>
574
+ <ErrorBoundary fallback={errorBoundaryFn}>
575
<Suspense fallback={<p>(loading)</p>}>
576
<App res={response} />
577
</Suspense>
@@ -545,7 +587,10 @@ describe('ReactFlightDOMBrowser', () => {
587
controller.signal.reason = 'for reasons';
588
controller.abort('for reasons');
589
});
548
- expect(container.innerHTML).toBe('<p>Error: for reasons</p>');
590
+ const expectedValue = __DEV__
591
+ ? '<p>Error: for reasons + a dev digest</p>'
592
+ : '<p>digest("for reasons")</p>';
593
+ expect(container.innerHTML).toBe(expectedValue);
594
595
expect(reportedErrors).toEqual(['for reasons']);
596
});
@@ -665,6 +710,7 @@ describe('ReactFlightDOMBrowser', () => {
710
{
711
onError(x) {
712
reportedErrors.push(x);
713
+ return __DEV__ ? 'a dev digest' : `digest("${x.message}")`;
714
},
715
},
716
);
@@ -677,7 +723,9 @@ describe('ReactFlightDOMBrowser', () => {
723
}
724
render() {
725
if (this.state.error) {
680
- return this.state.error.message;
726
+ return __DEV__
727
+ ? this.state.error.message + ' + ' + this.state.error.digest
728
+ : this.state.error.digest;
729
}
730
return this.props.children;
731
}
@@ -696,7 +744,9 @@ describe('ReactFlightDOMBrowser', () => {
744
</ErrorBoundary>,
745
);
746
});
699
- expect(container.innerHTML).toBe('Oops!');
747
+ expect(container.innerHTML).toBe(
748
+ __DEV__ ? 'Oops! + a dev digest' : 'digest("Oops!")',
749
+ );
750
expect(reportedErrors.length).toBe(1);
751
expect(reportedErrors[0].message).toBe('Oops!');
752
});
packages/react-server-native-relay/src/ReactFlightNativeRelayClient.js
+17
-3
@@ -16,7 +16,8 @@ import {
16
resolveModel,
17
resolveModule,
18
resolveSymbol,
19
- resolveError,
19
+ resolveErrorDev,
20
+ resolveErrorProd,
21
close,
22
getRoot,
23
} from 'react-client/src/ReactFlightClient';
@@ -34,7 +35,20 @@ export function resolveRow(response: Response, chunk: RowEncoding): void {
35
// $FlowFixMe: Flow doesn't support disjoint unions on tuples.
36
resolveSymbol(response, chunk[1], chunk[2]);
37
} 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
+ }
53
}
54
}
packages/react-server-native-relay/src/ReactFlightNativeRelayProtocol.js
+3
-2
@@ -26,8 +26,9 @@ export type RowEncoding =
26
'E',
27
number,
28
{
29
- message: string,
30
- stack: string,
29
+ digest: string,
30
+ message?: string,
31
+ stack?: string,
32
...
33
},
34
];
packages/react-server-native-relay/src/ReactFlightNativeRelayServerHostConfig.js
+32
-1
@@ -57,16 +57,47 @@ export function resolveModuleMetaData<T>(
57
58
export type Chunk = RowEncoding;
59
60
-export function processErrorChunk(
60
+export function processErrorChunkProd(
61
request: Request,
62
id: number,
63
+ digest: string,
64
+): Chunk {
65
+ if (__DEV__) {
66
+ // These errors should never make it into a build so we don't need to encode them in codes.json
67
+ // eslint-disable-next-line react-internal/prod-error-codes
68
+ throw new Error(
69
+ 'processErrorChunkProd should never be called while in development mode. Use processErrorChunkDev instead. This is a bug in React.',
70
+ );
71
+ }
72
+
73
+ return [
74
+ 'E',
75
+ id,
76
+ {
77
+ digest,
78
+ },
79
+ ];
80
+}
81
+export function processErrorChunkDev(
82
+ request: Request,
83
+ id: number,
84
+ digest: string,
85
message: string,
86
stack: string,
87
): Chunk {
88
+ if (!__DEV__) {
89
+ // These errors should never make it into a build so we don't need to encode them in codes.json
90
+ // eslint-disable-next-line react-internal/prod-error-codes
91
+ throw new Error(
92
+ 'processErrorChunkDev should never be called while in production mode. Use processErrorChunkProd instead. This is a bug in React.',
93
+ );
94
+ }
95
+
96
return [
97
'E',
98
id,
99
{
100
+ digest,
101
message,
102
stack,
103
},
packages/react-server/src/ReactFlightServer.js
+94
-31
@@ -35,7 +35,8 @@ import {
35
processModuleChunk,
36
processProviderChunk,
37
processSymbolChunk,
38
- processErrorChunk,
38
+ processErrorChunkProd,
39
+ processErrorChunkDev,
40
processReferenceChunk,
41
resolveModuleMetaData,
42
getModuleKey,
@@ -125,7 +126,7 @@ export type Request = {
126
writtenProviders: Map<string, number>,
127
identifierPrefix: string,
128
identifierCount: number,
128
- onError: (error: mixed) => void,
129
+ onError: (error: mixed) => ?string,
130
toJSON: (key: string, value: ReactModel) => ReactJSONValue,
131
};
132
@@ -143,7 +144,7 @@ const CLOSED = 2;
144
export function createRequest(
145
model: ReactModel,
146
bundlerConfig: BundlerConfig,
146
- onError: void | ((error: mixed) => void),
147
+ onError: void | ((error: mixed) => ?string),
148
context?: Array<[string, ServerContextJSONValue]>,
149
identifierPrefix?: string,
150
): Request {
@@ -364,7 +365,13 @@ function serializeModuleReference(
365
} catch (x) {
366
request.pendingChunks++;
367
const errorId = request.nextChunkId++;
367
- emitErrorChunk(request, errorId, x);
368
+ const digest = logRecoverableError(request, x);
369
+ if (__DEV__) {
370
+ const {message, stack} = getErrorMessageAndStackDev(x);
371
+ emitErrorChunkDev(request, errorId, digest, message, stack);
372
+ } else {
373
+ emitErrorChunkProd(request, errorId, digest);
374
+ }
375
return serializeByValueID(errorId);
376
}
377
}
@@ -629,7 +636,13 @@ export function resolveModelToJSON(
636
// once it gets rendered.
637
request.pendingChunks++;
638
const errorId = request.nextChunkId++;
632
- emitErrorChunk(request, errorId, x);
639
+ const digest = logRecoverableError(request, x);
640
+ if (__DEV__) {
641
+ const {message, stack} = getErrorMessageAndStackDev(x);
642
+ emitErrorChunkDev(request, errorId, digest, message, stack);
643
+ } else {
644
+ emitErrorChunkProd(request, errorId, digest);
645
+ }
646
return serializeByRefID(errorId);
647
}
648
}
@@ -797,9 +810,47 @@ export function resolveModelToJSON(
810
);
811
}
812
800
-function logRecoverableError(request: Request, error: mixed): void {
813
+function logRecoverableError(request: Request, error: mixed): string {
814
const onError = request.onError;
802
- onError(error);
815
+ const errorDigest = onError(error);
816
+ if (errorDigest != null && typeof errorDigest !== 'string') {
817
+ // eslint-disable-next-line react-internal/prod-error-codes
818
+ throw new Error(
819
+ `onError returned something with a type other than "string". onError should return a string and may return null or undefined but must not return anything else. It received something of type "${typeof errorDigest}" instead`,
820
+ );
821
+ }
822
+ return errorDigest || '';
823
+}
824
+
825
+function getErrorMessageAndStackDev(
826
+ error: mixed,
827
+): {message: string, stack: string} {
828
+ if (__DEV__) {
829
+ let message;
830
+ let stack = '';
831
+ try {
832
+ if (error instanceof Error) {
833
+ // eslint-disable-next-line react-internal/safe-string-coercion
834
+ message = String(error.message);
835
+ // eslint-disable-next-line react-internal/safe-string-coercion
836
+ stack = String(error.stack);
837
+ } else {
838
+ message = 'Error: ' + (error: any);
839
+ }
840
+ } catch (x) {
841
+ message = 'An error occurred but serializing the error message failed.';
842
+ }
843
+ return {
844
+ message,
845
+ stack,
846
+ };
847
+ } else {
848
+ // These errors should never make it into a build so we don't need to encode them in codes.json
849
+ // eslint-disable-next-line react-internal/prod-error-codes
850
+ throw new Error(
851
+ 'getErrorMessageAndStackDev should never be called from production mode. This is a bug in React.',
852
+ );
853
+ }
854
}
855
856
function fatalError(request: Request, error: mixed): void {
@@ -813,26 +864,29 @@ function fatalError(request: Request, error: mixed): void {
864
}
865
}
866
816
-function emitErrorChunk(request: Request, id: number, error: mixed): void {
817
- // TODO: We should not leak error messages to the client in prod.
818
- // Give this an error code instead and log on the server.
819
- // We can serialize the error in DEV as a convenience.
820
- let message;
821
- let stack = '';
822
- try {
823
- if (error instanceof Error) {
824
- // eslint-disable-next-line react-internal/safe-string-coercion
825
- message = String(error.message);
826
- // eslint-disable-next-line react-internal/safe-string-coercion
827
- stack = String(error.stack);
828
- } else {
829
- message = 'Error: ' + (error: any);
830
- }
831
- } catch (x) {
832
- message = 'An error occurred but serializing the error message failed.';
833
- }
867
+function emitErrorChunkProd(
868
+ request: Request,
869
+ id: number,
870
+ digest: string,
871
+): void {
872
+ const processedChunk = processErrorChunkProd(request, id, digest);
873
+ request.completedErrorChunks.push(processedChunk);
874
+}
875
835
- const processedChunk = processErrorChunk(request, id, message, stack);
876
+function emitErrorChunkDev(
877
+ request: Request,
878
+ id: number,
879
+ digest: string,
880
+ message: string,
881
+ stack: string,
882
+): void {
883
+ const processedChunk = processErrorChunkDev(
884
+ request,
885
+ id,
886
+ digest,
887
+ message,
888
+ stack,
889
+ );
890
request.completedErrorChunks.push(processedChunk);
891
}
892
@@ -935,9 +989,13 @@ function retryTask(request: Request, task: Task): void {
989
} else {
990
request.abortableTasks.delete(task);
991
task.status = ERRORED;
938
- logRecoverableError(request, x);
939
- // This errored, we need to serialize this error to the
940
- emitErrorChunk(request, task.id, x);
992
+ const digest = logRecoverableError(request, x);
993
+ if (__DEV__) {
994
+ const {message, stack} = getErrorMessageAndStackDev(x);
995
+ emitErrorChunkDev(request, task.id, digest, message, stack);
996
+ } else {
997
+ emitErrorChunkProd(request, task.id, digest);
998
+ }
999
}
1000
}
1001
}
@@ -1077,10 +1135,15 @@ export function abort(request: Request, reason: mixed): void {
1135
? new Error('The render was aborted by the server without a reason.')
1136
: reason;
1137
1080
- logRecoverableError(request, error);
1138
+ const digest = logRecoverableError(request, error);
1139
request.pendingChunks++;
1140
const errorId = request.nextChunkId++;
1083
- emitErrorChunk(request, errorId, error);
1141
+ if (__DEV__) {
1142
+ const {message, stack} = getErrorMessageAndStackDev(error);
1143
+ emitErrorChunkDev(request, errorId, digest, message, stack);
1144
+ } else {
1145
+ emitErrorChunkProd(request, errorId, digest);
1146
+ }
1147
abortableTasks.forEach(task => abortTask(task, request, errorId));
1148
abortableTasks.clear();
1149
}
packages/react-server/src/ReactFlightServerConfigStream.js
+29
-2
@@ -78,13 +78,40 @@ function serializeRowHeader(tag: string, id: number) {
78
return tag + id.toString(16) + ':';
79
}
80
81
-export function processErrorChunk(
81
+export function processErrorChunkProd(
82
request: Request,
83
id: number,
84
+ digest: string,
85
+): Chunk {
86
+ if (__DEV__) {
87
+ // These errors should never make it into a build so we don't need to encode them in codes.json
88
+ // eslint-disable-next-line react-internal/prod-error-codes
89
+ throw new Error(
90
+ 'processErrorChunkProd should never be called while in development mode. Use processErrorChunkDev instead. This is a bug in React.',
91
+ );
92
+ }
93
+
94
+ const errorInfo: any = {digest};
95
+ const row = serializeRowHeader('E', id) + stringify(errorInfo) + '\n';
96
+ return stringToChunk(row);
97
+}
98
+
99
+export function processErrorChunkDev(
100
+ request: Request,
101
+ id: number,
102
+ digest: string,
103
message: string,
104
stack: string,
105
): Chunk {
87
- const errorInfo = {message, stack};
106
+ if (!__DEV__) {
107
+ // These errors should never make it into a build so we don't need to encode them in codes.json
108
+ // eslint-disable-next-line react-internal/prod-error-codes
109
+ throw new Error(
110
+ 'processErrorChunkDev should never be called while in production mode. Use processErrorChunkProd instead. This is a bug in React.',
111
+ );
112
+ }
113
+
114
+ const errorInfo: any = {digest, message, stack};
115
const row = serializeRowHeader('E', id) + stringify(errorInfo) + '\n';
116
return stringToChunk(row);
117
}
scripts/error-codes/codes.json
+2
-1
@@ -425,5 +425,6 @@
425
"437": "the \"precedence\" prop for links to stylesheets expects to receive a string but received something of type \"%s\" instead.",
426
"438": "An unsupported type was passed to use(): %s",
427
"439": "We didn't expect to see a forward reference. This is a bug in the React Server.",
428
- "440": "An event from useEvent was called during render."
428
+ "440": "An event from useEvent was called during render.",
429
+ "441": "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."
430
}