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.
This commit is contained in:
Andrew Clark 2022-02-17 15:16:17 -05:00 committed by GitHub
parent 79ed5e18fd
commit 51c8411d9d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 315 additions and 41 deletions

View File

@ -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.',
]);
});
});

View File

@ -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, <App />);
return ReactDOM.hydrateRoot(container, <App />, {
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(<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');
});
});

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.',
);
}

View File

@ -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.',
);
}

View File

@ -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;

View File

@ -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;

View File

@ -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.',
]);
});
});

View File

@ -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."
}