Skip to content
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

Move Hydration Mismatch Errors to Throw or Log Once (Kind of) #28502

Merged
merged 10 commits into from
Mar 27, 2024
10 changes: 8 additions & 2 deletions packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ describe('ReactDOMFizzForm', () => {
ReactDOMClient.hydrateRoot(container, <App isClient={true} />);
});
}).toErrorDev(
'Prop `action` did not match. Server: "function" Client: "action"',
"A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.",
{withoutStack: true},
);
});

Expand Down Expand Up @@ -344,7 +345,12 @@ describe('ReactDOMFizzForm', () => {
await act(async () => {
root = ReactDOMClient.hydrateRoot(container, <App />);
});
}).toErrorDev(['Prop `formTarget` did not match.']);
}).toErrorDev(
[
"A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.",
],
{withoutStack: true},
);
await act(async () => {
root.render(<App isUpdate={true} />);
});
Expand Down
140 changes: 66 additions & 74 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ let waitForPaint;
let clientAct;
let streamingContainer;

function normalizeError(msg) {
// Take the first sentence to make it easier to assert on.
const idx = msg.indexOf('.');
if (idx > -1) {
return msg.slice(0, idx + 1);
}
return msg;
}

describe('ReactDOMFizzServer', () => {
beforeEach(() => {
jest.resetModules();
Expand Down Expand Up @@ -2391,26 +2400,22 @@ describe('ReactDOMFizzServer', () => {

ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('Log recoverable error: ' + error.message);
Scheduler.log(
'Log recoverable error: ' + normalizeError(error.message),
);
},
});

await expect(async () => {
// The first paint switches to client rendering due to mismatch
await waitForPaint([
'client',
'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.',
"Log recoverable error: Hydration failed because the server rendered HTML didn't match the client.",
'Log recoverable error: There was an error while hydrating.',
]);
}).toErrorDev(
[
'Warning: An error occurred during hydration. The server HTML was replaced with client content.',
'Warning: Expected server HTML to contain a matching <div> in the root.\n' +
' in div (at **)\n' +
' in App (at **)',
],
{withoutStack: 1},
);
Expand Down Expand Up @@ -2474,7 +2479,9 @@ describe('ReactDOMFizzServer', () => {

ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('Log recoverable error: ' + error.message);
Scheduler.log(
'Log recoverable error: ' + normalizeError(error.message),
);
},
});

Expand All @@ -2483,18 +2490,12 @@ describe('ReactDOMFizzServer', () => {
// The first paint switches to client rendering due to mismatch
await waitForPaint([
'client',
'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.',
"Log recoverable error: Hydration failed because the server rendered HTML didn't match the client.",
'Log recoverable error: There was an error while hydrating.',
]);
}).toErrorDev(
[
'Warning: An error occurred during hydration. The server HTML was replaced with client content',
'Warning: Expected server HTML to contain a matching <div> in the root.\n' +
' in div (at **)\n' +
' in App (at **)',
],
{withoutStack: 1},
);
Expand Down Expand Up @@ -2557,7 +2558,7 @@ describe('ReactDOMFizzServer', () => {
isClient = true;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log(error.message);
Scheduler.log(normalizeError(error.message));
},
});

Expand All @@ -2567,9 +2568,7 @@ describe('ReactDOMFizzServer', () => {
await waitForAll([
'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.',
'There was an error while hydrating.',
]);
}).toErrorDev(
'An error occurred during hydration. The server HTML was replaced',
Expand Down Expand Up @@ -2739,7 +2738,7 @@ describe('ReactDOMFizzServer', () => {
isClient = true;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log(error.message);
Scheduler.log(normalizeError(error.message));
},
});

Expand All @@ -2748,7 +2747,7 @@ describe('ReactDOMFizzServer', () => {
await waitForAll([
'Yay!',
'Hydration error',
'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
'There was an error while hydrating this Suspense boundary.',
]);
expect(getVisibleChildren(container)).toEqual(
<div>
Expand Down Expand Up @@ -3194,16 +3193,15 @@ describe('ReactDOMFizzServer', () => {
isClient = true;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log(error.message);
Scheduler.log(normalizeError(error.message));
},
});

// An error logged but instead of surfacing it to the UI, we switched
// to client rendering.
await waitForAll([
'Hydration error',
'There was an error while hydrating this Suspense boundary. Switched ' +
'to client rendering.',
'There was an error while hydrating this Suspense boundary.',
]);
expect(getVisibleChildren(container)).toEqual(
<div>
Expand Down Expand Up @@ -3263,7 +3261,9 @@ describe('ReactDOMFizzServer', () => {

const root = ReactDOMClient.createRoot(container, {
onRecoverableError(error) {
Scheduler.log('Logged a recoverable error: ' + error.message);
Scheduler.log(
'Logged a recoverable error: ' + normalizeError(error.message),
);
},
});
React.startTransition(() => {
Expand Down Expand Up @@ -3339,7 +3339,9 @@ describe('ReactDOMFizzServer', () => {
isClient = true;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('Logged recoverable error: ' + error.message);
Scheduler.log(
'Logged recoverable error: ' + normalizeError(error.message),
);
},
});

Expand All @@ -3349,11 +3351,11 @@ describe('ReactDOMFizzServer', () => {

'Logged recoverable error: Hydration error',
'Logged recoverable error: There was an error while hydrating this ' +
'Suspense boundary. Switched to client rendering.',
'Suspense boundary.',

'Logged recoverable error: Hydration error',
'Logged recoverable error: There was an error while hydrating this ' +
'Suspense boundary. Switched to client rendering.',
'Suspense boundary.',
]);
});

Expand Down Expand Up @@ -4395,7 +4397,9 @@ describe('ReactDOMFizzServer', () => {
const [ClientApp, clientResolve] = makeApp();
ReactDOMClient.hydrateRoot(container, <ClientApp />, {
onRecoverableError(error) {
Scheduler.log('Logged recoverable error: ' + error.message);
Scheduler.log(
'Logged recoverable error: ' + normalizeError(error.message),
);
},
});
await waitForAll([]);
Expand Down Expand Up @@ -4471,7 +4475,9 @@ describe('ReactDOMFizzServer', () => {
const [ClientApp, clientResolve] = makeApp();
ReactDOMClient.hydrateRoot(container, <ClientApp text="replaced" />, {
onRecoverableError(error) {
Scheduler.log('Logged recoverable error: ' + error.message);
Scheduler.log(
'Logged recoverable error: ' + normalizeError(error.message),
);
},
});
await waitForAll([]);
Expand All @@ -4486,14 +4492,10 @@ describe('ReactDOMFizzServer', () => {
// Now that the boundary resolves to it's children the hydration completes and discovers that there is a mismatch requiring
// client-side rendering.
await clientResolve();
await expect(async () => {
await waitForAll([
'Logged recoverable error: Text content does not match server-rendered HTML.',
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
]);
}).toErrorDev(
'Warning: Text content did not match. Server: "initial" Client: "replaced',
);
await waitForAll([
"Logged recoverable error: Hydration failed because the server rendered HTML didn't match the client.",
'Logged recoverable error: There was an error while hydrating this Suspense boundary.',
]);
expect(getVisibleChildren(container)).toEqual(
<div>
<p>A</p>
Expand Down Expand Up @@ -4539,12 +4541,14 @@ describe('ReactDOMFizzServer', () => {

ReactDOMClient.hydrateRoot(container, <App text="replaced" />, {
onRecoverableError(error) {
Scheduler.log('Logged recoverable error: ' + error.message);
Scheduler.log(
'Logged recoverable error: ' + normalizeError(error.message),
);
},
});
await waitForAll([
'Logged recoverable error: Text content does not match server-rendered HTML.',
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
"Logged recoverable error: Hydration failed because the server rendered HTML didn't match the client.",
'Logged recoverable error: There was an error while hydrating this Suspense boundary.',
]);

expect(getVisibleChildren(container)).toEqual(
Expand All @@ -4556,21 +4560,7 @@ describe('ReactDOMFizzServer', () => {
);

await waitForAll([]);
if (__DEV__) {
expect(mockError.mock.calls.length).toBe(1);
expect(mockError.mock.calls[0]).toEqual([
'Warning: Text content did not match. Server: "%s" Client: "%s"%s',
'initial',
'replaced',
'\n' +
' in h2 (at **)\n' +
' in Suspense (at **)\n' +
' in div (at **)\n' +
' in App (at **)',
]);
} else {
expect(mockError.mock.calls.length).toBe(0);
}
expect(mockError.mock.calls.length).toBe(0);
} finally {
console.error = originalConsoleError;
}
Expand Down Expand Up @@ -4626,12 +4616,14 @@ describe('ReactDOMFizzServer', () => {

ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('Logged recoverable error: ' + error.message);
Scheduler.log(
'Logged recoverable error: ' + normalizeError(error.message),
);
},
});
await waitForAll([
'Logged recoverable error: uh oh',
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
'Logged recoverable error: There was an error while hydrating this Suspense boundary.',
]);

expect(getVisibleChildren(container)).toEqual(
Expand Down Expand Up @@ -4713,7 +4705,9 @@ describe('ReactDOMFizzServer', () => {

ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('Logged recoverable error: ' + error.message);
Scheduler.log(
'Logged recoverable error: ' + normalizeError(error.message),
);
},
});
await waitForAll([
Expand All @@ -4722,7 +4716,7 @@ describe('ReactDOMFizzServer', () => {
// onRecoverableError because the UI recovered without surfacing the
// error to the user.
'Logged recoverable error: first error',
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
'Logged recoverable error: There was an error while hydrating this Suspense boundary.',
]);
expect(mockError.mock.calls).toEqual([]);
mockError.mockClear();
Expand Down Expand Up @@ -4830,7 +4824,9 @@ describe('ReactDOMFizzServer', () => {

ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('Logged recoverable error: ' + error.message);
Scheduler.log(
'Logged recoverable error: ' + normalizeError(error.message),
);
},
});
await waitForAll(['suspending']);
Expand All @@ -4847,7 +4843,7 @@ describe('ReactDOMFizzServer', () => {
await waitForAll([
'throwing: first error',
'Logged recoverable error: first error',
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
'Logged recoverable error: There was an error while hydrating this Suspense boundary.',
]);
expect(getVisibleChildren(container)).toEqual(
<div>
Expand Down Expand Up @@ -4954,14 +4950,16 @@ describe('ReactDOMFizzServer', () => {

ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('Logged recoverable error: ' + error.message);
Scheduler.log(
'Logged recoverable error: ' + normalizeError(error.message),
);
},
});
await waitForAll([
'throwing: first error',
'suspending',
'Logged recoverable error: first error',
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
'Logged recoverable error: There was an error while hydrating this Suspense boundary.',
]);
expect(mockError.mock.calls).toEqual([]);
mockError.mockClear();
Expand Down Expand Up @@ -6341,13 +6339,7 @@ describe('ReactDOMFizzServer', () => {
});
await expect(async () => {
await waitForAll([]);
}).toErrorDev(
[
'Expected server HTML to contain a matching <span> in the root',
'An error occurred during hydration',
],
{withoutStack: 1},
);
}).toErrorDev(['An error occurred during hydration'], {withoutStack: 1});
expect(errors.length).toEqual(2);
expect(getVisibleChildren(container)).toEqual(<span />);
});
Expand Down
Loading