From 51c8411d9dab33290b794fe716b9514e7db1fb68 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 17 Feb 2022 15:16:17 -0500 Subject: [PATCH] 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. --- .../src/__tests__/ReactDOMFizzServer-test.js | 84 ++++++++-- .../ReactDOMFizzShellHydration-test.js | 12 +- ...DOMServerPartialHydration-test.internal.js | 145 +++++++++++++++--- .../src/ReactFiberBeginWork.new.js | 35 +++++ .../src/ReactFiberBeginWork.old.js | 35 +++++ .../src/ReactFiberHydrationContext.new.js | 3 +- .../src/ReactFiberHydrationContext.old.js | 3 +- .../src/ReactFiberWorkLoop.new.js | 13 +- .../src/ReactFiberWorkLoop.old.js | 13 +- .../useMutableSourceHydration-test.js | 6 + scripts/error-codes/codes.json | 7 +- 11 files changed, 315 insertions(+), 41 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 1ecab933035f8..01397d00004fc 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -358,7 +358,11 @@ describe('ReactDOMFizzServer', () => { window.__INIT__ = function() { bootstrapped = true; // Attempt to hydrate the content. - ReactDOM.hydrateRoot(container, ); + ReactDOM.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); }; await act(async () => { @@ -394,7 +398,10 @@ describe('ReactDOMFizzServer', () => { expect(getVisibleChildren(container)).toEqual(
Loading...
); // Now we can client render it instead. - Scheduler.unstable_flushAll(); + expect(Scheduler).toFlushAndYield([ + 'The server could not finish this Suspense boundary, likely due to ' + + 'an error during server rendering. Switched to client rendering.', + ]); // The client rendered HTML is now in place. expect(getVisibleChildren(container)).toEqual(
Hello
); @@ -465,7 +472,11 @@ describe('ReactDOMFizzServer', () => { expect(loggedErrors).toEqual([]); // Attempt to hydrate the content. - ReactDOM.hydrateRoot(container, ); + ReactDOM.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); Scheduler.unstable_flushAll(); // We're still loading because we're waiting for the server to stream more content. @@ -484,7 +495,10 @@ describe('ReactDOMFizzServer', () => { expect(getVisibleChildren(container)).toEqual(
Loading...
); // Now we can client render it instead. - Scheduler.unstable_flushAll(); + expect(Scheduler).toFlushAndYield([ + 'The server could not finish this Suspense boundary, likely due to ' + + 'an error during server rendering. Switched to client rendering.', + ]); // The client rendered HTML is now in place. expect(getVisibleChildren(container)).toEqual(
Hello
); @@ -766,7 +780,11 @@ describe('ReactDOMFizzServer', () => { // We're still showing a fallback. // Attempt to hydrate the content. - ReactDOM.hydrateRoot(container, ); + ReactDOM.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); Scheduler.unstable_flushAll(); // We're still loading because we're waiting for the server to stream more content. @@ -778,7 +796,10 @@ describe('ReactDOMFizzServer', () => { }); // We still can't render it on the client. - Scheduler.unstable_flushAll(); + expect(Scheduler).toFlushAndYield([ + 'The server could not finish this Suspense boundary, likely due to an ' + + 'error during server rendering. Switched to client rendering.', + ]); expect(getVisibleChildren(container)).toEqual(
Loading...
); // We now resolve it on the client. @@ -1455,7 +1476,11 @@ describe('ReactDOMFizzServer', () => { // We're still showing a fallback. // Attempt to hydrate the content. - ReactDOM.hydrateRoot(container, ); + ReactDOM.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); Scheduler.unstable_flushAll(); // We're still loading because we're waiting for the server to stream more content. @@ -1484,7 +1509,10 @@ describe('ReactDOMFizzServer', () => { expect(getVisibleChildren(container)).toEqual(
Loading...
); // That will let us client render it instead. - Scheduler.unstable_flushAll(); + expect(Scheduler).toFlushAndYield([ + 'The server could not finish this Suspense boundary, likely due to ' + + 'an error during server rendering. Switched to client rendering.', + ]); // The client rendered HTML is now in place. expect(getVisibleChildren(container)).toEqual( @@ -1736,8 +1764,11 @@ describe('ReactDOMFizzServer', () => { // The first paint switches to client rendering due to mismatch expect(Scheduler).toFlushUntilNextPaint([ 'client', - 'Log recoverable error: An error occurred during hydration. ' + - 'The server HTML was replaced with client content', + 'Log recoverable error: Hydration failed because the initial ' + + 'UI does not match what was rendered on the server.', + '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.', ]); }).toErrorDev( [ @@ -1834,8 +1865,11 @@ describe('ReactDOMFizzServer', () => { // The first paint switches to client rendering due to mismatch expect(Scheduler).toFlushUntilNextPaint([ 'client', - 'Log recoverable error: An error occurred during hydration. ' + - 'The server HTML was replaced with client content', + 'Log recoverable error: Hydration failed because the initial ' + + 'UI does not match what was rendered on the server.', + '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.', ]); }).toErrorDev( [ @@ -1928,7 +1962,13 @@ describe('ReactDOMFizzServer', () => { // An error logged but instead of surfacing it to the UI, we switched // to client rendering. expect(() => { - expect(Scheduler).toFlushAndYield(['Yay!', 'Hydration error']); + expect(Scheduler).toFlushAndYield([ + 'Yay!', + 'Hydration error', + 'There was an error while hydrating. Because the error happened ' + + 'outside of a Suspense boundary, the entire root will switch ' + + 'to client rendering.', + ]); }).toErrorDev( 'An error occurred during hydration. The server HTML was replaced', {withoutStack: true}, @@ -2012,7 +2052,11 @@ describe('ReactDOMFizzServer', () => { // An error logged but instead of surfacing it to the UI, we switched // to client rendering. - expect(Scheduler).toFlushAndYield(['Yay!', 'Hydration error']); + expect(Scheduler).toFlushAndYield([ + 'Yay!', + 'Hydration error', + 'There was an error while hydrating this Suspense boundary. Switched to client rendering.', + ]); expect(getVisibleChildren(container)).toEqual(
@@ -2178,7 +2222,11 @@ describe('ReactDOMFizzServer', () => { // An error logged but instead of surfacing it to the UI, we switched // to client rendering. - expect(Scheduler).toFlushAndYield(['Hydration error']); + expect(Scheduler).toFlushAndYield([ + 'Hydration error', + 'There was an error while hydrating this Suspense boundary. Switched ' + + 'to client rendering.', + ]); expect(getVisibleChildren(container)).toEqual(
@@ -2328,8 +2376,14 @@ describe('ReactDOMFizzServer', () => { expect(Scheduler).toFlushAndYield([ 'A', 'B', + 'Logged recoverable error: Hydration error', + 'Logged recoverable error: There was an error while hydrating this ' + + 'Suspense boundary. Switched to client rendering.', + 'Logged recoverable error: Hydration error', + 'Logged recoverable error: There was an error while hydrating this ' + + 'Suspense boundary. Switched to client rendering.', ]); }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js index 1a32db7bb83df..f2668b74fe67d 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js @@ -232,7 +232,11 @@ describe('ReactDOMFizzShellHydration', () => { // Hydration suspends because the data for the shell hasn't loaded yet const root = await clientAct(async () => { - return ReactDOM.hydrateRoot(container, ); + return ReactDOM.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); }); expect(Scheduler).toHaveYielded(['Suspend! [Shell]']); expect(container.textContent).toBe('Shell'); @@ -240,7 +244,11 @@ describe('ReactDOMFizzShellHydration', () => { await clientAct(async () => { root.render(); }); - expect(Scheduler).toHaveYielded(['New screen']); + expect(Scheduler).toHaveYielded([ + 'This root received an early update, before anything was able ' + + 'hydrate. Switched the entire root to client rendering.', + 'New screen', + ]); expect(container.textContent).toBe('New screen'); }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 575b7c8a3fe9c..36c452b8cdf30 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -348,7 +348,8 @@ describe('ReactDOMServerPartialHydration', () => { 'Component', // Hydration mismatch is logged - 'An error occurred during hydration. The server HTML was replaced with client content', + 'Hydration failed because the initial UI does not match what was rendered on the server.', + 'There was an error while hydrating this Suspense boundary. Switched to client rendering.', ]); // Client rendered - suspense comment nodes removed @@ -432,8 +433,11 @@ describe('ReactDOMServerPartialHydration', () => { onDeleted(node) { deleted.push(node); }, + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, }); - Scheduler.unstable_flushAll(); + expect(Scheduler).toFlushAndYield([]); expect(hydrated.length).toBe(0); expect(deleted.length).toBe(0); @@ -453,6 +457,12 @@ describe('ReactDOMServerPartialHydration', () => { Scheduler.unstable_flushAll(); jest.runAllTimers(); + expect(Scheduler).toHaveYielded([ + '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.', + ]); expect(hydrated.length).toBe(1); expect(deleted.length).toBe(1); @@ -507,7 +517,11 @@ describe('ReactDOMServerPartialHydration', () => { expect(() => { act(() => { - ReactDOM.hydrateRoot(container, ); + ReactDOM.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); }); }).toErrorDev('Did not expect server HTML to contain a in
'); @@ -517,6 +531,10 @@ describe('ReactDOMServerPartialHydration', () => { expect(container.innerHTML).not.toContain('B'); if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) { + expect(Scheduler).toHaveYielded([ + 'There was an error while hydrating this Suspense boundary. ' + + 'Switched to client rendering.', + ]); expect(ref.current).not.toBe(span); } else { expect(ref.current).toBe(span); @@ -642,8 +660,8 @@ describe('ReactDOMServerPartialHydration', () => { }).toErrorDev('Did not expect server HTML to contain a in
'); if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) { expect(Scheduler).toHaveYielded([ - 'An error occurred during hydration. The server HTML was replaced ' + - 'with client content', + 'Hydration failed because the initial UI does not match what was rendered on the server.', + 'There was an error while hydrating this Suspense boundary. Switched to client rendering.', ]); } @@ -1087,6 +1105,11 @@ describe('ReactDOMServerPartialHydration', () => { const root = ReactDOM.hydrateRoot( container, , + { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }, ); Scheduler.unstable_flushAll(); jest.runAllTimers(); @@ -1097,6 +1120,12 @@ describe('ReactDOMServerPartialHydration', () => { root.render(); Scheduler.unstable_flushAll(); jest.runAllTimers(); + expect(Scheduler).toHaveYielded([ + '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.', + ]); // Flushing now should delete the existing content and show the fallback. @@ -1162,6 +1191,11 @@ describe('ReactDOMServerPartialHydration', () => { const root = ReactDOM.hydrateRoot( container, , + { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }, ); Scheduler.unstable_flushAll(); jest.runAllTimers(); @@ -1175,6 +1209,12 @@ describe('ReactDOMServerPartialHydration', () => { // Flushing now should delete the existing content and show the fallback. Scheduler.unstable_flushAll(); jest.runAllTimers(); + expect(Scheduler).toHaveYielded([ + '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.', + ]); expect(container.getElementsByTagName('span').length).toBe(1); expect(ref.current).toBe(span); @@ -1236,6 +1276,11 @@ describe('ReactDOMServerPartialHydration', () => { const root = ReactDOM.hydrateRoot( container, , + { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }, ); Scheduler.unstable_flushAll(); jest.runAllTimers(); @@ -1257,6 +1302,12 @@ describe('ReactDOMServerPartialHydration', () => { suspend = false; resolve(); await promise; + expect(Scheduler).toHaveYielded([ + '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.', + ]); Scheduler.unstable_flushAll(); jest.runAllTimers(); @@ -1545,6 +1596,11 @@ describe('ReactDOMServerPartialHydration', () => { , + { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }, ); Scheduler.unstable_flushAll(); jest.runAllTimers(); @@ -1561,6 +1617,12 @@ describe('ReactDOMServerPartialHydration', () => { // Flushing now should delete the existing content and show the fallback. Scheduler.unstable_flushAll(); jest.runAllTimers(); + expect(Scheduler).toHaveYielded([ + '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.', + ]); expect(container.getElementsByTagName('span').length).toBe(0); expect(ref.current).toBe(null); @@ -1618,8 +1680,15 @@ describe('ReactDOMServerPartialHydration', () => { // On the client we have the data available quickly for some reason. suspend = false; - ReactDOM.hydrateRoot(container, ); - Scheduler.unstable_flushAll(); + ReactDOM.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([ + 'The server could not finish this Suspense boundary, likely due to ' + + 'an error during server rendering. Switched to client rendering.', + ]); jest.runAllTimers(); expect(container.textContent).toBe('Hello'); @@ -1673,8 +1742,15 @@ describe('ReactDOMServerPartialHydration', () => { // On the client we have the data available quickly for some reason. suspend = false; - ReactDOM.hydrateRoot(container, ); - Scheduler.unstable_flushAll(); + ReactDOM.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([ + 'The server could not finish this Suspense boundary, likely due to ' + + 'an error during server rendering. Switched to client rendering.', + ]); // This will have exceeded the suspended time so we should timeout. jest.advanceTimersByTime(500); // The boundary should longer be suspended for the middle content @@ -1733,8 +1809,15 @@ describe('ReactDOMServerPartialHydration', () => { // On the client we have the data available quickly for some reason. suspend = false; - ReactDOM.hydrateRoot(container, ); - Scheduler.unstable_flushAll(); + ReactDOM.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([ + 'The server could not finish this Suspense boundary, likely due to ' + + 'an error during server rendering. Switched to client rendering.', + ]); // This will have exceeded the suspended time so we should timeout. jest.advanceTimersByTime(500); // The boundary should longer be suspended for the middle content @@ -2036,10 +2119,17 @@ describe('ReactDOMServerPartialHydration', () => { const container = document.createElement('div'); container.innerHTML = html; - ReactDOM.hydrateRoot(container, ); + ReactDOM.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); suspend = true; - Scheduler.unstable_flushAll(); + expect(Scheduler).toFlushAndYield([ + 'The server could not finish this Suspense boundary, likely due to ' + + 'an error during server rendering. Switched to client rendering.', + ]); // We haven't hydrated the second child but the placeholder is still in the list. expect(container.textContent).toBe('ALoading B'); @@ -2094,8 +2184,15 @@ describe('ReactDOMServerPartialHydration', () => { const span = container.getElementsByTagName('span')[1]; suspend = false; - ReactDOM.hydrateRoot(container, ); - Scheduler.unstable_flushAll(); + ReactDOM.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([ + 'The server could not finish this Suspense boundary, likely due to ' + + 'an error during server rendering. Switched to client rendering.', + ]); jest.runAllTimers(); expect(ref.current).toBe(span); @@ -2193,6 +2290,11 @@ describe('ReactDOMServerPartialHydration', () => { , + { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }, ); Scheduler.unstable_flushAll(); jest.runAllTimers(); @@ -2212,6 +2314,12 @@ describe('ReactDOMServerPartialHydration', () => { // This will force all expiration times to flush. Scheduler.unstable_flushAll(); jest.runAllTimers(); + expect(Scheduler).toHaveYielded([ + '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.', + ]); // This will now be a new span because we weren't able to hydrate before const newSpan = container.getElementsByTagName('span')[0]; @@ -3232,12 +3340,11 @@ describe('ReactDOMServerPartialHydration', () => { {withoutStack: 1}, ); expect(Scheduler).toHaveYielded([ - 'Log recoverable error: An error occurred during hydration. The server ' + - 'HTML was replaced with client content', + 'Log recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.', // TODO: There were multiple mismatches in a single container. Should // we attempt to de-dupe them? - 'Log recoverable error: An error occurred during hydration. The server ' + - 'HTML was replaced with client content', + 'Log recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.', + '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.', ]); // We show fallback state when mismatch happens at root diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index f0d7b6c625cc6..102ee33376616 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -200,6 +200,7 @@ import { resetHydrationState, tryToClaimNextHydratableInstance, warnIfHydrating, + queueHydrationError, } from './ReactFiberHydrationContext.new'; import { adoptClassInstance, @@ -2145,6 +2146,10 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { current, workInProgress, renderLanes, + new Error( + 'There was an error while hydrating this Suspense boundary. ' + + 'Switched to client rendering.', + ), ); } else if ( (workInProgress.memoizedState: null | SuspenseState) !== null @@ -2531,7 +2536,19 @@ function retrySuspenseComponentWithoutHydrating( current: Fiber, workInProgress: Fiber, renderLanes: Lanes, + recoverableError: Error | null, ) { + // Falling back to client rendering. Because this has performance + // implications, it's considered a recoverable error, even though the user + // likely won't observe anything wrong with the UI. + // + // The error is passed in as an argument to enforce that every caller provide + // a custom message, or explicitly opt out (currently the only path that opts + // out is legacy mode; every concurrent path provides an error). + if (recoverableError !== null) { + queueHydrationError(recoverableError); + } + // This will add the old fiber to the deletion list reconcileChildFibers(workInProgress, current.child, null, renderLanes); @@ -2648,6 +2665,10 @@ function updateDehydratedSuspenseComponent( current, workInProgress, renderLanes, + // TODO: When we delete legacy mode, we should make this error argument + // required — every concurrent mode path that causes hydration to + // de-opt to client rendering should have an error message. + null, ); } @@ -2659,6 +2680,14 @@ function updateDehydratedSuspenseComponent( current, workInProgress, renderLanes, + // TODO: The server should serialize the error message so we can log it + // here on the client. Or, in production, a hash/id that corresponds to + // the error. + new Error( + 'The server could not finish this Suspense boundary, likely ' + + 'due to an error during server rendering. Switched to ' + + 'client rendering.', + ), ); } @@ -2717,6 +2746,12 @@ function updateDehydratedSuspenseComponent( current, workInProgress, renderLanes, + new Error( + '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.', + ), ); } else if (isSuspenseInstancePending(suspenseInstance)) { // This component is still pending more data from the server, so we can't hydrate its diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index b562291850ea5..5876cb3eee702 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -200,6 +200,7 @@ import { resetHydrationState, tryToClaimNextHydratableInstance, warnIfHydrating, + queueHydrationError, } from './ReactFiberHydrationContext.old'; import { adoptClassInstance, @@ -2145,6 +2146,10 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { current, workInProgress, renderLanes, + new Error( + 'There was an error while hydrating this Suspense boundary. ' + + 'Switched to client rendering.', + ), ); } else if ( (workInProgress.memoizedState: null | SuspenseState) !== null @@ -2531,7 +2536,19 @@ function retrySuspenseComponentWithoutHydrating( current: Fiber, workInProgress: Fiber, renderLanes: Lanes, + recoverableError: Error | null, ) { + // Falling back to client rendering. Because this has performance + // implications, it's considered a recoverable error, even though the user + // likely won't observe anything wrong with the UI. + // + // The error is passed in as an argument to enforce that every caller provide + // a custom message, or explicitly opt out (currently the only path that opts + // out is legacy mode; every concurrent path provides an error). + if (recoverableError !== null) { + queueHydrationError(recoverableError); + } + // This will add the old fiber to the deletion list reconcileChildFibers(workInProgress, current.child, null, renderLanes); @@ -2648,6 +2665,10 @@ function updateDehydratedSuspenseComponent( current, workInProgress, renderLanes, + // TODO: When we delete legacy mode, we should make this error argument + // required — every concurrent mode path that causes hydration to + // de-opt to client rendering should have an error message. + null, ); } @@ -2659,6 +2680,14 @@ function updateDehydratedSuspenseComponent( current, workInProgress, renderLanes, + // TODO: The server should serialize the error message so we can log it + // here on the client. Or, in production, a hash/id that corresponds to + // the error. + new Error( + 'The server could not finish this Suspense boundary, likely ' + + 'due to an error during server rendering. Switched to ' + + 'client rendering.', + ), ); } @@ -2717,6 +2746,12 @@ function updateDehydratedSuspenseComponent( current, workInProgress, renderLanes, + new Error( + '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.', + ), ); } else if (isSuspenseInstancePending(suspenseInstance)) { // This component is still pending more data from the server, so we can't hydrate its diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index ab2d05cea0aac..59ed33bb80e7d 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -358,7 +358,8 @@ function shouldClientRenderOnMismatch(fiber: Fiber) { function throwOnHydrationMismatch(fiber: Fiber) { throw new Error( - 'An error occurred during hydration. The server HTML was replaced with client content', + 'Hydration failed because the initial UI does not match what was ' + + 'rendered on the server.', ); } diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index 9ad186495d8e2..4ff9011fddc80 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -358,7 +358,8 @@ function shouldClientRenderOnMismatch(fiber: Fiber) { function throwOnHydrationMismatch(fiber: Fiber) { throw new Error( - 'An error occurred during hydration. The server HTML was replaced with client content', + 'Hydration failed because the initial UI does not match what was ' + + 'rendered on the server.', ); } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 12bc721c6d399..14a313a077744 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -518,7 +518,6 @@ export function scheduleUpdateOnFiber( if (root.isDehydrated && root.tag !== LegacyRoot) { // This root's shell hasn't hydrated yet. Revert to client rendering. - // TODO: Log a recoverable error if (workInProgressRoot === root) { // If this happened during an interleaved event, interrupt the // in-progress hydration. Theoretically, we could attempt to force a @@ -538,6 +537,12 @@ export function scheduleUpdateOnFiber( prepareFreshStack(root, NoLanes); } root.isDehydrated = false; + const error = new Error( + 'This root received an early update, before anything was able ' + + 'hydrate. Switched the entire root to client rendering.', + ); + const onRecoverableError = root.onRecoverableError; + onRecoverableError(error); } else if (root === workInProgressRoot) { // TODO: Consolidate with `isInterleavedUpdate` check @@ -951,6 +956,12 @@ function recoverFromConcurrentError(root, errorRetryLanes) { if (__DEV__) { errorHydratingContainer(root.containerInfo); } + const error = new Error( + 'There was an error while hydrating. Because the error happened outside ' + + 'of a Suspense boundary, the entire root will switch to ' + + 'client rendering.', + ); + renderDidError(error); } const errorsFromFirstAttempt = workInProgressRootConcurrentErrors; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 9e37f41f4fc82..36e3ba4b943d9 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -518,7 +518,6 @@ export function scheduleUpdateOnFiber( if (root.isDehydrated && root.tag !== LegacyRoot) { // This root's shell hasn't hydrated yet. Revert to client rendering. - // TODO: Log a recoverable error if (workInProgressRoot === root) { // If this happened during an interleaved event, interrupt the // in-progress hydration. Theoretically, we could attempt to force a @@ -538,6 +537,12 @@ export function scheduleUpdateOnFiber( prepareFreshStack(root, NoLanes); } root.isDehydrated = false; + const error = new Error( + 'This root received an early update, before anything was able ' + + 'hydrate. Switched the entire root to client rendering.', + ); + const onRecoverableError = root.onRecoverableError; + onRecoverableError(error); } else if (root === workInProgressRoot) { // TODO: Consolidate with `isInterleavedUpdate` check @@ -951,6 +956,12 @@ function recoverFromConcurrentError(root, errorRetryLanes) { if (__DEV__) { errorHydratingContainer(root.containerInfo); } + const error = new Error( + 'There was an error while hydrating. Because the error happened outside ' + + 'of a Suspense boundary, the entire root will switch to ' + + 'client rendering.', + ); + renderDidError(error); } const errorsFromFirstAttempt = workInProgressRootConcurrentErrors; diff --git a/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js b/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js index bbaed39d15ed2..5fe2f861dc5f0 100644 --- a/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js +++ b/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js @@ -279,6 +279,9 @@ describe('useMutableSourceHydration', () => { 'Log error: Cannot read from mutable source during the current ' + 'render without tearing. This may be a bug in React. Please file ' + 'an issue.', + 'Log error: There was an error while hydrating. Because the error ' + + 'happened outside of a Suspense boundary, the entire root will ' + + 'switch to client rendering.', ]); expect(source.listenerCount).toBe(2); }); @@ -369,6 +372,9 @@ describe('useMutableSourceHydration', () => { 'Log error: Cannot read from mutable source during the current ' + 'render without tearing. This may be a bug in React. Please file ' + 'an issue.', + 'Log error: There was an error while hydrating. Because the error ' + + 'happened outside of a Suspense boundary, the entire root will ' + + 'switch to client rendering.', ]); }); }); diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 6703e2cd36539..9c9de4605f2dd 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -403,5 +403,10 @@ "415": "Error parsing the data. It's probably an error code or network corruption.", "416": "This environment don't support binary chunks.", "417": "React currently only supports piping to one writable stream.", - "418": "An error occurred during hydration. The server HTML was replaced with client content" + "418": "Hydration failed because the initial UI does not match what was rendered on the server.", + "419": "The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.", + "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.", + "421": "There was an error while hydrating this Suspense boundary. Switched to client rendering.", + "422": "There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.", + "423": "This root received an early update, before anything was able hydrate. Switched the entire root to client rendering." }