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

[Fizz] Send errors down to client (#24551)

* use return from onError * export getSuspenseInstanceFallbackError * stringToChunk * return string from onError in downstream type signatures * 1 more type * support encoding errors in html stream and escape user input This commit adds another way to get errors to the suspense instance by encoding them as dataset properties of a template element at the head of the boundary. Previously if there was an error before the boundary flushed there was no way to stream the error to the client because there would never be a client render instruction. Additionally the error is sent in 3 parts 1) error hash - this is always sent (dev or prod) if one is provided 2) error message - Dev only 3) error component stack - Dev only, this now captures the stack at the point of error Another item addressed in this commit is the escaping of potentially unsafe data. all error components are escaped as test for browers when written into the html and as javascript strings when written into a client render instruction. * nits Co-authored-by: Marco Salazar <salazarm@fb.com>

Josh Story committed May 29, 2022 at 23:07 UTC aec575914a0fd76c9db8998ea62c3ee975de70d7
17 files changed +768 -77
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+418 -39
@@ -89,6 +89,48 @@ describe('ReactDOMFizzServer', () => {
89 });
90 });
91
92 + function expectErrors(errorsArr, toBeDevArr, toBeProdArr) {
93 + const mappedErrows = errorsArr.map(error => {
94 + if (error.componentStack) {
95 + return [
96 + error.message,
97 + error.hash,
98 + normalizeCodeLocInfo(error.componentStack),
99 + ];
100 + } else if (error.hash) {
101 + return [error.message, error.hash];
102 + }
103 + return error.message;
104 + });
105 + if (__DEV__) {
106 + expect(mappedErrows).toEqual(
107 + toBeDevArr,
108 + // .map(([errorMessage, errorHash, errorComponentStack]) => {
109 + // if (typeof error === 'string' || error instanceof String) {
110 + // return error;
111 + // }
112 + // let str = JSON.stringify(error).replace(/\\n/g, '\n');
113 + // // this gets stripped away by normalizeCodeLocInfo...
114 + // // Kind of hacky but lets strip it away here too just so they match...
115 + // // easier than fixing the regex to account for this edge case
116 + // if (str.endsWith('at **)"}')) {
117 + // str = str.replace(/at \*\*\)\"}$/, 'at **)');
118 + // }
119 + // return str;
120 + // }),
121 + );
122 + } else {
123 + expect(mappedErrows).toEqual(toBeProdArr);
124 + }
125 + }
126 +
127 + // @TODO we will use this in a followup change once we start exposing componentStacks from server errors
128 + // function componentStack(components) {
129 + // return components
130 + // .map(component => `\n in ${component} (at **)`)
131 + // .join('');
132 + // }
133 +
134 async function act(callback) {
135 await callback();
136 // Await one turn around the event loop.
@@ -413,8 +455,6 @@ describe('ReactDOMFizzServer', () => {
455 });
456 });
457
416 - const loggedErrors = [];
417 -
458 function App({isClient}) {
459 return (
460 <div>
@@ -426,24 +466,32 @@ describe('ReactDOMFizzServer', () => {
466 }
467
468 let bootstrapped = false;
469 + const errors = [];
470 window.__INIT__ = function() {
471 bootstrapped = true;
472 // Attempt to hydrate the content.
473 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
474 onRecoverableError(error) {
434 - Scheduler.unstable_yieldValue(error.message);
475 + errors.push(error);
476 },
477 });
478 };
479
480 + const theError = new Error('Test');
481 + const loggedErrors = [];
482 + function onError(x) {
483 + loggedErrors.push(x);
484 + return 'Hash of (' + x.message + ')';
485 + }
486 + // const expectedHash = onError(theError);
487 + // loggedErrors.length = 0;
488 +
489 await act(async () => {
490 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
491 <App isClient={false} />,
492 {
493 bootstrapScriptContent: '__INIT__();',
444 - onError(x) {
445 - loggedErrors.push(x);
446 - },
494 + onError,
495 },
496 );
497 pipe(writable);
@@ -458,7 +506,6 @@ describe('ReactDOMFizzServer', () => {
506
507 expect(loggedErrors).toEqual([]);
508
461 - const theError = new Error('Test');
509 await act(async () => {
510 rejectComponent(theError);
511 });
@@ -469,10 +516,14 @@ describe('ReactDOMFizzServer', () => {
516 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
517
518 // Now we can client render it instead.
472 - expect(Scheduler).toFlushAndYield([
473 - 'The server could not finish this Suspense boundary, likely due to ' +
474 - 'an error during server rendering. Switched to client rendering.',
475 - ]);
519 + expect(Scheduler).toFlushAndYield([]);
520 + expectErrors(
521 + errors,
522 + [theError.message],
523 + [
524 + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
525 + ],
526 + );
527
528 // The client rendered HTML is now in place.
529 expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
@@ -520,7 +571,14 @@ describe('ReactDOMFizzServer', () => {
571 });
572 });
573
574 + const theError = new Error('Test');
575 const loggedErrors = [];
576 + function onError(x) {
577 + loggedErrors.push(x);
578 + return 'hash of (' + x.message + ')';
579 + }
580 + // const expectedHash = onError(theError);
581 + // loggedErrors.length = 0;
582
583 function App({isClient}) {
584 return (
@@ -537,19 +595,18 @@ describe('ReactDOMFizzServer', () => {
595 <App isClient={false} />,
596
597 {
540 - onError(x) {
541 - loggedErrors.push(x);
542 - },
598 + onError,
599 },
600 );
601 pipe(writable);
602 });
603 expect(loggedErrors).toEqual([]);
604
605 + const errors = [];
606 // Attempt to hydrate the content.
607 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
608 onRecoverableError(error) {
552 - Scheduler.unstable_yieldValue(error.message);
609 + errors.push(error);
610 },
611 });
612 Scheduler.unstable_flushAll();
@@ -559,7 +616,6 @@ describe('ReactDOMFizzServer', () => {
616
617 expect(loggedErrors).toEqual([]);
618
562 - const theError = new Error('Test');
619 await act(async () => {
620 rejectElement(theError);
621 });
@@ -570,14 +626,161 @@ describe('ReactDOMFizzServer', () => {
626 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
627
628 // Now we can client render it instead.
573 - expect(Scheduler).toFlushAndYield([
574 - 'The server could not finish this Suspense boundary, likely due to ' +
575 - 'an error during server rendering. Switched to client rendering.',
576 - ]);
629 + expect(Scheduler).toFlushAndYield([]);
630 +
631 + expectErrors(
632 + errors,
633 + [theError.message],
634 + [
635 + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
636 + ],
637 + );
638
639 // The client rendered HTML is now in place.
579 - expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
640 + // expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
641 +
642 + expect(loggedErrors).toEqual([theError]);
643 + });
644 +
645 + // @gate experimental
646 + it('Errors in boundaries should be sent to the client and reported on client render - Error before flushing', async () => {
647 + function Indirection({level, children}) {
648 + if (level > 0) {
649 + return <Indirection level={level - 1}>{children}</Indirection>;
650 + }
651 + return children;
652 + }
653 +
654 + const theError = new Error('uh oh');
655 +
656 + function Erroring({isClient}) {
657 + if (isClient) {
658 + return 'Hello World';
659 + }
660 + throw theError;
661 + }
662 +
663 + function App({isClient}) {
664 + return (
665 + <div>
666 + <Suspense fallback={<span>loading...</span>}>
667 + <Erroring isClient={isClient} />
668 + </Suspense>
669 + </div>
670 + );
671 + }
672 +
673 + const loggedErrors = [];
674 + function onError(x) {
675 + loggedErrors.push(x);
676 + return 'hash(' + x.message + ')';
677 + }
678 + // const expectedHash = onError(theError);
679 + // loggedErrors.length = 0;
680 +
681 + await act(async () => {
682 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
683 + <App />,
684
685 + {
686 + onError,
687 + },
688 + );
689 + pipe(writable);
690 + });
691 + expect(loggedErrors).toEqual([theError]);
692 +
693 + const errors = [];
694 + // Attempt to hydrate the content.
695 + ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
696 + onRecoverableError(error) {
697 + errors.push(error);
698 + },
699 + });
700 + Scheduler.unstable_flushAll();
701 +
702 + expect(getVisibleChildren(container)).toEqual(<div>Hello World</div>);
703 +
704 + expectErrors(
705 + errors,
706 + [theError.message],
707 + [
708 + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
709 + ],
710 + );
711 + });
712 +
713 + // @gate experimental
714 + it('Errors in boundaries should be sent to the client and reported on client render - Error after flushing', async () => {
715 + let rejectComponent;
716 + const LazyComponent = React.lazy(() => {
717 + return new Promise((resolve, reject) => {
718 + rejectComponent = reject;
719 + });
720 + });
721 +
722 + function App({isClient}) {
723 + return (
724 + <div>
725 + <Suspense fallback={<Text text="Loading..." />}>
726 + {isClient ? <Text text="Hello" /> : <LazyComponent text="Hello" />}
727 + </Suspense>
728 + </div>
729 + );
730 + }
731 +
732 + const loggedErrors = [];
733 + const theError = new Error('uh oh');
734 + function onError(x) {
735 + loggedErrors.push(x);
736 + return 'hash(' + x.message + ')';
737 + }
738 + // const expectedHash = onError(theError);
739 + // loggedErrors.length = 0;
740 +
741 + await act(async () => {
742 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
743 + <App />,
744 +
745 + {
746 + onError,
747 + },
748 + );
749 + pipe(writable);
750 + });
751 + expect(loggedErrors).toEqual([]);
752 +
753 + const errors = [];
754 + // Attempt to hydrate the content.
755 + ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
756 + onRecoverableError(error) {
757 + errors.push(error);
758 + },
759 + });
760 + Scheduler.unstable_flushAll();
761 +
762 + expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
763 +
764 + await act(async () => {
765 + rejectComponent(theError);
766 + });
767 +
768 + expect(loggedErrors).toEqual([theError]);
769 + expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
770 +
771 + // Now we can client render it instead.
772 + expect(Scheduler).toFlushAndYield([]);
773 +
774 + expectErrors(
775 + errors,
776 + [theError.message],
777 + [
778 + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
779 + ],
780 + );
781 +
782 + // The client rendered HTML is now in place.
783 + expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
784 expect(loggedErrors).toEqual([theError]);
785 });
786
@@ -849,18 +1052,25 @@ describe('ReactDOMFizzServer', () => {
1052 );
1053 }
1054
1055 + const loggedErrors = [];
1056 + function onError(error) {
1057 + loggedErrors.push(error);
1058 + return `Hash of (${error.message})`;
1059 + }
1060 +
1061 let controls;
1062 await act(async () => {
854 - controls = ReactDOMFizzServer.renderToPipeableStream(<App />);
1063 + controls = ReactDOMFizzServer.renderToPipeableStream(<App />, {onError});
1064 controls.pipe(writable);
1065 });
1066
1067 // We're still showing a fallback.
1068
1069 + const errors = [];
1070 // Attempt to hydrate the content.
1071 ReactDOMClient.hydrateRoot(container, <App />, {
1072 onRecoverableError(error) {
863 - Scheduler.unstable_yieldValue(error.message);
1073 + errors.push(error);
1074 },
1075 });
1076 Scheduler.unstable_flushAll();
@@ -874,10 +1084,14 @@ describe('ReactDOMFizzServer', () => {
1084 });
1085
1086 // We still can't render it on the client.
877 - expect(Scheduler).toFlushAndYield([
878 - 'The server could not finish this Suspense boundary, likely due to an ' +
879 - 'error during server rendering. Switched to client rendering.',
880 - ]);
1087 + expect(Scheduler).toFlushAndYield([]);
1088 + expectErrors(
1089 + errors,
1090 + ['This Suspense boundary was aborted by the server'],
1091 + [
1092 + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
1093 + ],
1094 + );
1095 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
1096
1097 // We now resolve it on the client.
@@ -1535,16 +1749,22 @@ describe('ReactDOMFizzServer', () => {
1749 );
1750 }
1751
1752 + const theError = new Error('Test');
1753 const loggedErrors = [];
1754 + function onError(x) {
1755 + loggedErrors.push(x);
1756 + return `hash of (${x.message})`;
1757 + }
1758 + // const expectedHash = onError(theError);
1759 + // loggedErrors.length = 0;
1760 +
1761 let controls;
1762 await act(async () => {
1763 controls = ReactDOMFizzServer.renderToPipeableStream(
1764 <App isClient={false} />,
1765
1766 {
1545 - onError(x) {
1546 - loggedErrors.push(x);
1547 - },
1767 + onError,
1768 },
1769 );
1770 controls.pipe(writable);
@@ -1552,10 +1772,11 @@ describe('ReactDOMFizzServer', () => {
1772
1773 // We're still showing a fallback.
1774
1775 + const errors = [];
1776 // Attempt to hydrate the content.
1777 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
1778 onRecoverableError(error) {
1558 - Scheduler.unstable_yieldValue(error.message);
1779 + errors.push(error);
1780 },
1781 });
1782 Scheduler.unstable_flushAll();
@@ -1565,7 +1786,6 @@ describe('ReactDOMFizzServer', () => {
1786
1787 expect(loggedErrors).toEqual([]);
1788
1568 - const theError = new Error('Test');
1789 // Error the content, but we don't have a fallback yet.
1790 await act(async () => {
1791 rejectText('Hello', theError);
@@ -1586,10 +1806,14 @@ describe('ReactDOMFizzServer', () => {
1806 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
1807
1808 // That will let us client render it instead.
1589 - expect(Scheduler).toFlushAndYield([
1590 - 'The server could not finish this Suspense boundary, likely due to ' +
1591 - 'an error during server rendering. Switched to client rendering.',
1592 - ]);
1809 + expect(Scheduler).toFlushAndYield([]);
1810 + expectErrors(
1811 + errors,
1812 + [theError.message],
1813 + [
1814 + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
1815 + ],
1816 + );
1817
1818 // The client rendered HTML is now in place.
1819 expect(getVisibleChildren(container)).toEqual(
@@ -2178,11 +2402,10 @@ describe('ReactDOMFizzServer', () => {
2402
2403 // Hydrate the tree. Child will throw during render.
2404 isClient = true;
2405 + const errors = [];
2406 ReactDOMClient.hydrateRoot(container, <App />, {
2407 onRecoverableError(error) {
2183 - Scheduler.unstable_yieldValue(
2184 - 'Log recoverable error: ' + error.message,
2185 - );
2408 + errors.push(error.message);
2409 },
2410 });
2411
@@ -2190,6 +2413,8 @@ describe('ReactDOMFizzServer', () => {
2413 // shouldn't be called.
2414 expect(Scheduler).toFlushAndYield([]);
2415 expect(getVisibleChildren(container)).toEqual('Oops!');
2416 +
2417 + expectErrors(errors, [], []);
2418 },
2419 );
2420
@@ -2794,6 +3019,160 @@ describe('ReactDOMFizzServer', () => {
3019 );
3020 });
3021
3022 + describe('error escaping', () => {
3023 + //@gate experimental
3024 + it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => {
3025 + window.__outlet = {};
3026 +
3027 + const dangerousErrorString =
3028 + '"></template></div><script>window.__outlet.message="from error"</script><div><template data-foo="';
3029 +
3030 + function Erroring() {
3031 + throw new Error(dangerousErrorString);
3032 + }
3033 +
3034 + // We can't test newline in component stacks because the stack always takes just one line and we end up
3035 + // dropping the first part including the \n character
3036 + Erroring.displayName =
3037 + 'DangerousName' +
3038 + dangerousErrorString.replace(
3039 + 'message="from error"',
3040 + 'stack="from_stack"',
3041 + );
3042 +
3043 + function App() {
3044 + return (
3045 + <div>
3046 + <Suspense fallback={<div>Loading...</div>}>
3047 + <Erroring />
3048 + </Suspense>
3049 + </div>
3050 + );
3051 + }
3052 +
3053 + function onError(x) {
3054 + return `dangerous hash ${x.message.replace(
3055 + 'message="from error"',
3056 + 'hash="from hash"',
3057 + )}`;
3058 + }
3059 +
3060 + await act(async () => {
3061 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
3062 + onError,
3063 + });
3064 + pipe(writable);
3065 + });
3066 + expect(window.__outlet).toEqual({});
3067 + });
3068 + //@gate experimental
3069 + it('escapes error hash, message, and component stack values in clientRenderInstruction (javascript escaping)', async () => {
3070 + window.__outlet = {};
3071 +
3072 + const dangerousErrorString =
3073 + '");window.__outlet.message="from error";</script><script>(() => {})("';
3074 +
3075 + let rejectComponent;
3076 + const SuspensyErroring = React.lazy(() => {
3077 + return new Promise((resolve, reject) => {
3078 + rejectComponent = reject;
3079 + });
3080 + });
3081 +
3082 + // We can't test newline in component stacks because the stack always takes just one line and we end up
3083 + // dropping the first part including the \n character
3084 + SuspensyErroring.displayName =
3085 + 'DangerousName' +
3086 + dangerousErrorString.replace(
3087 + 'message="from error"',
3088 + 'stack="from_stack"',
3089 + );
3090 +
3091 + function App() {
3092 + return (
3093 + <div>
3094 + <Suspense fallback={<div>Loading...</div>}>
3095 + <SuspensyErroring />
3096 + </Suspense>
3097 + </div>
3098 + );
3099 + }
3100 +
3101 + function onError(x) {
3102 + return `dangerous hash ${x.message.replace(
3103 + 'message="from error"',
3104 + 'hash="from hash"',
3105 + )}`;
3106 + }
3107 +
3108 + await act(async () => {
3109 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
3110 + onError,
3111 + });
3112 + pipe(writable);
3113 + });
3114 +
3115 + await act(() => {
3116 + rejectComponent(new Error(dangerousErrorString));
3117 + });
3118 + expect(window.__outlet).toEqual({});
3119 + });
3120 + //@gate experimental
3121 + it('escapes such that attributes cannot be masked', async () => {
3122 + const dangerousErrorString = '" data-msg="bad message" data-foo="';
3123 + const theError = new Error(dangerousErrorString);
3124 +
3125 + function Erroring({isClient}) {
3126 + if (isClient) return 'Hello';
3127 + throw theError;
3128 + }
3129 +
3130 + function App({isClient}) {
3131 + return (
3132 + <div>
3133 + <Suspense fallback={<div>Loading...</div>}>
3134 + <Erroring isClient={isClient} />
3135 + </Suspense>
3136 + </div>
3137 + );
3138 + }
3139 +
3140 + const loggedErrors = [];
3141 + function onError(x) {
3142 + loggedErrors.push(x);
3143 + return x.message.replace('bad message', 'bad hash');
3144 + }
3145 + // const expectedHash = onError(theError);
3146 + // loggedErrors.length = 0;
3147 +
3148 + await act(async () => {
3149 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
3150 + onError,
3151 + });
3152 + pipe(writable);
3153 + });
3154 +
3155 + expect(loggedErrors).toEqual([theError]);
3156 +
3157 + const errors = [];
3158 + ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
3159 + onRecoverableError(error) {
3160 + errors.push(error);
3161 + },
3162 + });
3163 + expect(Scheduler).toFlushAndYield([]);
3164 +
3165 + // If escaping were not done we would get a message that says "bad hash"
3166 + expectErrors(
3167 + errors,
3168 + [theError.message],
3169 + [
3170 + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
3171 + ],
3172 + );
3173 + });
3174 + });
3175 +
3176 describe('bootstrapScriptContent escaping', () => {
3177 // @gate experimental
3178 it('the "S" in "</?[Ss]cript" strings are replaced with unicode escaped lowercase s or S depending on case, preserving case sensitivity of nearby characters', async () => {
packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
+21 -1
@@ -193,6 +193,7 @@ describe('ReactDOMFizzServer', () => {
193
194 // @gate experimental
195 it('should be able to complete by aborting even if the promise never resolves', async () => {
196 + const errors = [];
197 const controller = new AbortController();
198 const stream = await ReactDOMFizzServer.renderToReadableStream(
199 <div>
@@ -200,13 +201,22 @@ describe('ReactDOMFizzServer', () => {
201 <InfiniteSuspend />
202 </Suspense>
203 </div>,
203 - {signal: controller.signal},
204 + {
205 + signal: controller.signal,
206 + onError(x) {
207 + errors.push(x.message);
208 + },
209 + },
210 );
211
212 controller.abort();
213
214 const result = await readResult(stream);
215 expect(result).toContain('Loading');
216 +
217 + expect(errors).toEqual([
218 + 'This Suspense boundary was aborted by the server',
219 + ]);
220 });
221
222 // @gate experimental
@@ -223,12 +233,18 @@ describe('ReactDOMFizzServer', () => {
233 rendered = true;
234 return 'Done';
235 }
236 + const errors = [];
237 const stream = await ReactDOMFizzServer.renderToReadableStream(
238 <div>
239 <Suspense fallback={<div>Loading</div>}>
240 <Wait /> />
241 </Suspense>
242 </div>,
243 + {
244 + onError(x) {
245 + errors.push(x.message);
246 + },
247 + },
248 );
249
250 stream.allReady.then(() => (isComplete = true));
@@ -239,6 +255,10 @@ describe('ReactDOMFizzServer', () => {
255 const reader = stream.getReader();
256 reader.cancel();
257
258 + expect(errors).toEqual([
259 + 'This Suspense boundary was aborted by the server',
260 + ]);
261 +
262 hasLoaded = true;
263 resolve();
264
packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
+28 -2
@@ -211,7 +211,7 @@ describe('ReactDOMFizzServer', () => {
211
212 {
213 onError(x) {
214 - reportedErrors.push(x);
214 + reportedErrors.push(x.message);
215 },
216 onShellError(x) {
217 reportedShellErrors.push(x);
@@ -224,7 +224,10 @@ describe('ReactDOMFizzServer', () => {
224
225 expect(output.error).toBe(theError);
226 expect(output.result).toBe('');
227 - expect(reportedErrors).toEqual([theError]);
227 + expect(reportedErrors).toEqual([
228 + theError.message,
229 + 'This Suspense boundary was aborted by the server',
230 + ]);
231 expect(reportedShellErrors).toEqual([theError]);
232 });
233
@@ -289,6 +292,7 @@ describe('ReactDOMFizzServer', () => {
292 // @gate experimental
293 it('should be able to complete by aborting even if the promise never resolves', async () => {
294 let isCompleteCalls = 0;
295 + const errors = [];
296 const {writable, output, completed} = getTestWritable();
297 const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(
298 <div>
@@ -298,6 +302,9 @@ describe('ReactDOMFizzServer', () => {
302 </div>,
303
304 {
305 + onError(x) {
306 + errors.push(x.message);
307 + },
308 onAllReady() {
309 isCompleteCalls++;
310 },
@@ -314,6 +321,9 @@ describe('ReactDOMFizzServer', () => {
321
322 await completed;
323
324 + expect(errors).toEqual([
325 + 'This Suspense boundary was aborted by the server',
326 + ]);
327 expect(output.error).toBe(undefined);
328 expect(output.result).toContain('Loading');
329 expect(isCompleteCalls).toBe(1);
@@ -322,6 +332,7 @@ describe('ReactDOMFizzServer', () => {
332 // @gate experimental
333 it('should be able to complete by abort when the fallback is also suspended', async () => {
334 let isCompleteCalls = 0;
335 + const errors = [];
336 const {writable, output, completed} = getTestWritable();
337 const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(
338 <div>
@@ -333,6 +344,9 @@ describe('ReactDOMFizzServer', () => {
344 </div>,
345
346 {
347 + onError(x) {
348 + errors.push(x.message);
349 + },
350 onAllReady() {
351 isCompleteCalls++;
352 },
@@ -349,6 +363,11 @@ describe('ReactDOMFizzServer', () => {
363
364 await completed;
365
366 + expect(errors).toEqual([
367 + // There are two boundaries that abort
368 + 'This Suspense boundary was aborted by the server',
369 + 'This Suspense boundary was aborted by the server',
370 + ]);
371 expect(output.error).toBe(undefined);
372 expect(output.result).toContain('Loading');
373 expect(isCompleteCalls).toBe(1);
@@ -552,6 +571,7 @@ describe('ReactDOMFizzServer', () => {
571 rendered = true;
572 return 'Done';
573 }
574 + const errors = [];
575 const {writable, completed} = getTestWritable();
576 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
577 <div>
@@ -560,6 +580,9 @@ describe('ReactDOMFizzServer', () => {
580 </Suspense>
581 </div>,
582 {
583 + onError(x) {
584 + errors.push(x.message);
585 + },
586 onAllReady() {
587 isComplete = true;
588 },
@@ -579,6 +602,9 @@ describe('ReactDOMFizzServer', () => {
602
603 await completed;
604
605 + expect(errors).toEqual([
606 + 'This Suspense boundary was aborted by the server',
607 + ]);
608 expect(rendered).toBe(false);
609 expect(isComplete).toBe(true);
610 });
packages/react-dom/src/client/ReactDOMHostConfig.js
+24
@@ -729,6 +729,30 @@ export function isSuspenseInstancePending(instance: SuspenseInstance) {
729 export function isSuspenseInstanceFallback(instance: SuspenseInstance) {
730 return instance.data === SUSPENSE_FALLBACK_START_DATA;
731 }
732 +export function getSuspenseInstanceFallbackErrorDetails(
733 + instance: SuspenseInstance,
734 +) {
735 + const nextSibling = instance.nextSibling;
736 + let errorMessage /*, errorComponentStack, errorHash*/;
737 + if (
738 + nextSibling &&
739 + nextSibling.nodeType === ELEMENT_NODE &&
740 + nextSibling.nodeName.toLowerCase() === 'template'
741 + ) {
742 + const msg = ((nextSibling: any): HTMLTemplateElement).dataset.msg;
743 + if (msg !== null) errorMessage = msg;
744 +
745 + // @TODO read and return hash and componentStack once we know how we are goign to
746 + // expose this extra errorInfo to onRecoverableError
747 +
748 + // const hash = ((nextSibling: any): HTMLTemplateElement).dataset.hash;
749 + // if (hash !== null) errorHash = hash;
750 +
751 + // const stack = ((nextSibling: any): HTMLTemplateElement).dataset.stack;
752 + // if (stack !== null) errorComponentStack = stack;
753 + }
754 + return {errorMessage /*, errorComponentStack, errorHash*/};
755 +}
756
757 export function registerSuspenseInstanceRetry(
758 instance: SuspenseInstance,
packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+1 -1
@@ -32,7 +32,7 @@ type Options = {|
32 bootstrapModules?: Array<string>,
33 progressiveChunkSize?: number,
34 signal?: AbortSignal,
35 - onError?: (error: mixed) => void,
35 + onError?: (error: mixed) => ?string,
36 |};
37
38 // TODO: Move to sub-classing ReadableStream.
packages/react-dom/src/server/ReactDOMFizzServerNode.js
+1 -1
@@ -43,7 +43,7 @@ type Options = {|
43 onShellReady?: () => void,
44 onShellError?: (error: mixed) => void,
45 onAllReady?: () => void,
46 - onError?: (error: mixed) => void,
46 + onError?: (error: mixed) => ?string,
47 |};
48
49 type PipeableStream = {|
packages/react-dom/src/server/ReactDOMServerFormatConfig.js
+106 -4
@@ -1526,6 +1526,19 @@ const startClientRenderedSuspenseBoundary = stringToPrecomputedChunk(
1526 );
1527 const endSuspenseBoundary = stringToPrecomputedChunk('<!--/$-->');
1528
1529 +const clientRenderedSuspenseBoundaryError1 = stringToPrecomputedChunk(
1530 + '<template data-hash="',
1531 +);
1532 +const clientRenderedSuspenseBoundaryError1A = stringToPrecomputedChunk(
1533 + '" data-msg="',
1534 +);
1535 +const clientRenderedSuspenseBoundaryError1B = stringToPrecomputedChunk(
1536 + '" data-stack="',
1537 +);
1538 +const clientRenderedSuspenseBoundaryError2 = stringToPrecomputedChunk(
1539 + '"></template>',
1540 +);
1541 +
1542 export function pushStartCompletedSuspenseBoundary(
1543 target: Array<Chunk | PrecomputedChunk>,
1544 ) {
@@ -1563,8 +1576,43 @@ export function writeStartPendingSuspenseBoundary(
1576 export function writeStartClientRenderedSuspenseBoundary(
1577 destination: Destination,
1578 responseState: ResponseState,
1579 + errorHash: ?string,
1580 + errorMesssage: ?string,
1581 + errorComponentStack: ?string,
1582 ): boolean {
1567 - return writeChunkAndReturn(destination, startClientRenderedSuspenseBoundary);
1583 + let result;
1584 + result = writeChunkAndReturn(
1585 + destination,
1586 + startClientRenderedSuspenseBoundary,
1587 + );
1588 + if (errorHash) {
1589 + writeChunk(destination, clientRenderedSuspenseBoundaryError1);
1590 + writeChunk(destination, stringToChunk(escapeTextForBrowser(errorHash)));
1591 + // In prod errorMessage will usually be nullish but there is one case where
1592 + // it is used (currently when the server aborts the task) so we leave it ungated.
1593 + if (errorMesssage) {
1594 + writeChunk(destination, clientRenderedSuspenseBoundaryError1A);
1595 + writeChunk(
1596 + destination,
1597 + stringToChunk(escapeTextForBrowser(errorMesssage)),
1598 + );
1599 + }
1600 + if (__DEV__) {
1601 + // Component stacks are currently only captured in dev
1602 + if (errorComponentStack) {
1603 + writeChunk(destination, clientRenderedSuspenseBoundaryError1B);
1604 + writeChunk(
1605 + destination,
1606 + stringToChunk(escapeTextForBrowser(errorComponentStack)),
1607 + );
1608 + }
1609 + }
1610 + result = writeChunkAndReturn(
1611 + destination,
1612 + clientRenderedSuspenseBoundaryError2,
1613 + );
1614 + }
1615 + return result;
1616 }
1617 export function writeEndCompletedSuspenseBoundary(
1618 destination: Destination,
@@ -1724,7 +1772,7 @@ export function writeEndSegment(
1772 // const SUSPENSE_PENDING_START_DATA = '$?';
1773 // const SUSPENSE_FALLBACK_START_DATA = '$!';
1774 //
1727 -// function clientRenderBoundary(suspenseBoundaryID) {
1775 +// function clientRenderBoundary(suspenseBoundaryID, errorHash, errorMsg, errorComponentStack) {
1776 // // Find the fallback's first element.
1777 // const suspenseIdNode = document.getElementById(suspenseBoundaryID);
1778 // if (!suspenseIdNode) {
@@ -1736,6 +1784,11 @@ export function writeEndSegment(
1784 // const suspenseNode = suspenseIdNode.previousSibling;
1785 // // Tag it to be client rendered.
1786 // suspenseNode.data = SUSPENSE_FALLBACK_START_DATA;
1787 +// // assign error metadata to first sibling
1788 +// let dataset = suspenseIdNode.dataset;
1789 +// if (errorHash) dataset.hash = errorHash;
1790 +// if (errorMsg) dataset.msg = errorMsg;
1791 +// if (errorComponentStack) dataset.stack = errorComponentStack;
1792 // // Tell React to retry it if the parent already hydrated.
1793 // if (suspenseNode._reactRetry) {
1794 // suspenseNode._reactRetry();
@@ -1823,7 +1876,7 @@ const completeSegmentFunction =
1876 const completeBoundaryFunction =
1877 'function $RC(a,b){a=document.getElementById(a);b=document.getElementById(b);b.parentNode.removeChild(b);if(a){a=a.previousSibling;var f=a.parentNode,c=a.nextSibling,e=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d)if(0===e)break;else e--;else"$"!==d&&"$?"!==d&&"$!"!==d||e++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;b.firstChild;)f.insertBefore(b.firstChild,c);a.data="$";a._reactRetry&&a._reactRetry()}}';
1878 const clientRenderFunction =
1826 - 'function $RX(a){if(a=document.getElementById(a))a=a.previousSibling,a.data="$!",a._reactRetry&&a._reactRetry()}';
1879 + 'function $RX(b,c,d,e){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,c&&(a.hash=c),d&&(a.msg=d),e&&(a.stack=e),b._reactRetry&&b._reactRetry())}';
1880
1881 const completeSegmentScript1Full = stringToPrecomputedChunk(
1882 completeSegmentFunction + ';$RS("',
@@ -1896,12 +1949,17 @@ const clientRenderScript1Full = stringToPrecomputedChunk(
1949 clientRenderFunction + ';$RX("',
1950 );
1951 const clientRenderScript1Partial = stringToPrecomputedChunk('$RX("');
1899 -const clientRenderScript2 = stringToPrecomputedChunk('")</script>');
1952 +const clientRenderScript1A = stringToPrecomputedChunk('"');
1953 +const clientRenderScript2 = stringToPrecomputedChunk(')</script>');
1954 +const clientRenderErrorScriptArgInterstitial = stringToPrecomputedChunk(',');
1955
1956 export function writeClientRenderBoundaryInstruction(
1957 destination: Destination,
1958 responseState: ResponseState,
1959 boundaryID: SuspenseBoundaryID,
1960 + errorHash: ?string,
1961 + errorMessage?: string,
1962 + errorComponentStack?: string,
1963 ): boolean {
1964 writeChunk(destination, responseState.startInlineScript);
1965 if (!responseState.sentClientRenderFunction) {
@@ -1920,5 +1978,49 @@ export function writeClientRenderBoundaryInstruction(
1978 }
1979
1980 writeChunk(destination, boundaryID);
1981 + writeChunk(destination, clientRenderScript1A);
1982 + if (errorHash || errorMessage || errorComponentStack) {
1983 + writeChunk(destination, clientRenderErrorScriptArgInterstitial);
1984 + writeChunk(
1985 + destination,
1986 + stringToChunk(escapeJSStringsForInstructionScripts(errorHash || '')),
1987 + );
1988 + }
1989 + if (errorMessage || errorComponentStack) {
1990 + writeChunk(destination, clientRenderErrorScriptArgInterstitial);
1991 + writeChunk(
1992 + destination,
1993 + stringToChunk(escapeJSStringsForInstructionScripts(errorMessage || '')),
1994 + );
1995 + }
1996 + if (errorComponentStack) {
1997 + writeChunk(destination, clientRenderErrorScriptArgInterstitial);
1998 + writeChunk(
1999 + destination,
2000 + stringToChunk(escapeJSStringsForInstructionScripts(errorComponentStack)),
2001 + );
2002 + }
2003 return writeChunkAndReturn(destination, clientRenderScript2);
2004 }
2005 +
2006 +const regexForJSStringsInScripts = /[<\u2028\u2029]/g;
2007 +function escapeJSStringsForInstructionScripts(input: string): string {
2008 + const escaped = JSON.stringify(input);
2009 + return escaped.replace(regexForJSStringsInScripts, match => {
2010 + switch (match) {
2011 + // santizing breaking out of strings and script tags
2012 + case '<':
2013 + return '\\u003c';
2014 + case '\u2028':
2015 + return '\\u2028';
2016 + case '\u2029':
2017 + return '\\u2029';
2018 + default: {
2019 + // eslint-disable-next-line react-internal/prod-error-codes
2020 + throw new Error(
2021 + 'escapeJSStringsForInstructionScripts encountered a match it does not know how to replace. this means the match regex and the replacement characters are no longer in sync. This is a bug in React',
2022 + );
2023 + }
2024 + }
2025 + });
2026 +}
packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js
+4
@@ -148,6 +148,10 @@ export function writeStartCompletedSuspenseBoundary(
148 export function writeStartClientRenderedSuspenseBoundary(
149 destination: Destination,
150 responseState: ResponseState,
151 + // flushing these error arguments are not currently supported in this legacy streaming format.
152 + errorHash: ?string,
153 + errorMessage?: string,
154 + errorComponentStack?: string,
155 ): boolean {
156 if (responseState.generateStaticMarkup) {
157 // A client rendered boundary is done and doesn't need a representation in the HTML
packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js
+8
@@ -225,6 +225,10 @@ export function writeStartPendingSuspenseBoundary(
225 export function writeStartClientRenderedSuspenseBoundary(
226 destination: Destination,
227 responseState: ResponseState,
228 + // TODO: encode error for native
229 + errorHash: ?string,
230 + errorMessage: ?string,
231 + errorComponentStack: ?string,
232 ): boolean {
233 return writeChunkAndReturn(destination, SUSPENSE_CLIENT_RENDER);
234 }
@@ -295,6 +299,10 @@ export function writeClientRenderBoundaryInstruction(
299 destination: Destination,
300 responseState: ResponseState,
301 boundaryID: SuspenseBoundaryID,
302 + // TODO: encode error for native
303 + errorHash: ?string,
304 + errorMessage: ?string,
305 + errorComponentStack: ?string,
306 ): boolean {
307 writeChunk(destination, SUSPENSE_UPDATE_TO_CLIENT_RENDER);
308 return writeChunkAndReturn(destination, formatID(boundaryID));
packages/react-noop-renderer/src/ReactNoopServer.js
+1 -1
@@ -267,7 +267,7 @@ type Options = {
267 progressiveChunkSize?: number,
268 onShellReady?: () => void,
269 onAllReady?: () => void,
270 - onError?: (error: mixed) => void,
270 + onError?: (error: mixed) => ?string,
271 };
272
273 function render(children: React$Element<any>, options?: Options): Destination {
packages/react-reconciler/src/ReactFiberBeginWork.new.js
+13 -8
@@ -157,6 +157,7 @@ import {
157 shouldSetTextContent,
158 isSuspenseInstancePending,
159 isSuspenseInstanceFallback,
160 + getSuspenseInstanceFallbackErrorDetails,
161 registerSuspenseInstanceRetry,
162 supportsHydration,
163 isPrimaryRenderer,
@@ -2572,18 +2573,22 @@ function updateDehydratedSuspenseComponent(
2573 // This boundary is in a permanent fallback state. In this case, we'll never
2574 // get an update and we'll never be able to hydrate the final content. Let's just try the
2575 // client side render instead.
2576 + const {errorMessage} = getSuspenseInstanceFallbackErrorDetails(
2577 + suspenseInstance,
2578 + );
2579 + const error = errorMessage
2580 + ? // eslint-disable-next-line react-internal/prod-error-codes
2581 + new Error(errorMessage)
2582 + : new Error(
2583 + 'The server could not finish this Suspense boundary, likely ' +
2584 + 'due to an error during server rendering. Switched to ' +
2585 + 'client rendering.',
2586 + );
2587 return retrySuspenseComponentWithoutHydrating(
2588 current,
2589 workInProgress,
2590 renderLanes,
2579 - // TODO: The server should serialize the error message so we can log it
2580 - // here on the client. Or, in production, a hash/id that corresponds to
2581 - // the error.
2582 - new Error(
2583 - 'The server could not finish this Suspense boundary, likely ' +
2584 - 'due to an error during server rendering. Switched to ' +
2585 - 'client rendering.',
2586 - ),
2591 + error,
2592 );
2593 }
2594
packages/react-reconciler/src/ReactFiberBeginWork.old.js
+13 -8
@@ -157,6 +157,7 @@ import {
157 shouldSetTextContent,
158 isSuspenseInstancePending,
159 isSuspenseInstanceFallback,
160 + getSuspenseInstanceFallbackErrorDetails,
161 registerSuspenseInstanceRetry,
162 supportsHydration,
163 isPrimaryRenderer,
@@ -2572,18 +2573,22 @@ function updateDehydratedSuspenseComponent(
2573 // This boundary is in a permanent fallback state. In this case, we'll never
2574 // get an update and we'll never be able to hydrate the final content. Let's just try the
2575 // client side render instead.
2576 + const {errorMessage} = getSuspenseInstanceFallbackErrorDetails(
2577 + suspenseInstance,
2578 + );
2579 + const error = errorMessage
2580 + ? // eslint-disable-next-line react-internal/prod-error-codes
2581 + new Error(errorMessage)
2582 + : new Error(
2583 + 'The server could not finish this Suspense boundary, likely ' +
2584 + 'due to an error during server rendering. Switched to ' +
2585 + 'client rendering.',
2586 + );
2587 return retrySuspenseComponentWithoutHydrating(
2588 current,
2589 workInProgress,
2590 renderLanes,
2579 - // TODO: The server should serialize the error message so we can log it
2580 - // here on the client. Or, in production, a hash/id that corresponds to
2581 - // the error.
2582 - new Error(
2583 - 'The server could not finish this Suspense boundary, likely ' +
2584 - 'due to an error during server rendering. Switched to ' +
2585 - 'client rendering.',
2586 - ),
2591 + error,
2592 );
2593 }
2594
packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js
+1
@@ -26,6 +26,7 @@ export const canHydrateTextInstance = shim;
26 export const canHydrateSuspenseInstance = shim;
27 export const isSuspenseInstancePending = shim;
28 export const isSuspenseInstanceFallback = shim;
29 +export const getSuspenseInstanceFallbackErrorDetails = shim;
30 export const registerSuspenseInstanceRetry = shim;
31 export const getNextHydratableSibling = shim;
32 export const getFirstHydratableChild = shim;
packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js
+2
@@ -136,6 +136,8 @@ export const isSuspenseInstancePending =
136 $$$hostConfig.isSuspenseInstancePending;
137 export const isSuspenseInstanceFallback =
138 $$$hostConfig.isSuspenseInstanceFallback;
139 +export const getSuspenseInstanceFallbackErrorDetails =
140 + $$$hostConfig.getSuspenseInstanceFallbackErrorDetails;
141 export const registerSuspenseInstanceRetry =
142 $$$hostConfig.registerSuspenseInstanceRetry;
143 export const getNextHydratableSibling = $$$hostConfig.getNextHydratableSibling;
packages/react-server-dom-relay/src/__tests__/ReactDOMServerFB-test.internal.js
+6 -1
@@ -169,6 +169,7 @@ describe('ReactDOMServerFB', () => {
169 });
170
171 it('should be able to complete by aborting even if the promise never resolves', () => {
172 + const errors = [];
173 const stream = ReactDOMServer.renderToStream(
174 <div>
175 <Suspense fallback={<div>Loading</div>}>
@@ -177,7 +178,7 @@ describe('ReactDOMServerFB', () => {
178 </div>,
179 {
180 onError(x) {
180 - console.error(x);
181 + errors.push(x.message);
182 },
183 },
184 );
@@ -189,5 +190,9 @@ describe('ReactDOMServerFB', () => {
190
191 const remaining = readResult(stream);
192 expect(remaining).toEqual('');
193 +
194 + expect(errors).toEqual([
195 + 'This Suspense boundary was aborted by the server',
196 + ]);
197 });
198 });
packages/react-server/src/ReactFizzServer.js
+119 -10
@@ -131,6 +131,9 @@ type LegacyContext = {
131 type SuspenseBoundary = {
132 id: SuspenseBoundaryID,
133 rootSegmentID: number,
134 + errorHash: ?string, // the error hash if it errors
135 + errorMessage?: string, // the error string if it errors
136 + errorComponentStack?: string, // the error component stack if it errors
137 forceClientRender: boolean, // if it errors or infinitely suspends
138 parentFlushed: boolean,
139 pendingTasks: number, // when it reaches zero we can show this boundary's content
@@ -196,7 +199,9 @@ export opaque type Request = {
199 completedBoundaries: Array<SuspenseBoundary>, // Completed but not yet fully flushed boundaries to show.
200 partialBoundaries: Array<SuspenseBoundary>, // Partially completed boundaries that can flush its segments early.
201 // onError is called when an error happens anywhere in the tree. It might recover.
199 - onError: (error: mixed) => void,
202 + // The return string is used in production primarily to avoid leaking internals, secondarily to save bytes.
203 + // Returning null/undefined will cause a defualt error message in production
204 + onError: (error: mixed) => ?string,
205 // onAllReady is called when all pending task is done but it may not have flushed yet.
206 // This is a good time to start writing if you want only HTML and no intermediate steps.
207 onAllReady: () => void,
@@ -229,6 +234,7 @@ const DEFAULT_PROGRESSIVE_CHUNK_SIZE = 12800;
234
235 function defaultErrorHandler(error: mixed) {
236 console['error'](error); // Don't transform to our wrapper
237 + return null;
238 }
239
240 function noop(): void {}
@@ -238,7 +244,7 @@ export function createRequest(
244 responseState: ResponseState,
245 rootFormatContext: FormatContext,
246 progressiveChunkSize: void | number,
241 - onError: void | ((error: mixed) => void),
247 + onError: void | ((error: mixed) => ?string),
248 onAllReady: void | (() => void),
249 onShellReady: void | (() => void),
250 onShellError: void | ((error: mixed) => void),
@@ -317,6 +323,7 @@ function createSuspenseBoundary(
323 completedSegments: [],
324 byteSize: 0,
325 fallbackAbortableTasks,
326 + errorHash: null,
327 };
328 }
329
@@ -426,11 +433,44 @@ function popComponentStackInDEV(task: Task): void {
433 }
434 }
435
429 -function logRecoverableError(request: Request, error: mixed): void {
436 +// stash the component stack of an unwinding error until it is processed
437 +let lastBoundaryErrorComponentStackDev: ?string = null;
438 +
439 +function captureBoundaryErrorDetailsDev(
440 + boundary: SuspenseBoundary,
441 + error: mixed,
442 +) {
443 + if (__DEV__) {
444 + let errorMessage;
445 + if (typeof error === 'string') {
446 + errorMessage = error;
447 + } else if (error && typeof error.message === 'string') {
448 + errorMessage = error.message;
449 + } else {
450 + // eslint-disable-next-line react-internal/safe-string-coercion
451 + errorMessage = String(error);
452 + }
453 +
454 + const errorComponentStack =
455 + lastBoundaryErrorComponentStackDev || getCurrentStackInDEV();
456 + lastBoundaryErrorComponentStackDev = null;
457 +
458 + boundary.errorMessage = errorMessage;
459 + boundary.errorComponentStack = errorComponentStack;
460 + }
461 +}
462 +
463 +function logRecoverableError(request: Request, error: any): ?string {
464 // If this callback errors, we intentionally let that error bubble up to become a fatal error
465 // so that someone fixes the error reporting instead of hiding it.
432 - const onError = request.onError;
433 - onError(error);
466 + const errorHash = request.onError(error);
467 + if (errorHash != null && typeof errorHash !== 'string') {
468 + // eslint-disable-next-line react-internal/prod-error-codes
469 + throw new Error(
470 + `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 errorHash}" instead`,
471 + );
472 + }
473 + return errorHash;
474 }
475
476 function fatalError(request: Request, error: mixed): void {
@@ -527,8 +567,12 @@ function renderSuspenseBoundary(
567 }
568 } catch (error) {
569 contentRootSegment.status = ERRORED;
530 - logRecoverableError(request, error);
570 newBoundary.forceClientRender = true;
571 + newBoundary.errorHash = logRecoverableError(request, error);
572 + if (__DEV__) {
573 + captureBoundaryErrorDetailsDev(newBoundary, error);
574 + }
575 +
576 // We don't need to decrement any task numbers because we didn't spawn any new task.
577 // We don't need to schedule any task because we know the parent has written yet.
578 // We do need to fallthrough to create the fallback though.
@@ -1165,9 +1209,38 @@ function validateIterable(iterable, iteratorFn: Function): void {
1209 }
1210 }
1211
1212 +function renderNodeDestructive(
1213 + request: Request,
1214 + task: Task,
1215 + node: ReactNodeList,
1216 +): void {
1217 + if (__DEV__) {
1218 + // In Dev we wrap renderNodeDestructiveImpl in a try / catch so we can capture
1219 + // a component stack at the right place in the tree. We don't do this in renderNode
1220 + // becuase it is not called at every layer of the tree and we may lose frames
1221 + try {
1222 + return renderNodeDestructiveImpl(request, task, node);
1223 + } catch (x) {
1224 + if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
1225 + // This is a Wakable, noop
1226 + } else {
1227 + // This is an error, stash the component stack if it is null.
1228 + lastBoundaryErrorComponentStackDev =
1229 + lastBoundaryErrorComponentStackDev !== null
1230 + ? lastBoundaryErrorComponentStackDev
1231 + : getCurrentStackInDEV();
1232 + }
1233 + // rethrow so normal suspense logic can handle thrown value accordingly
1234 + throw x;
1235 + }
1236 + } else {
1237 + return renderNodeDestructiveImpl(request, task, node);
1238 + }
1239 +}
1240 +
1241 // This function by it self renders a node and consumes the task by mutating it
1242 // to update the current execution state.
1170 -function renderNodeDestructive(
1243 +function renderNodeDestructiveImpl(
1244 request: Request,
1245 task: Task,
1246 node: ReactNodeList,
@@ -1197,7 +1270,27 @@ function renderNodeDestructive(
1270 const lazyNode: LazyComponentType<any, any> = (node: any);
1271 const payload = lazyNode._payload;
1272 const init = lazyNode._init;
1200 - const resolvedNode = init(payload);
1273 + let resolvedNode;
1274 + if (__DEV__) {
1275 + try {
1276 + resolvedNode = init(payload);
1277 + } catch (x) {
1278 + if (
1279 + typeof x === 'object' &&
1280 + x !== null &&
1281 + typeof x.then === 'function'
1282 + ) {
1283 + // this Lazy initializer is suspending. push a temporary frame onto the stack so it can be
1284 + // popped off in spawnNewSuspendedTask. This aligns stack behavior between Lazy in element position
1285 + // vs Component position. We do not want the frame for Errors so we exclusively do this in
1286 + // the wakeable branch
1287 + pushBuiltInComponentStackInDEV(task, 'Lazy');
1288 + }
1289 + throw x;
1290 + }
1291 + } else {
1292 + resolvedNode = init(payload);
1293 + }
1294 renderNodeDestructive(request, task, resolvedNode);
1295 return;
1296 }
@@ -1395,13 +1488,17 @@ function erroredTask(
1488 error: mixed,
1489 ) {
1490 // Report the error to a global handler.
1398 - logRecoverableError(request, error);
1491 + const errorHash = logRecoverableError(request, error);
1492 if (boundary === null) {
1493 fatalError(request, error);
1494 } else {
1495 boundary.pendingTasks--;
1496 if (!boundary.forceClientRender) {
1497 boundary.forceClientRender = true;
1498 + boundary.errorHash = errorHash;
1499 + if (__DEV__) {
1500 + captureBoundaryErrorDetailsDev(boundary, error);
1501 + }
1502
1503 // Regardless of what happens next, this boundary won't be displayed,
1504 // so we can flush it, if the parent already flushed.
@@ -1456,6 +1553,13 @@ function abortTask(task: Task): void {
1553
1554 if (!boundary.forceClientRender) {
1555 boundary.forceClientRender = true;
1556 + const error = new Error(
1557 + 'This Suspense boundary was aborted by the server',
1558 + );
1559 + boundary.errorHash = request.onError(error);
1560 + if (__DEV__) {
1561 + captureBoundaryErrorDetailsDev(boundary, error);
1562 + }
1563 if (boundary.parentFlushed) {
1564 request.clientRenderedBoundaries.push(boundary);
1565 }
@@ -1734,8 +1838,10 @@ function flushSegment(
1838 writeStartClientRenderedSuspenseBoundary(
1839 destination,
1840 request.responseState,
1841 + boundary.errorHash,
1842 + boundary.errorMessage,
1843 + boundary.errorComponentStack,
1844 );
1738 -
1845 // Flush the fallback.
1846 flushSubtree(request, destination, segment);
1847
@@ -1815,6 +1921,9 @@ function flushClientRenderedBoundary(
1921 destination,
1922 request.responseState,
1923 boundary.id,
1924 + boundary.errorHash,
1925 + boundary.errorMessage,
1926 + boundary.errorComponentStack,
1927 );
1928 }
1929
scripts/error-codes/codes.json
+2 -1
@@ -416,5 +416,6 @@
416 "428": "useServerContext is only supported while rendering.",
417 "429": "ServerContext: %s already defined",
418 "430": "ServerContext can only have a value prop and children. Found: %s",
419 - "431": "React elements are not allowed in ServerContext"
419 + "431": "React elements are not allowed in ServerContext",
420 + "432": "This Suspense boundary was aborted by the server"
421 }