@samitouri / QOS-React-2 / commits / efc6a08e98

[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 }