@samitouri / QOS-React / commits / 51c8411d9d

Log a recoverable error whenever hydration fails (#23319)

There are several cases where hydration fails, server-rendered HTML is discarded, and we fall back to client rendering. Whenever this happens, we will now log an error with onRecoverableError, with a message explaining why. In some of these scenarios, this is not the only recoverable error that is logged. For example, an error during hydration will cause hydration to fail, which is itself an error. So we end up logging two separate errors: the original error, and one that explains why hydration failed. I've made sure that the original error always gets logged first, to preserve the causal sequence. Another thing we could do is aggregate the errors with the Error "cause" feature and AggregateError. Since these are new-ish features in JavaScript, we'd need a fallback behavior. I'll leave this for a follow up.

Andrew Clark committed Feb 17, 2022 at 15:16 UTC 51c8411d9dab33290b794fe716b9514e7db1fb68
11 files changed +315 -41
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+69 -15
@@ -358,7 +358,11 @@ describe('ReactDOMFizzServer', () => {
358 window.__INIT__ = function() {
359 bootstrapped = true;
360 // Attempt to hydrate the content.
361 - ReactDOM.hydrateRoot(container, <App isClient={true} />);
361 + ReactDOM.hydrateRoot(container, <App isClient={true} />, {
362 + onRecoverableError(error) {
363 + Scheduler.unstable_yieldValue(error.message);
364 + },
365 + });
366 };
367
368 await act(async () => {
@@ -394,7 +398,10 @@ describe('ReactDOMFizzServer', () => {
398 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
399
400 // Now we can client render it instead.
397 - Scheduler.unstable_flushAll();
401 + expect(Scheduler).toFlushAndYield([
402 + 'The server could not finish this Suspense boundary, likely due to ' +
403 + 'an error during server rendering. Switched to client rendering.',
404 + ]);
405
406 // The client rendered HTML is now in place.
407 expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
@@ -465,7 +472,11 @@ describe('ReactDOMFizzServer', () => {
472 expect(loggedErrors).toEqual([]);
473
474 // Attempt to hydrate the content.
468 - ReactDOM.hydrateRoot(container, <App isClient={true} />);
475 + ReactDOM.hydrateRoot(container, <App isClient={true} />, {
476 + onRecoverableError(error) {
477 + Scheduler.unstable_yieldValue(error.message);
478 + },
479 + });
480 Scheduler.unstable_flushAll();
481
482 // We're still loading because we're waiting for the server to stream more content.
@@ -484,7 +495,10 @@ describe('ReactDOMFizzServer', () => {
495 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
496
497 // Now we can client render it instead.
487 - Scheduler.unstable_flushAll();
498 + expect(Scheduler).toFlushAndYield([
499 + 'The server could not finish this Suspense boundary, likely due to ' +
500 + 'an error during server rendering. Switched to client rendering.',
501 + ]);
502
503 // The client rendered HTML is now in place.
504 expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
@@ -766,7 +780,11 @@ describe('ReactDOMFizzServer', () => {
780 // We're still showing a fallback.
781
782 // Attempt to hydrate the content.
769 - ReactDOM.hydrateRoot(container, <App />);
783 + ReactDOM.hydrateRoot(container, <App />, {
784 + onRecoverableError(error) {
785 + Scheduler.unstable_yieldValue(error.message);
786 + },
787 + });
788 Scheduler.unstable_flushAll();
789
790 // We're still loading because we're waiting for the server to stream more content.
@@ -778,7 +796,10 @@ describe('ReactDOMFizzServer', () => {
796 });
797
798 // We still can't render it on the client.
781 - Scheduler.unstable_flushAll();
799 + expect(Scheduler).toFlushAndYield([
800 + 'The server could not finish this Suspense boundary, likely due to an ' +
801 + 'error during server rendering. Switched to client rendering.',
802 + ]);
803 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
804
805 // We now resolve it on the client.
@@ -1455,7 +1476,11 @@ describe('ReactDOMFizzServer', () => {
1476 // We're still showing a fallback.
1477
1478 // Attempt to hydrate the content.
1458 - ReactDOM.hydrateRoot(container, <App isClient={true} />);
1479 + ReactDOM.hydrateRoot(container, <App isClient={true} />, {
1480 + onRecoverableError(error) {
1481 + Scheduler.unstable_yieldValue(error.message);
1482 + },
1483 + });
1484 Scheduler.unstable_flushAll();
1485
1486 // We're still loading because we're waiting for the server to stream more content.
@@ -1484,7 +1509,10 @@ describe('ReactDOMFizzServer', () => {
1509 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
1510
1511 // That will let us client render it instead.
1487 - Scheduler.unstable_flushAll();
1512 + expect(Scheduler).toFlushAndYield([
1513 + 'The server could not finish this Suspense boundary, likely due to ' +
1514 + 'an error during server rendering. Switched to client rendering.',
1515 + ]);
1516
1517 // The client rendered HTML is now in place.
1518 expect(getVisibleChildren(container)).toEqual(
@@ -1736,8 +1764,11 @@ describe('ReactDOMFizzServer', () => {
1764 // The first paint switches to client rendering due to mismatch
1765 expect(Scheduler).toFlushUntilNextPaint([
1766 'client',
1739 - 'Log recoverable error: An error occurred during hydration. ' +
1740 - 'The server HTML was replaced with client content',
1767 + 'Log recoverable error: Hydration failed because the initial ' +
1768 + 'UI does not match what was rendered on the server.',
1769 + 'Log recoverable error: There was an error while hydrating. ' +
1770 + 'Because the error happened outside of a Suspense boundary, the ' +
1771 + 'entire root will switch to client rendering.',
1772 ]);
1773 }).toErrorDev(
1774 [
@@ -1834,8 +1865,11 @@ describe('ReactDOMFizzServer', () => {
1865 // The first paint switches to client rendering due to mismatch
1866 expect(Scheduler).toFlushUntilNextPaint([
1867 'client',
1837 - 'Log recoverable error: An error occurred during hydration. ' +
1838 - 'The server HTML was replaced with client content',
1868 + 'Log recoverable error: Hydration failed because the initial ' +
1869 + 'UI does not match what was rendered on the server.',
1870 + 'Log recoverable error: There was an error while hydrating. ' +
1871 + 'Because the error happened outside of a Suspense boundary, the ' +
1872 + 'entire root will switch to client rendering.',
1873 ]);
1874 }).toErrorDev(
1875 [
@@ -1928,7 +1962,13 @@ describe('ReactDOMFizzServer', () => {
1962 // An error logged but instead of surfacing it to the UI, we switched
1963 // to client rendering.
1964 expect(() => {
1931 - expect(Scheduler).toFlushAndYield(['Yay!', 'Hydration error']);
1965 + expect(Scheduler).toFlushAndYield([
1966 + 'Yay!',
1967 + 'Hydration error',
1968 + 'There was an error while hydrating. Because the error happened ' +
1969 + 'outside of a Suspense boundary, the entire root will switch ' +
1970 + 'to client rendering.',
1971 + ]);
1972 }).toErrorDev(
1973 'An error occurred during hydration. The server HTML was replaced',
1974 {withoutStack: true},
@@ -2012,7 +2052,11 @@ describe('ReactDOMFizzServer', () => {
2052
2053 // An error logged but instead of surfacing it to the UI, we switched
2054 // to client rendering.
2015 - expect(Scheduler).toFlushAndYield(['Yay!', 'Hydration error']);
2055 + expect(Scheduler).toFlushAndYield([
2056 + 'Yay!',
2057 + 'Hydration error',
2058 + 'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
2059 + ]);
2060 expect(getVisibleChildren(container)).toEqual(
2061 <div>
2062 <span />
@@ -2178,7 +2222,11 @@ describe('ReactDOMFizzServer', () => {
2222
2223 // An error logged but instead of surfacing it to the UI, we switched
2224 // to client rendering.
2181 - expect(Scheduler).toFlushAndYield(['Hydration error']);
2225 + expect(Scheduler).toFlushAndYield([
2226 + 'Hydration error',
2227 + 'There was an error while hydrating this Suspense boundary. Switched ' +
2228 + 'to client rendering.',
2229 + ]);
2230 expect(getVisibleChildren(container)).toEqual(
2231 <div>
2232 <span />
@@ -2328,8 +2376,14 @@ describe('ReactDOMFizzServer', () => {
2376 expect(Scheduler).toFlushAndYield([
2377 'A',
2378 'B',
2379 +
2380 'Logged recoverable error: Hydration error',
2381 + 'Logged recoverable error: There was an error while hydrating this ' +
2382 + 'Suspense boundary. Switched to client rendering.',
2383 +
2384 'Logged recoverable error: Hydration error',
2385 + 'Logged recoverable error: There was an error while hydrating this ' +
2386 + 'Suspense boundary. Switched to client rendering.',
2387 ]);
2388 });
2389 });
packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js
+10 -2
@@ -232,7 +232,11 @@ describe('ReactDOMFizzShellHydration', () => {
232
233 // Hydration suspends because the data for the shell hasn't loaded yet
234 const root = await clientAct(async () => {
235 - return ReactDOM.hydrateRoot(container, <App />);
235 + return ReactDOM.hydrateRoot(container, <App />, {
236 + onRecoverableError(error) {
237 + Scheduler.unstable_yieldValue(error.message);
238 + },
239 + });
240 });
241 expect(Scheduler).toHaveYielded(['Suspend! [Shell]']);
242 expect(container.textContent).toBe('Shell');
@@ -240,7 +244,11 @@ describe('ReactDOMFizzShellHydration', () => {
244 await clientAct(async () => {
245 root.render(<Text text="New screen" />);
246 });
243 - expect(Scheduler).toHaveYielded(['New screen']);
247 + expect(Scheduler).toHaveYielded([
248 + 'This root received an early update, before anything was able ' +
249 + 'hydrate. Switched the entire root to client rendering.',
250 + 'New screen',
251 + ]);
252 expect(container.textContent).toBe('New screen');
253 });
254 });
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+126 -19
@@ -348,7 +348,8 @@ describe('ReactDOMServerPartialHydration', () => {
348 'Component',
349
350 // Hydration mismatch is logged
351 - 'An error occurred during hydration. The server HTML was replaced with client content',
351 + 'Hydration failed because the initial UI does not match what was rendered on the server.',
352 + 'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
353 ]);
354
355 // Client rendered - suspense comment nodes removed
@@ -432,8 +433,11 @@ describe('ReactDOMServerPartialHydration', () => {
433 onDeleted(node) {
434 deleted.push(node);
435 },
436 + onRecoverableError(error) {
437 + Scheduler.unstable_yieldValue(error.message);
438 + },
439 });
436 - Scheduler.unstable_flushAll();
440 + expect(Scheduler).toFlushAndYield([]);
441
442 expect(hydrated.length).toBe(0);
443 expect(deleted.length).toBe(0);
@@ -453,6 +457,12 @@ describe('ReactDOMServerPartialHydration', () => {
457
458 Scheduler.unstable_flushAll();
459 jest.runAllTimers();
460 + expect(Scheduler).toHaveYielded([
461 + 'This Suspense boundary received an update before it finished ' +
462 + 'hydrating. This caused the boundary to switch to client rendering. ' +
463 + 'The usual way to fix this is to wrap the original update ' +
464 + 'in startTransition.',
465 + ]);
466
467 expect(hydrated.length).toBe(1);
468 expect(deleted.length).toBe(1);
@@ -507,7 +517,11 @@ describe('ReactDOMServerPartialHydration', () => {
517
518 expect(() => {
519 act(() => {
510 - ReactDOM.hydrateRoot(container, <App hasB={false} />);
520 + ReactDOM.hydrateRoot(container, <App hasB={false} />, {
521 + onRecoverableError(error) {
522 + Scheduler.unstable_yieldValue(error.message);
523 + },
524 + });
525 });
526 }).toErrorDev('Did not expect server HTML to contain a <span> in <div>');
527
@@ -517,6 +531,10 @@ describe('ReactDOMServerPartialHydration', () => {
531 expect(container.innerHTML).not.toContain('<span>B</span>');
532
533 if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
534 + expect(Scheduler).toHaveYielded([
535 + 'There was an error while hydrating this Suspense boundary. ' +
536 + 'Switched to client rendering.',
537 + ]);
538 expect(ref.current).not.toBe(span);
539 } else {
540 expect(ref.current).toBe(span);
@@ -642,8 +660,8 @@ describe('ReactDOMServerPartialHydration', () => {
660 }).toErrorDev('Did not expect server HTML to contain a <span> in <div>');
661 if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
662 expect(Scheduler).toHaveYielded([
645 - 'An error occurred during hydration. The server HTML was replaced ' +
646 - 'with client content',
663 + 'Hydration failed because the initial UI does not match what was rendered on the server.',
664 + 'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
665 ]);
666 }
667
@@ -1087,6 +1105,11 @@ describe('ReactDOMServerPartialHydration', () => {
1105 const root = ReactDOM.hydrateRoot(
1106 container,
1107 <App text="Hello" className="hello" />,
1108 + {
1109 + onRecoverableError(error) {
1110 + Scheduler.unstable_yieldValue(error.message);
1111 + },
1112 + },
1113 );
1114 Scheduler.unstable_flushAll();
1115 jest.runAllTimers();
@@ -1097,6 +1120,12 @@ describe('ReactDOMServerPartialHydration', () => {
1120 root.render(<App text="Hi" className="hi" />);
1121 Scheduler.unstable_flushAll();
1122 jest.runAllTimers();
1123 + expect(Scheduler).toHaveYielded([
1124 + 'This Suspense boundary received an update before it finished ' +
1125 + 'hydrating. This caused the boundary to switch to client ' +
1126 + 'rendering. The usual way to fix this is to wrap the original ' +
1127 + 'update in startTransition.',
1128 + ]);
1129
1130 // Flushing now should delete the existing content and show the fallback.
1131
@@ -1162,6 +1191,11 @@ describe('ReactDOMServerPartialHydration', () => {
1191 const root = ReactDOM.hydrateRoot(
1192 container,
1193 <App text="Hello" className="hello" />,
1194 + {
1195 + onRecoverableError(error) {
1196 + Scheduler.unstable_yieldValue(error.message);
1197 + },
1198 + },
1199 );
1200 Scheduler.unstable_flushAll();
1201 jest.runAllTimers();
@@ -1175,6 +1209,12 @@ describe('ReactDOMServerPartialHydration', () => {
1209 // Flushing now should delete the existing content and show the fallback.
1210 Scheduler.unstable_flushAll();
1211 jest.runAllTimers();
1212 + expect(Scheduler).toHaveYielded([
1213 + 'This Suspense boundary received an update before it finished ' +
1214 + 'hydrating. This caused the boundary to switch to client rendering. ' +
1215 + 'The usual way to fix this is to wrap the original update ' +
1216 + 'in startTransition.',
1217 + ]);
1218
1219 expect(container.getElementsByTagName('span').length).toBe(1);
1220 expect(ref.current).toBe(span);
@@ -1236,6 +1276,11 @@ describe('ReactDOMServerPartialHydration', () => {
1276 const root = ReactDOM.hydrateRoot(
1277 container,
1278 <App text="Hello" className="hello" />,
1279 + {
1280 + onRecoverableError(error) {
1281 + Scheduler.unstable_yieldValue(error.message);
1282 + },
1283 + },
1284 );
1285 Scheduler.unstable_flushAll();
1286 jest.runAllTimers();
@@ -1257,6 +1302,12 @@ describe('ReactDOMServerPartialHydration', () => {
1302 suspend = false;
1303 resolve();
1304 await promise;
1305 + expect(Scheduler).toHaveYielded([
1306 + 'This Suspense boundary received an update before it finished ' +
1307 + 'hydrating. This caused the boundary to switch to client rendering. ' +
1308 + 'The usual way to fix this is to wrap the original update ' +
1309 + 'in startTransition.',
1310 + ]);
1311
1312 Scheduler.unstable_flushAll();
1313 jest.runAllTimers();
@@ -1545,6 +1596,11 @@ describe('ReactDOMServerPartialHydration', () => {
1596 <Context.Provider value={{text: 'Hello', className: 'hello'}}>
1597 <App />
1598 </Context.Provider>,
1599 + {
1600 + onRecoverableError(error) {
1601 + Scheduler.unstable_yieldValue(error.message);
1602 + },
1603 + },
1604 );
1605 Scheduler.unstable_flushAll();
1606 jest.runAllTimers();
@@ -1561,6 +1617,12 @@ describe('ReactDOMServerPartialHydration', () => {
1617 // Flushing now should delete the existing content and show the fallback.
1618 Scheduler.unstable_flushAll();
1619 jest.runAllTimers();
1620 + expect(Scheduler).toHaveYielded([
1621 + 'This Suspense boundary received an update before it finished ' +
1622 + 'hydrating. This caused the boundary to switch to client rendering. ' +
1623 + 'The usual way to fix this is to wrap the original update ' +
1624 + 'in startTransition.',
1625 + ]);
1626
1627 expect(container.getElementsByTagName('span').length).toBe(0);
1628 expect(ref.current).toBe(null);
@@ -1618,8 +1680,15 @@ describe('ReactDOMServerPartialHydration', () => {
1680
1681 // On the client we have the data available quickly for some reason.
1682 suspend = false;
1621 - ReactDOM.hydrateRoot(container, <App />);
1622 - Scheduler.unstable_flushAll();
1683 + ReactDOM.hydrateRoot(container, <App />, {
1684 + onRecoverableError(error) {
1685 + Scheduler.unstable_yieldValue(error.message);
1686 + },
1687 + });
1688 + expect(Scheduler).toFlushAndYield([
1689 + 'The server could not finish this Suspense boundary, likely due to ' +
1690 + 'an error during server rendering. Switched to client rendering.',
1691 + ]);
1692 jest.runAllTimers();
1693
1694 expect(container.textContent).toBe('Hello');
@@ -1673,8 +1742,15 @@ describe('ReactDOMServerPartialHydration', () => {
1742
1743 // On the client we have the data available quickly for some reason.
1744 suspend = false;
1676 - ReactDOM.hydrateRoot(container, <App />);
1677 - Scheduler.unstable_flushAll();
1745 + ReactDOM.hydrateRoot(container, <App />, {
1746 + onRecoverableError(error) {
1747 + Scheduler.unstable_yieldValue(error.message);
1748 + },
1749 + });
1750 + expect(Scheduler).toFlushAndYield([
1751 + 'The server could not finish this Suspense boundary, likely due to ' +
1752 + 'an error during server rendering. Switched to client rendering.',
1753 + ]);
1754 // This will have exceeded the suspended time so we should timeout.
1755 jest.advanceTimersByTime(500);
1756 // The boundary should longer be suspended for the middle content
@@ -1733,8 +1809,15 @@ describe('ReactDOMServerPartialHydration', () => {
1809
1810 // On the client we have the data available quickly for some reason.
1811 suspend = false;
1736 - ReactDOM.hydrateRoot(container, <App />);
1737 - Scheduler.unstable_flushAll();
1812 + ReactDOM.hydrateRoot(container, <App />, {
1813 + onRecoverableError(error) {
1814 + Scheduler.unstable_yieldValue(error.message);
1815 + },
1816 + });
1817 + expect(Scheduler).toFlushAndYield([
1818 + 'The server could not finish this Suspense boundary, likely due to ' +
1819 + 'an error during server rendering. Switched to client rendering.',
1820 + ]);
1821 // This will have exceeded the suspended time so we should timeout.
1822 jest.advanceTimersByTime(500);
1823 // The boundary should longer be suspended for the middle content
@@ -2036,10 +2119,17 @@ describe('ReactDOMServerPartialHydration', () => {
2119 const container = document.createElement('div');
2120 container.innerHTML = html;
2121
2039 - ReactDOM.hydrateRoot(container, <App />);
2122 + ReactDOM.hydrateRoot(container, <App />, {
2123 + onRecoverableError(error) {
2124 + Scheduler.unstable_yieldValue(error.message);
2125 + },
2126 + });
2127
2128 suspend = true;
2042 - Scheduler.unstable_flushAll();
2129 + expect(Scheduler).toFlushAndYield([
2130 + 'The server could not finish this Suspense boundary, likely due to ' +
2131 + 'an error during server rendering. Switched to client rendering.',
2132 + ]);
2133
2134 // We haven't hydrated the second child but the placeholder is still in the list.
2135 expect(container.textContent).toBe('ALoading B');
@@ -2094,8 +2184,15 @@ describe('ReactDOMServerPartialHydration', () => {
2184 const span = container.getElementsByTagName('span')[1];
2185
2186 suspend = false;
2097 - ReactDOM.hydrateRoot(container, <App />);
2098 - Scheduler.unstable_flushAll();
2187 + ReactDOM.hydrateRoot(container, <App />, {
2188 + onRecoverableError(error) {
2189 + Scheduler.unstable_yieldValue(error.message);
2190 + },
2191 + });
2192 + expect(Scheduler).toFlushAndYield([
2193 + 'The server could not finish this Suspense boundary, likely due to ' +
2194 + 'an error during server rendering. Switched to client rendering.',
2195 + ]);
2196 jest.runAllTimers();
2197
2198 expect(ref.current).toBe(span);
@@ -2193,6 +2290,11 @@ describe('ReactDOMServerPartialHydration', () => {
2290 <ClassName.Provider value={'hello'}>
2291 <App text="Hello" />
2292 </ClassName.Provider>,
2293 + {
2294 + onRecoverableError(error) {
2295 + Scheduler.unstable_yieldValue(error.message);
2296 + },
2297 + },
2298 );
2299 Scheduler.unstable_flushAll();
2300 jest.runAllTimers();
@@ -2212,6 +2314,12 @@ describe('ReactDOMServerPartialHydration', () => {
2314 // This will force all expiration times to flush.
2315 Scheduler.unstable_flushAll();
2316 jest.runAllTimers();
2317 + expect(Scheduler).toHaveYielded([
2318 + 'This Suspense boundary received an update before it finished ' +
2319 + 'hydrating. This caused the boundary to switch to client rendering. ' +
2320 + 'The usual way to fix this is to wrap the original update ' +
2321 + 'in startTransition.',
2322 + ]);
2323
2324 // This will now be a new span because we weren't able to hydrate before
2325 const newSpan = container.getElementsByTagName('span')[0];
@@ -3232,12 +3340,11 @@ describe('ReactDOMServerPartialHydration', () => {
3340 {withoutStack: 1},
3341 );
3342 expect(Scheduler).toHaveYielded([
3235 - 'Log recoverable error: An error occurred during hydration. The server ' +
3236 - 'HTML was replaced with client content',
3343 + 'Log recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.',
3344 // TODO: There were multiple mismatches in a single container. Should
3345 // we attempt to de-dupe them?
3239 - 'Log recoverable error: An error occurred during hydration. The server ' +
3240 - 'HTML was replaced with client content',
3346 + 'Log recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.',
3347 + 'Log recoverable error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
3348 ]);
3349
3350 // We show fallback state when mismatch happens at root
packages/react-reconciler/src/ReactFiberBeginWork.new.js
+35
@@ -200,6 +200,7 @@ import {
200 resetHydrationState,
201 tryToClaimNextHydratableInstance,
202 warnIfHydrating,
203 + queueHydrationError,
204 } from './ReactFiberHydrationContext.new';
205 import {
206 adoptClassInstance,
@@ -2145,6 +2146,10 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
2146 current,
2147 workInProgress,
2148 renderLanes,
2149 + new Error(
2150 + 'There was an error while hydrating this Suspense boundary. ' +
2151 + 'Switched to client rendering.',
2152 + ),
2153 );
2154 } else if (
2155 (workInProgress.memoizedState: null | SuspenseState) !== null
@@ -2531,7 +2536,19 @@ function retrySuspenseComponentWithoutHydrating(
2536 current: Fiber,
2537 workInProgress: Fiber,
2538 renderLanes: Lanes,
2539 + recoverableError: Error | null,
2540 ) {
2541 + // Falling back to client rendering. Because this has performance
2542 + // implications, it's considered a recoverable error, even though the user
2543 + // likely won't observe anything wrong with the UI.
2544 + //
2545 + // The error is passed in as an argument to enforce that every caller provide
2546 + // a custom message, or explicitly opt out (currently the only path that opts
2547 + // out is legacy mode; every concurrent path provides an error).
2548 + if (recoverableError !== null) {
2549 + queueHydrationError(recoverableError);
2550 + }
2551 +
2552 // This will add the old fiber to the deletion list
2553 reconcileChildFibers(workInProgress, current.child, null, renderLanes);
2554
@@ -2648,6 +2665,10 @@ function updateDehydratedSuspenseComponent(
2665 current,
2666 workInProgress,
2667 renderLanes,
2668 + // TODO: When we delete legacy mode, we should make this error argument
2669 + // required — every concurrent mode path that causes hydration to
2670 + // de-opt to client rendering should have an error message.
2671 + null,
2672 );
2673 }
2674
@@ -2659,6 +2680,14 @@ function updateDehydratedSuspenseComponent(
2680 current,
2681 workInProgress,
2682 renderLanes,
2683 + // TODO: The server should serialize the error message so we can log it
2684 + // here on the client. Or, in production, a hash/id that corresponds to
2685 + // the error.
2686 + new Error(
2687 + 'The server could not finish this Suspense boundary, likely ' +
2688 + 'due to an error during server rendering. Switched to ' +
2689 + 'client rendering.',
2690 + ),
2691 );
2692 }
2693
@@ -2717,6 +2746,12 @@ function updateDehydratedSuspenseComponent(
2746 current,
2747 workInProgress,
2748 renderLanes,
2749 + new Error(
2750 + 'This Suspense boundary received an update before it finished ' +
2751 + 'hydrating. This caused the boundary to switch to client rendering. ' +
2752 + 'The usual way to fix this is to wrap the original update ' +
2753 + 'in startTransition.',
2754 + ),
2755 );
2756 } else if (isSuspenseInstancePending(suspenseInstance)) {
2757 // This component is still pending more data from the server, so we can't hydrate its
packages/react-reconciler/src/ReactFiberBeginWork.old.js
+35
@@ -200,6 +200,7 @@ import {
200 resetHydrationState,
201 tryToClaimNextHydratableInstance,
202 warnIfHydrating,
203 + queueHydrationError,
204 } from './ReactFiberHydrationContext.old';
205 import {
206 adoptClassInstance,
@@ -2145,6 +2146,10 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
2146 current,
2147 workInProgress,
2148 renderLanes,
2149 + new Error(
2150 + 'There was an error while hydrating this Suspense boundary. ' +
2151 + 'Switched to client rendering.',
2152 + ),
2153 );
2154 } else if (
2155 (workInProgress.memoizedState: null | SuspenseState) !== null
@@ -2531,7 +2536,19 @@ function retrySuspenseComponentWithoutHydrating(
2536 current: Fiber,
2537 workInProgress: Fiber,
2538 renderLanes: Lanes,
2539 + recoverableError: Error | null,
2540 ) {
2541 + // Falling back to client rendering. Because this has performance
2542 + // implications, it's considered a recoverable error, even though the user
2543 + // likely won't observe anything wrong with the UI.
2544 + //
2545 + // The error is passed in as an argument to enforce that every caller provide
2546 + // a custom message, or explicitly opt out (currently the only path that opts
2547 + // out is legacy mode; every concurrent path provides an error).
2548 + if (recoverableError !== null) {
2549 + queueHydrationError(recoverableError);
2550 + }
2551 +
2552 // This will add the old fiber to the deletion list
2553 reconcileChildFibers(workInProgress, current.child, null, renderLanes);
2554
@@ -2648,6 +2665,10 @@ function updateDehydratedSuspenseComponent(
2665 current,
2666 workInProgress,
2667 renderLanes,
2668 + // TODO: When we delete legacy mode, we should make this error argument
2669 + // required — every concurrent mode path that causes hydration to
2670 + // de-opt to client rendering should have an error message.
2671 + null,
2672 );
2673 }
2674
@@ -2659,6 +2680,14 @@ function updateDehydratedSuspenseComponent(
2680 current,
2681 workInProgress,
2682 renderLanes,
2683 + // TODO: The server should serialize the error message so we can log it
2684 + // here on the client. Or, in production, a hash/id that corresponds to
2685 + // the error.
2686 + new Error(
2687 + 'The server could not finish this Suspense boundary, likely ' +
2688 + 'due to an error during server rendering. Switched to ' +
2689 + 'client rendering.',
2690 + ),
2691 );
2692 }
2693
@@ -2717,6 +2746,12 @@ function updateDehydratedSuspenseComponent(
2746 current,
2747 workInProgress,
2748 renderLanes,
2749 + new Error(
2750 + 'This Suspense boundary received an update before it finished ' +
2751 + 'hydrating. This caused the boundary to switch to client rendering. ' +
2752 + 'The usual way to fix this is to wrap the original update ' +
2753 + 'in startTransition.',
2754 + ),
2755 );
2756 } else if (isSuspenseInstancePending(suspenseInstance)) {
2757 // This component is still pending more data from the server, so we can't hydrate its
packages/react-reconciler/src/ReactFiberHydrationContext.new.js
+2 -1
@@ -358,7 +358,8 @@ function shouldClientRenderOnMismatch(fiber: Fiber) {
358
359 function throwOnHydrationMismatch(fiber: Fiber) {
360 throw new Error(
361 - 'An error occurred during hydration. The server HTML was replaced with client content',
361 + 'Hydration failed because the initial UI does not match what was ' +
362 + 'rendered on the server.',
363 );
364 }
365
packages/react-reconciler/src/ReactFiberHydrationContext.old.js
+2 -1
@@ -358,7 +358,8 @@ function shouldClientRenderOnMismatch(fiber: Fiber) {
358
359 function throwOnHydrationMismatch(fiber: Fiber) {
360 throw new Error(
361 - 'An error occurred during hydration. The server HTML was replaced with client content',
361 + 'Hydration failed because the initial UI does not match what was ' +
362 + 'rendered on the server.',
363 );
364 }
365
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+12 -1
@@ -518,7 +518,6 @@ export function scheduleUpdateOnFiber(
518
519 if (root.isDehydrated && root.tag !== LegacyRoot) {
520 // This root's shell hasn't hydrated yet. Revert to client rendering.
521 - // TODO: Log a recoverable error
521 if (workInProgressRoot === root) {
522 // If this happened during an interleaved event, interrupt the
523 // in-progress hydration. Theoretically, we could attempt to force a
@@ -538,6 +537,12 @@ export function scheduleUpdateOnFiber(
537 prepareFreshStack(root, NoLanes);
538 }
539 root.isDehydrated = false;
540 + const error = new Error(
541 + 'This root received an early update, before anything was able ' +
542 + 'hydrate. Switched the entire root to client rendering.',
543 + );
544 + const onRecoverableError = root.onRecoverableError;
545 + onRecoverableError(error);
546 } else if (root === workInProgressRoot) {
547 // TODO: Consolidate with `isInterleavedUpdate` check
548
@@ -951,6 +956,12 @@ function recoverFromConcurrentError(root, errorRetryLanes) {
956 if (__DEV__) {
957 errorHydratingContainer(root.containerInfo);
958 }
959 + const error = new Error(
960 + 'There was an error while hydrating. Because the error happened outside ' +
961 + 'of a Suspense boundary, the entire root will switch to ' +
962 + 'client rendering.',
963 + );
964 + renderDidError(error);
965 }
966
967 const errorsFromFirstAttempt = workInProgressRootConcurrentErrors;
packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+12 -1
@@ -518,7 +518,6 @@ export function scheduleUpdateOnFiber(
518
519 if (root.isDehydrated && root.tag !== LegacyRoot) {
520 // This root's shell hasn't hydrated yet. Revert to client rendering.
521 - // TODO: Log a recoverable error
521 if (workInProgressRoot === root) {
522 // If this happened during an interleaved event, interrupt the
523 // in-progress hydration. Theoretically, we could attempt to force a
@@ -538,6 +537,12 @@ export function scheduleUpdateOnFiber(
537 prepareFreshStack(root, NoLanes);
538 }
539 root.isDehydrated = false;
540 + const error = new Error(
541 + 'This root received an early update, before anything was able ' +
542 + 'hydrate. Switched the entire root to client rendering.',
543 + );
544 + const onRecoverableError = root.onRecoverableError;
545 + onRecoverableError(error);
546 } else if (root === workInProgressRoot) {
547 // TODO: Consolidate with `isInterleavedUpdate` check
548
@@ -951,6 +956,12 @@ function recoverFromConcurrentError(root, errorRetryLanes) {
956 if (__DEV__) {
957 errorHydratingContainer(root.containerInfo);
958 }
959 + const error = new Error(
960 + 'There was an error while hydrating. Because the error happened outside ' +
961 + 'of a Suspense boundary, the entire root will switch to ' +
962 + 'client rendering.',
963 + );
964 + renderDidError(error);
965 }
966
967 const errorsFromFirstAttempt = workInProgressRootConcurrentErrors;
packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js
+6
@@ -279,6 +279,9 @@ describe('useMutableSourceHydration', () => {
279 'Log error: Cannot read from mutable source during the current ' +
280 'render without tearing. This may be a bug in React. Please file ' +
281 'an issue.',
282 + 'Log error: There was an error while hydrating. Because the error ' +
283 + 'happened outside of a Suspense boundary, the entire root will ' +
284 + 'switch to client rendering.',
285 ]);
286 expect(source.listenerCount).toBe(2);
287 });
@@ -369,6 +372,9 @@ describe('useMutableSourceHydration', () => {
372 'Log error: Cannot read from mutable source during the current ' +
373 'render without tearing. This may be a bug in React. Please file ' +
374 'an issue.',
375 + 'Log error: There was an error while hydrating. Because the error ' +
376 + 'happened outside of a Suspense boundary, the entire root will ' +
377 + 'switch to client rendering.',
378 ]);
379 });
380 });
scripts/error-codes/codes.json
+6 -1
@@ -403,5 +403,10 @@
403 "415": "Error parsing the data. It's probably an error code or network corruption.",
404 "416": "This environment don't support binary chunks.",
405 "417": "React currently only supports piping to one writable stream.",
406 - "418": "An error occurred during hydration. The server HTML was replaced with client content"
406 + "418": "Hydration failed because the initial UI does not match what was rendered on the server.",
407 + "419": "The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.",
408 + "420": "This Suspense boundary received an update before it finished hydrating. This caused the boundary to switch to client rendering. The usual way to fix this is to wrap the original update in startTransition.",
409 + "421": "There was an error while hydrating this Suspense boundary. Switched to client rendering.",
410 + "422": "There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.",
411 + "423": "This root received an early update, before anything was able hydrate. Switched the entire root to client rendering."
412 }