Skip to content

Commit 94fc49c

Browse files
sebmarkbageAndyPengc12
authored andcommitted
Move Hydration Mismatch Errors to Throw or Log Once (Kind of) (facebook#28502)
Stacked on facebook#28476. We used to `console.error` for every mismatch we found, up until the error we threw for the hydration mismatch. This changes it so that we build up a set of diffs up until we either throw or complete hydrating the root/suspense boundary. If we throw, we append the diff to the error message which gets passed to onRecoverableError (which by default is also logged to console). If we complete, we append it to a `console.error`. Since we early abort when something throws, it effectively means that we can only collect multiple diffs if there were preceding non-throwing mismatches - i.e. only properties mismatched but tag name matched. There can still be multiple logs if multiple siblings Suspense boundaries all error hydrating but then they're separate errors entirely. We still log an extra line about something erroring but I think the goal should be that it leads to a single recoverable or console.error. This doesn't yet actually print the diff as part of this message. That's in a follow up PR.
1 parent 1cec82b commit 94fc49c

16 files changed

+1207
-1271
lines changed

packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,8 @@ describe('ReactDOMFizzForm', () => {
182182
ReactDOMClient.hydrateRoot(container, <App isClient={true} />);
183183
});
184184
}).toErrorDev(
185-
'Prop `action` did not match. Server: "function" Client: "action"',
185+
"A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.",
186+
{withoutStack: true},
186187
);
187188
});
188189

@@ -344,7 +345,12 @@ describe('ReactDOMFizzForm', () => {
344345
await act(async () => {
345346
root = ReactDOMClient.hydrateRoot(container, <App />);
346347
});
347-
}).toErrorDev(['Prop `formTarget` did not match.']);
348+
}).toErrorDev(
349+
[
350+
"A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.",
351+
],
352+
{withoutStack: true},
353+
);
348354
await act(async () => {
349355
root.render(<App isUpdate={true} />);
350356
});

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

Lines changed: 66 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ let waitForPaint;
4747
let clientAct;
4848
let streamingContainer;
4949

