Skip to content

Log a recoverable error whenever hydration fails #23319

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 17, 2022
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 69 additions & 15 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
@@ -358,7 +358,11 @@ describe('ReactDOMFizzServer', () => {
window.__INIT__ = function() {
bootstrapped = true;
// Attempt to hydrate the content.
ReactDOM.hydrateRoot(container, <App isClient={true} />);
ReactDOM.hydrateRoot(container, <App isClient={true} />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(error.message);
},
});
};

await act(async () => {
@@ -394,7 +398,10 @@ describe('ReactDOMFizzServer', () => {
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);

// 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(<div>Hello</div>);
@@ -465,7 +472,11 @@ describe('ReactDOMFizzServer', () => {
expect(loggedErrors).toEqual([]);

// Attempt to hydrate the content.
ReactDOM.hydrateRoot(container, <App isClient={true} />);
ReactDOM.hydrateRoot(container, <App isClient={true} />, {
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(<div>Loading...</div>);

// 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(<div>Hello</div>);
@@ -766,7 +780,11 @@ describe('ReactDOMFizzServer', () => {
// We're still showing a fallback.

// Attempt to hydrate the content.
ReactDOM.hydrateRoot(container, <App />);
ReactDOM.hydrateRoot(container, <App />, {
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(<div>Loading...</div>);

// 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, <App isClient={true} />);
ReactDOM.hydrateRoot(container, <App isClient={true} />, {
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(<div>Loading...</div>);

// 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(
<div>
<span />
@@ -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(
<div>
<span />
@@ -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.',
]);
});
});
Original file line number Diff line number Diff line change
@@ -232,15 +232,23 @@ describe('ReactDOMFizzShellHydration', () => {

// Hydration suspends because the data for the shell hasn't loaded yet
const root = await clientAct(async () => {
return ReactDOM.hydrateRoot(container, <App />);
return ReactDOM.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(error.message);
},
});
});
expect(Scheduler).toHaveYielded(['Suspend! [Shell]']);
expect(container.textContent).toBe('Shell');

await clientAct(async () => {
root.render(<Text text="New screen" />);
});
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');
});
});
Original file line number Diff line number Diff line change
@@ -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, <App hasB={false} />);
ReactDOM.hydrateRoot(container, <App hasB={false} />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(error.message);
},
});
});
}).toErrorDev('Did not expect server HTML to contain a <span> in <div>');

@@ -517,6 +531,10 @@ describe('ReactDOMServerPartialHydration', () => {
expect(container.innerHTML).not.toContain('<span>B</span>');

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 <span> in <div>');
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,
<App text="Hello" className="hello" />,
{
onRecoverableError(error) {
Scheduler.unstable_yieldValue(error.message);
},
},
);
Scheduler.unstable_flushAll();
jest.runAllTimers();
@@ -1097,6 +1120,12 @@ describe('ReactDOMServerPartialHydration', () => {
root.render(<App text="Hi" className="hi" />);
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,
<App text="Hello" className="hello" />,
{
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,
<App text="Hello" className="hello" />,
{
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', () => {
<Context.Provider value={{text: 'Hello', className: 'hello'}}>
<App />
</Context.Provider>,
{
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, <App />);
Scheduler.unstable_flushAll();
ReactDOM.hydrateRoot(container, <App />, {
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, <App />);
Scheduler.unstable_flushAll();
ReactDOM.hydrateRoot(container, <App />, {
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, <App />);
Scheduler.unstable_flushAll();
ReactDOM.hydrateRoot(container, <App />, {
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, <App />);
ReactDOM.hydrateRoot(container, <App />, {
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, <App />);
Scheduler.unstable_flushAll();
ReactDOM.hydrateRoot(container, <App />, {
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', () => {
<ClassName.Provider value={'hello'}>
<App text="Hello" />
</ClassName.Provider>,
{
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
35 changes: 35 additions & 0 deletions packages/react-reconciler/src/ReactFiberBeginWork.new.js
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions packages/react-reconciler/src/ReactFiberBeginWork.old.js
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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.',
);
}

Original file line number Diff line number Diff line change
@@ -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.',
);
}

13 changes: 12 additions & 1 deletion packages/react-reconciler/src/ReactFiberWorkLoop.new.js
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 12 additions & 1 deletion packages/react-reconciler/src/ReactFiberWorkLoop.old.js
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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.',
]);
});
});
7 changes: 6 additions & 1 deletion scripts/error-codes/codes.json
Original file line number Diff line number Diff line change
@@ -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."
}