50+
function normalizeError(msg) {
51+
// Take the first sentence to make it easier to assert on.
52+
const idx = msg.indexOf('.');
53+
if (idx > -1) {
54+
return msg.slice(0, idx + 1);
55+
}
56+
return msg;
57+
}
58+
5059
describe('ReactDOMFizzServer', () => {
5160
beforeEach(() => {
5261
jest.resetModules();
@@ -2391,26 +2400,22 @@ describe('ReactDOMFizzServer', () => {
23912400

23922401
ReactDOMClient.hydrateRoot(container, <App />, {
23932402
onRecoverableError(error) {
2394-
Scheduler.log('Log recoverable error: ' + error.message);
2403+
Scheduler.log(
2404+
'Log recoverable error: ' + normalizeError(error.message),
2405+
);
23952406
},
23962407
});
23972408

23982409
await expect(async () => {
23992410
// The first paint switches to client rendering due to mismatch
24002411
await waitForPaint([
24012412
'client',
2402-
'Log recoverable error: Hydration failed because the initial ' +
2403-
'UI does not match what was rendered on the server.',
2404-
'Log recoverable error: There was an error while hydrating. ' +
2405-
'Because the error happened outside of a Suspense boundary, the ' +
2406-
'entire root will switch to client rendering.',
2413+
"Log recoverable error: Hydration failed because the server rendered HTML didn't match the client.",
2414+
'Log recoverable error: There was an error while hydrating.',
24072415
]);
24082416
}).toErrorDev(
24092417
[
24102418
'Warning: An error occurred during hydration. The server HTML was replaced with client content.',
2411-
'Warning: Expected server HTML to contain a matching <div> in the root.\n' +
2412-
' in div (at **)\n' +
2413-
' in App (at **)',
24142419
],
24152420
{withoutStack: 1},
24162421
);
@@ -2474,7 +2479,9 @@ describe('ReactDOMFizzServer', () => {
24742479

24752480
ReactDOMClient.hydrateRoot(container, <App />, {
24762481
onRecoverableError(error) {
2477-
Scheduler.log('Log recoverable error: ' + error.message);
2482+
Scheduler.log(
2483+
'Log recoverable error: ' + normalizeError(error.message),
2484+
);
24782485
},
24792486
});
24802487

@@ -2483,18 +2490,12 @@ describe('ReactDOMFizzServer', () => {
24832490
// The first paint switches to client rendering due to mismatch
24842491
await waitForPaint([
24852492
'client',
2486-
'Log recoverable error: Hydration failed because the initial ' +
2487-
'UI does not match what was rendered on the server.',
2488-
'Log recoverable error: There was an error while hydrating. ' +
2489-
'Because the error happened outside of a Suspense boundary, the ' +
2490-
'entire root will switch to client rendering.',
2493+
"Log recoverable error: Hydration failed because the server rendered HTML didn't match the client.",
2494+
'Log recoverable error: There was an error while hydrating.',
24912495
]);
24922496
}).toErrorDev(
24932497
[
24942498
'Warning: An error occurred during hydration. The server HTML was replaced with client content',
2495-
'Warning: Expected server HTML to contain a matching <div> in the root.\n' +
2496-
' in div (at **)\n' +
2497-
' in App (at **)',
24982499
],
24992500
{withoutStack: 1},
25002501
);
@@ -2557,7 +2558,7 @@ describe('ReactDOMFizzServer', () => {
25572558
isClient = true;
25582559
ReactDOMClient.hydrateRoot(container, <App />, {
25592560
onRecoverableError(error) {
2560-
Scheduler.log(error.message);
2561+
Scheduler.log(normalizeError(error.message));
25612562
},
25622563
});
25632564

@@ -2567,9 +2568,7 @@ describe('ReactDOMFizzServer', () => {
25672568
await waitForAll([
25682569
'Yay!',
25692570
'Hydration error',
2570-
'There was an error while hydrating. Because the error happened ' +
2571-
'outside of a Suspense boundary, the entire root will switch ' +
2572-
'to client rendering.',
2571+
'There was an error while hydrating.',
25732572
]);
25742573
}).toErrorDev(
25752574
'An error occurred during hydration. The server HTML was replaced',
@@ -2739,7 +2738,7 @@ describe('ReactDOMFizzServer', () => {
27392738
isClient = true;
27402739
ReactDOMClient.hydrateRoot(container, <App />, {
27412740
onRecoverableError(error) {
2742-
Scheduler.log(error.message);
2741+
Scheduler.log(normalizeError(error.message));
27432742
},
27442743
});
27452744

@@ -2748,7 +2747,7 @@ describe('ReactDOMFizzServer', () => {
27482747
await waitForAll([
27492748
'Yay!',
27502749
'Hydration error',
2751-
'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
2750+
'There was an error while hydrating this Suspense boundary.',
27522751
]);
27532752
expect(getVisibleChildren(container)).toEqual(
27542753
<div>
@@ -3194,16 +3193,15 @@ describe('ReactDOMFizzServer', () => {
31943193
isClient = true;
31953194
ReactDOMClient.hydrateRoot(container, <App />, {
31963195
onRecoverableError(error) {
3197-
Scheduler.log(error.message);
3196+
Scheduler.log(normalizeError(error.message));
31983197
},
31993198
});
32003199

32013200
// An error logged but instead of surfacing it to the UI, we switched
32023201
// to client rendering.
32033202
await waitForAll([
32043203
'Hydration error',
3205-
'There was an error while hydrating this Suspense boundary. Switched ' +
3206-
'to client rendering.',
3204+
'There was an error while hydrating this Suspense boundary.',
32073205
]);
32083206
expect(getVisibleChildren(container)).toEqual(
32093207
<div>
@@ -3263,7 +3261,9 @@ describe('ReactDOMFizzServer', () => {
32633261

32643262
const root = ReactDOMClient.createRoot(container, {
32653263
onRecoverableError(error) {
3266-
Scheduler.log('Logged a recoverable error: ' + error.message);
3264+
Scheduler.log(
3265+
'Logged a recoverable error: ' + normalizeError(error.message),
3266+
);
32673267
},
32683268
});
32693269
React.startTransition(() => {
@@ -3339,7 +3339,9 @@ describe('ReactDOMFizzServer', () => {
33393339
isClient = true;
33403340
ReactDOMClient.hydrateRoot(container, <App />, {
33413341
onRecoverableError(error) {
3342-
Scheduler.log('Logged recoverable error: ' + error.message);
3342+
Scheduler.log(
3343+
'Logged recoverable error: ' + normalizeError(error.message),
3344+
);
33433345
},
33443346
});
33453347

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

33503352
'Logged recoverable error: Hydration error',
33513353
'Logged recoverable error: There was an error while hydrating this ' +
3352-
'Suspense boundary. Switched to client rendering.',
3354+
'Suspense boundary.',
33533355

33543356
'Logged recoverable error: Hydration error',
33553357
'Logged recoverable error: There was an error while hydrating this ' +
3356-
'Suspense boundary. Switched to client rendering.',
3358+
'Suspense boundary.',
33573359
]);
33583360
});
33593361

@@ -4395,7 +4397,9 @@ describe('ReactDOMFizzServer', () => {
43954397
const [ClientApp, clientResolve] = makeApp();
43964398
ReactDOMClient.hydrateRoot(container, <ClientApp />, {
43974399
onRecoverableError(error) {
4398-
Scheduler.log('Logged recoverable error: ' + error.message);
4400+
Scheduler.log(
4401+
'Logged recoverable error: ' + normalizeError(error.message),
4402+
);
43994403
},
44004404
});
44014405
await waitForAll([]);
@@ -4471,7 +4475,9 @@ describe('ReactDOMFizzServer', () => {
44714475
const [ClientApp, clientResolve] = makeApp();
44724476
ReactDOMClient.hydrateRoot(container, <ClientApp text="replaced" />, {
44734477
onRecoverableError(error) {
4474-
Scheduler.log('Logged recoverable error: ' + error.message);
4478+
Scheduler.log(
4479+
'Logged recoverable error: ' + normalizeError(error.message),
4480+
);
44754481
},
44764482
});
44774483
await waitForAll([]);
@@ -4486,14 +4492,10 @@ describe('ReactDOMFizzServer', () => {
44864492
// Now that the boundary resolves to it's children the hydration completes and discovers that there is a mismatch requiring
44874493
// client-side rendering.
44884494
await clientResolve();
4489-
await expect(async () => {
4490-
await waitForAll([
4491-
'Logged recoverable error: Text content does not match server-rendered HTML.',
4492-
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
4493-
]);
4494-
}).toErrorDev(
4495-
'Warning: Text content did not match. Server: "initial" Client: "replaced',
4496-
);
4495+
await waitForAll([
4496+
"Logged recoverable error: Hydration failed because the server rendered HTML didn't match the client.",
4497+
'Logged recoverable error: There was an error while hydrating this Suspense boundary.',
4498+
]);
44974499
expect(getVisibleChildren(container)).toEqual(
44984500
<div>
44994501
<p>A</p>
@@ -4539,12 +4541,14 @@ describe('ReactDOMFizzServer', () => {
45394541

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

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

45584562
await waitForAll([]);
4559-
if (__DEV__) {
4560-
expect(mockError.mock.calls.length).toBe(1);
4561-
expect(mockError.mock.calls[0]).toEqual([
4562-
'Warning: Text content did not match. Server: "%s" Client: "%s"%s',
4563-
'initial',
4564-
'replaced',
4565-
'\n' +
4566-
' in h2 (at **)\n' +
4567-
' in Suspense (at **)\n' +
4568-
' in div (at **)\n' +
4569-
' in App (at **)',
4570-
]);
4571-
} else {
4572-
expect(mockError.mock.calls.length).toBe(0);
4573-
}
4563+
expect(mockError.mock.calls.length).toBe(0);
45744564
} finally {
45754565
console.error = originalConsoleError;
45764566
}
@@ -4626,12 +4616,14 @@ describe('ReactDOMFizzServer', () => {
46264616

46274617
ReactDOMClient.hydrateRoot(container, <App />, {
46284618
onRecoverableError(error) {
4629-
Scheduler.log('Logged recoverable error: ' + error.message);
4619+
Scheduler.log(
4620+
'Logged recoverable error: ' + normalizeError(error.message),
4621+
);
46304622
},
46314623
});
46324624
await waitForAll([
46334625
'Logged recoverable error: uh oh',
4634-
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
4626+
'Logged recoverable error: There was an error while hydrating this Suspense boundary.',
46354627
]);
46364628

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

47144706
ReactDOMClient.hydrateRoot(container, <App />, {
47154707
onRecoverableError(error) {
4716-
Scheduler.log('Logged recoverable error: ' + error.message);
4708+
Scheduler.log(
4709+
'Logged recoverable error: ' + normalizeError(error.message),
4710+
);
47174711
},
47184712
});
47194713
await waitForAll([
@@ -4722,7 +4716,7 @@ describe('ReactDOMFizzServer', () => {
47224716
// onRecoverableError because the UI recovered without surfacing the
47234717
// error to the user.
47244718
'Logged recoverable error: first error',
4725-
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
4719+
'Logged recoverable error: There was an error while hydrating this Suspense boundary.',
47264720
]);
47274721
expect(mockError.mock.calls).toEqual([]);
47284722
mockError.mockClear();
@@ -4830,7 +4824,9 @@ describe('ReactDOMFizzServer', () => {
48304824

48314825
ReactDOMClient.hydrateRoot(container, <App />, {
48324826
onRecoverableError(error) {
4833-
Scheduler.log('Logged recoverable error: ' + error.message);
4827+
Scheduler.log(
4828+
'Logged recoverable error: ' + normalizeError(error.message),
4829+
);
48344830
},
48354831
});
48364832
await waitForAll(['suspending']);
@@ -4847,7 +4843,7 @@ describe('ReactDOMFizzServer', () => {
48474843
await waitForAll([
48484844
'throwing: first error',
48494845
'Logged recoverable error: first error',
4850-
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
4846+
'Logged recoverable error: There was an error while hydrating this Suspense boundary.',
48514847
]);
48524848
expect(getVisibleChildren(container)).toEqual(
48534849
<div>
@@ -4954,14 +4950,16 @@ describe('ReactDOMFizzServer', () => {
49544950

49554951
ReactDOMClient.hydrateRoot(container, <App />, {
49564952
onRecoverableError(error) {
4957-
Scheduler.log('Logged recoverable error: ' + error.message);
4953+
Scheduler.log(
4954+
'Logged recoverable error: ' + normalizeError(error.message),
4955+
);
49584956
},
49594957
});
49604958
await waitForAll([
49614959
'throwing: first error',
49624960
'suspending',
49634961
'Logged recoverable error: first error',
4964-
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
4962+
'Logged recoverable error: There was an error while hydrating this Suspense boundary.',
49654963
]);
49664964
expect(mockError.mock.calls).toEqual([]);
49674965
mockError.mockClear();
@@ -6341,13 +6339,7 @@ describe('ReactDOMFizzServer', () => {
63416339
});
63426340
await expect(async () => {
63436341
await waitForAll([]);
6344-
}).toErrorDev(
6345-
[
6346-
'Expected server HTML to contain a matching <span> in the root',
6347-
'An error occurred during hydration',
6348-
],
6349-
{withoutStack: 1},
6350-
);
6342+
}).toErrorDev(['An error occurred during hydration'], {withoutStack: 1});
63516343
expect(errors.length).toEqual(2);
63526344
expect(getVisibleChildren(container)).toEqual(<span />);
63536345
});

0 commit comments

Comments
 (0)