Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 51c8411

Browse files
authoredFeb 17, 2022
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.
1 parent 79ed5e1 commit 51c8411

11 files changed

+315
-41
lines changed
 

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

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,11 @@ describe('ReactDOMFizzServer', () => {
358358
window.__INIT__ = function() {
359359
bootstrapped = true;
360360
// Attempt to hydrate the content.
361-
ReactDOM.hydrateRoot(container, <App isClient={true} />);
361+
ReactDOM.hydrateRoot(container, <App isClient={true} />, {
362+
onRecoverableError(error) {
363+
Scheduler.unstable_yieldValue(error.message);
364+
},
365+
});
362366
};
363367

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

396400
// Now we can client render it instead.
397-
Scheduler.unstable_flushAll();
401+
expect(Scheduler).toFlushAndYield([
402+
'The server could not finish this Suspense boundary, likely due to ' +
403+
'an error during server rendering. Switched to client rendering.',
404+
]);
398405

399406
// The client rendered HTML is now in place.
400407
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
@@ -465,7 +472,11 @@ describe('ReactDOMFizzServer', () => {
465472
expect(loggedErrors).toEqual([]);
466473

467474
// Attempt to hydrate the content.
468-
ReactDOM.hydrateRoot(container, <App isClient={true} />);
475+
ReactDOM.hydrateRoot(container, <App isClient={true} />, {
476+
onRecoverableError(error) {
477+
Scheduler.unstable_yieldValue(error.message);
478+
},
479+
});
469480
Scheduler.unstable_flushAll();
470481

471482
// We're still loading because we're waiting for the server to stream more content.
@@ -484,7 +495,10 @@ describe('ReactDOMFizzServer', () => {
484495
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
485496

486497
// Now we can client render it instead.
487-
Scheduler.unstable_flushAll();
498+
expect(Scheduler).toFlushAndYield([
499+
'The server could not finish this Suspense boundary, likely due to ' +
500+
'an error during server rendering. Switched to client rendering.',
501+
]);
488502

489503
// The client rendered HTML is now in place.
490504
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
@@ -766,7 +780,11 @@ describe('ReactDOMFizzServer', () => {
766780
// We're still showing a fallback.
767781

768782
// Attempt to hydrate the content.
769-
ReactDOM.hydrateRoot(container, <App />);
783+
ReactDOM.hydrateRoot(container, <App />, {
784+
onRecoverableError(error) {
785+
Scheduler.unstable_yieldValue(error.message);
786+
},
787+
});
770788
Scheduler.unstable_flushAll();
771789

772790
// We're still loading because we're waiting for the server to stream more content.
@@ -778,7 +796,10 @@ describe('ReactDOMFizzServer', () => {
778796
});
779797

780798
// We still can't render it on the client.
781-
Scheduler.unstable_flushAll();
799+
expect(Scheduler).toFlushAndYield([
800+
'The server could not finish this Suspense boundary, likely due to an ' +
801+
'error during server rendering. Switched to client rendering.',
802+
]);
782803
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
783804

784805
// We now resolve it on the client.
@@ -1455,7 +1476,11 @@ describe('ReactDOMFizzServer', () => {
14551476
// We're still showing a fallback.
14561477

14571478
// Attempt to hydrate the content.
1458-
ReactDOM.hydrateRoot(container, <App isClient={true} />);
1479+
ReactDOM.hydrateRoot(container, <App isClient={true} />, {
1480+
onRecoverableError(error) {
1481+
Scheduler.unstable_yieldValue(error.message);
1482+
},
1483+
});
14591484
Scheduler.unstable_flushAll();
14601485

14611486
// We're still loading because we're waiting for the server to stream more content.
@@ -1484,7 +1509,10 @@ describe('ReactDOMFizzServer', () => {
14841509
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
14851510

14861511
// That will let us client render it instead.
1487-
Scheduler.unstable_flushAll();
1512+
expect(Scheduler).toFlushAndYield([
1513+
'The server could not finish this Suspense boundary, likely due to ' +
1514+
'an error during server rendering. Switched to client rendering.',
1515+
]);
14881516

14891517
// The client rendered HTML is now in place.
14901518
expect(getVisibleChildren(container)).toEqual(
@@ -1736,8 +1764,11 @@ describe('ReactDOMFizzServer', () => {
17361764
// The first paint switches to client rendering due to mismatch
17371765
expect(Scheduler).toFlushUntilNextPaint([
17381766
'client',
1739-
'Log recoverable error: An error occurred during hydration. ' +
1740-
'The server HTML was replaced with client content',
1767+
'Log recoverable error: Hydration failed because the initial ' +
1768+
'UI does not match what was rendered on the server.',
1769+
'Log recoverable error: There was an error while hydrating. ' +
1770+
'Because the error happened outside of a Suspense boundary, the ' +
1771+
'entire root will switch to client rendering.',
17411772
]);
17421773
}).toErrorDev(
17431774
[
@@ -1834,8 +1865,11 @@ describe('ReactDOMFizzServer', () => {
18341865
// The first paint switches to client rendering due to mismatch
18351866
expect(Scheduler).toFlushUntilNextPaint([
18361867
'client',
1837-
'Log recoverable error: An error occurred during hydration. ' +
1838-
'The server HTML was replaced with client content',
1868+
'Log recoverable error: Hydration failed because the initial ' +
1869+
'UI does not match what was rendered on the server.',
1870+
'Log recoverable error: There was an error while hydrating. ' +
1871+
'Because the error happened outside of a Suspense boundary, the ' +
1872+
'entire root will switch to client rendering.',
18391873
]);
18401874
}).toErrorDev(
18411875
[
@@ -1928,7 +1962,13 @@ describe('ReactDOMFizzServer', () => {
19281962
// An error logged but instead of surfacing it to the UI, we switched
19291963
// to client rendering.
19301964
expect(() => {
1931-
expect(Scheduler).toFlushAndYield(['Yay!', 'Hydration error']);
1965+
expect(Scheduler).toFlushAndYield([
1966+
'Yay!',
1967+
'Hydration error',
1968+
'There was an error while hydrating. Because the error happened ' +
1969+
'outside of a Suspense boundary, the entire root will switch ' +
1970+
'to client rendering.',
1971+
]);
19321972
}).toErrorDev(
19331973
'An error occurred during hydration. The server HTML was replaced',
19341974
{withoutStack: true},
@@ -2012,7 +2052,11 @@ describe('ReactDOMFizzServer', () => {
20122052

20132053
// An error logged but instead of surfacing it to the UI, we switched
20142054
// to client rendering.
2015-
expect(Scheduler).toFlushAndYield(['Yay!', 'Hydration error']);
2055+
expect(Scheduler).toFlushAndYield([
2056+
'Yay!',
2057+
'Hydration error',
2058+
'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
2059+
]);
20162060
expect(getVisibleChildren(container)).toEqual(
20172061
<div>
20182062
<span />
@@ -2178,7 +2222,11 @@ describe('ReactDOMFizzServer', () => {
21782222

21792223
// An error logged but instead of surfacing it to the UI, we switched
21802224
// to client rendering.
2181-
expect(Scheduler).toFlushAndYield(['Hydration error']);
2225+
expect(Scheduler).toFlushAndYield([
2226+
'Hydration error',
2227+
'There was an error while hydrating this Suspense boundary. Switched ' +
2228+
'to client rendering.',
2229+
]);
21822230
expect(getVisibleChildren(container)).toEqual(
21832231
<div>
21842232
<span />
@@ -2328,8 +2376,14 @@ describe('ReactDOMFizzServer', () => {
23282376
expect(Scheduler).toFlushAndYield([
23292377
'A',
23302378
'B',
2379+
23312380
'Logged recoverable error: Hydration error',
2381+
'Logged recoverable error: There was an error while hydrating this ' +
2382+
'Suspense boundary. Switched to client rendering.',
2383+
23322384
'Logged recoverable error: Hydration error',
2385+
'Logged recoverable error: There was an error while hydrating this ' +
2386+
'Suspense boundary. Switched to client rendering.',
23332387
]);
23342388
});
23352389
});

‎packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,15 +232,23 @@ describe('ReactDOMFizzShellHydration', () => {
232232

233233
// Hydration suspends because the data for the shell hasn't loaded yet
234234
const root = await clientAct(async () => {
235-
return ReactDOM.hydrateRoot(container, <App />);
235+
return ReactDOM.hydrateRoot(container, <App />, {
236+
onRecoverableError(error) {
237+
Scheduler.unstable_yieldValue(error.message);
238+
},
239+
});
236240
});
237241
expect(Scheduler).toHaveYielded(['Suspend! [Shell]']);
238242
expect(container.textContent).toBe('Shell');
239243

240244
await clientAct(async () => {
241245
root.render(<Text text="New screen" />);
242246
});
243-
expect(Scheduler).toHaveYielded(['New screen']);
247+
expect(Scheduler).toHaveYielded([
248+
'This root received an early update, before anything was able ' +
249+
'hydrate. Switched the entire root to client rendering.',
250+
'New screen',
251+
]);
244252
expect(container.textContent).toBe('New screen');
245253
});
246254
});

‎packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js

Lines changed: 126 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,8 @@ describe('ReactDOMServerPartialHydration', () => {
348348
'Component',
349349

350350
// Hydration mismatch is logged
351-
'An error occurred during hydration. The server HTML was replaced with client content',
351+
'Hydration failed because the initial UI does not match what was rendered on the server.',
352+
'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
352353
]);
353354

354355
// Client rendered - suspense comment nodes removed
@@ -432,8 +433,11 @@ describe('ReactDOMServerPartialHydration', () => {
432433
onDeleted(node) {
433434
deleted.push(node);
434435
},
436+
onRecoverableError(error) {
437+
Scheduler.unstable_yieldValue(error.message);
438+
},
435439
});
436-
Scheduler.unstable_flushAll();
440+
expect(Scheduler).toFlushAndYield([]);
437441

438442
expect(hydrated.length).toBe(0);
439443
expect(deleted.length).toBe(0);
@@ -453,6 +457,12 @@ describe('ReactDOMServerPartialHydration', () => {
453457

454458
Scheduler.unstable_flushAll();
455459
jest.runAllTimers();
460+
expect(Scheduler).toHaveYielded([
461+
'This Suspense boundary received an update before it finished ' +
462+
'hydrating. This caused the boundary to switch to client rendering. ' +
463+
'The usual way to fix this is to wrap the original update ' +
464+
'in startTransition.',
465+
]);
456466

457467
expect(hydrated.length).toBe(1);
458468
expect(deleted.length).toBe(1);
@@ -507,7 +517,11 @@ describe('ReactDOMServerPartialHydration', () => {
507517

508518
expect(() => {
509519
act(() => {
510-
ReactDOM.hydrateRoot(container, <App hasB={false} />);
520+
ReactDOM.hydrateRoot(container, <App hasB={false} />, {
521+
onRecoverableError(error) {
522+
Scheduler.unstable_yieldValue(error.message);
523+
},
524+
});
511525
});
512526
}).toErrorDev('Did not expect server HTML to contain a <span> in <div>');
513527

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

519533
if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
534+
expect(Scheduler).toHaveYielded([
535+
'There was an error while hydrating this Suspense boundary. ' +
536+
'Switched to client rendering.',
537+
]);
520538
expect(ref.current).not.toBe(span);
521539
} else {
522540
expect(ref.current).toBe(span);
@@ -642,8 +660,8 @@ describe('ReactDOMServerPartialHydration', () => {
642660
}).toErrorDev('Did not expect server HTML to contain a <span> in <div>');
643661
if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
644662
expect(Scheduler).toHaveYielded([
645-
'An error occurred during hydration. The server HTML was replaced ' +
646-
'with client content',
663+
'Hydration failed because the initial UI does not match what was rendered on the server.',
664+
'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
647665
]);
648666
}
649667

@@ -1087,6 +1105,11 @@ describe('ReactDOMServerPartialHydration', () => {
10871105
const root = ReactDOM.hydrateRoot(
10881106
container,
10891107
<App text="Hello" className="hello" />,
1108+
{
1109+
onRecoverableError(error) {
1110+
Scheduler.unstable_yieldValue(error.message);
1111+
},
1112+
},
10901113
);
10911114
Scheduler.unstable_flushAll();
10921115
jest.runAllTimers();
@@ -1097,6 +1120,12 @@ describe('ReactDOMServerPartialHydration', () => {
10971120
root.render(<App text="Hi" className="hi" />);
10981121
Scheduler.unstable_flushAll();
10991122
jest.runAllTimers();
1123+
expect(Scheduler).toHaveYielded([
1124+
'This Suspense boundary received an update before it finished ' +
1125+
'hydrating. This caused the boundary to switch to client ' +
1126+
'rendering. The usual way to fix this is to wrap the original ' +
1127+
'update in startTransition.',
1128+
]);
11001129

11011130
// Flushing now should delete the existing content and show the fallback.
11021131

@@ -1162,6 +1191,11 @@ describe('ReactDOMServerPartialHydration', () => {
11621191
const root = ReactDOM.hydrateRoot(
11631192
container,
11641193
<App text="Hello" className="hello" />,
1194+
{
1195+
onRecoverableError(error) {
1196+
Scheduler.unstable_yieldValue(error.message);
1197+
},
1198+
},
11651199
);
11661200
Scheduler.unstable_flushAll();
11671201
jest.runAllTimers();
@@ -1175,6 +1209,12 @@ describe('ReactDOMServerPartialHydration', () => {
11751209
// Flushing now should delete the existing content and show the fallback.
11761210
Scheduler.unstable_flushAll();
11771211
jest.runAllTimers();
1212+
expect(Scheduler).toHaveYielded([
1213+
'This Suspense boundary received an update before it finished ' +
1214+
'hydrating. This caused the boundary to switch to client rendering. ' +
1215+
'The usual way to fix this is to wrap the original update ' +
1216+
'in startTransition.',
1217+
]);
11781218

11791219
expect(container.getElementsByTagName('span').length).toBe(1);
11801220
expect(ref.current).toBe(span);
@@ -1236,6 +1276,11 @@ describe('ReactDOMServerPartialHydration', () => {
12361276
const root = ReactDOM.hydrateRoot(
12371277
container,
12381278
<App text="Hello" className="hello" />,
1279+
{
1280+
onRecoverableError(error) {
1281+
Scheduler.unstable_yieldValue(error.message);
1282+
},
1283+
},
12391284
);
12401285
Scheduler.unstable_flushAll();
12411286
jest.runAllTimers();
@@ -1257,6 +1302,12 @@ describe('ReactDOMServerPartialHydration', () => {
12571302
suspend = false;
12581303
resolve();
12591304
await promise;
1305+
expect(Scheduler).toHaveYielded([
1306+
'This Suspense boundary received an update before it finished ' +
1307+
'hydrating. This caused the boundary to switch to client rendering. ' +
1308+
'The usual way to fix this is to wrap the original update ' +
1309+
'in startTransition.',
1310+
]);
12601311

12611312
Scheduler.unstable_flushAll();
12621313
jest.runAllTimers();
@@ -1545,6 +1596,11 @@ describe('ReactDOMServerPartialHydration', () => {
15451596
<Context.Provider value={{text: 'Hello', className: 'hello'}}>
15461597
<App />
15471598
</Context.Provider>,
1599+
{
1600+
onRecoverableError(error) {
1601+
Scheduler.unstable_yieldValue(error.message);
1602+
},
1603+
},
15481604
);
15491605
Scheduler.unstable_flushAll();
15501606
jest.runAllTimers();
@@ -1561,6 +1617,12 @@ describe('ReactDOMServerPartialHydration', () => {
15611617
// Flushing now should delete the existing content and show the fallback.
15621618
Scheduler.unstable_flushAll();
15631619
jest.runAllTimers();
1620+
expect(Scheduler).toHaveYielded([
1621+
'This Suspense boundary received an update before it finished ' +
1622+
'hydrating. This caused the boundary to switch to client rendering. ' +
1623+
'The usual way to fix this is to wrap the original update ' +
1624+
'in startTransition.',
1625+
]);
15641626

15651627
expect(container.getElementsByTagName('span').length).toBe(0);
15661628
expect(ref.current).toBe(null);
@@ -1618,8 +1680,15 @@ describe('ReactDOMServerPartialHydration', () => {
16181680

16191681
// On the client we have the data available quickly for some reason.
16201682
suspend = false;
1621-
ReactDOM.hydrateRoot(container, <App />);
1622-
Scheduler.unstable_flushAll();
1683+
ReactDOM.hydrateRoot(container, <App />, {
1684+
onRecoverableError(error) {
1685+
Scheduler.unstable_yieldValue(error.message);
1686+
},
1687+
});
1688+
expect(Scheduler).toFlushAndYield([
1689+
'The server could not finish this Suspense boundary, likely due to ' +
1690+
'an error during server rendering. Switched to client rendering.',
1691+
]);
16231692
jest.runAllTimers();
16241693

16251694
expect(container.textContent).toBe('Hello');
@@ -1673,8 +1742,15 @@ describe('ReactDOMServerPartialHydration', () => {
16731742

16741743
// On the client we have the data available quickly for some reason.
16751744
suspend = false;
1676-
ReactDOM.hydrateRoot(container, <App />);
1677-
Scheduler.unstable_flushAll();
1745+
ReactDOM.hydrateRoot(container, <App />, {
1746+
onRecoverableError(error) {
1747+
Scheduler.unstable_yieldValue(error.message);
1748+
},
1749+
});
1750+
expect(Scheduler).toFlushAndYield([
1751+
'The server could not finish this Suspense boundary, likely due to ' +
1752+
'an error during server rendering. Switched to client rendering.',
1753+
]);
16781754
// This will have exceeded the suspended time so we should timeout.
16791755
jest.advanceTimersByTime(500);
16801756
// The boundary should longer be suspended for the middle content
@@ -1733,8 +1809,15 @@ describe('ReactDOMServerPartialHydration', () => {
17331809

17341810
// On the client we have the data available quickly for some reason.
17351811
suspend = false;
1736-
ReactDOM.hydrateRoot(container, <App />);
1737-
Scheduler.unstable_flushAll();
1812+
ReactDOM.hydrateRoot(container, <App />, {
1813+
onRecoverableError(error) {
1814+
Scheduler.unstable_yieldValue(error.message);
1815+
},
1816+
});
1817+
expect(Scheduler).toFlushAndYield([
1818+
'The server could not finish this Suspense boundary, likely due to ' +
1819+
'an error during server rendering. Switched to client rendering.',
1820+
]);
17381821
// This will have exceeded the suspended time so we should timeout.
17391822
jest.advanceTimersByTime(500);
17401823
// The boundary should longer be suspended for the middle content
@@ -2036,10 +2119,17 @@ describe('ReactDOMServerPartialHydration', () => {
20362119
const container = document.createElement('div');
20372120
container.innerHTML = html;
20382121

2039-
ReactDOM.hydrateRoot(container, <App />);
2122+
ReactDOM.hydrateRoot(container, <App />, {
2123+
onRecoverableError(error) {
2124+
Scheduler.unstable_yieldValue(error.message);
2125+
},
2126+
});
20402127

20412128
suspend = true;
2042-
Scheduler.unstable_flushAll();
2129+
expect(Scheduler).toFlushAndYield([
2130+
'The server could not finish this Suspense boundary, likely due to ' +
2131+
'an error during server rendering. Switched to client rendering.',
2132+
]);
20432133

20442134
// We haven't hydrated the second child but the placeholder is still in the list.
20452135
expect(container.textContent).toBe('ALoading B');
@@ -2094,8 +2184,15 @@ describe('ReactDOMServerPartialHydration', () => {
20942184
const span = container.getElementsByTagName('span')[1];
20952185

20962186
suspend = false;
2097-
ReactDOM.hydrateRoot(container, <App />);
2098-
Scheduler.unstable_flushAll();
2187+
ReactDOM.hydrateRoot(container, <App />, {
2188+
onRecoverableError(error) {
2189+
Scheduler.unstable_yieldValue(error.message);
2190+
},
2191+
});
2192+
expect(Scheduler).toFlushAndYield([
2193+
'The server could not finish this Suspense boundary, likely due to ' +
2194+
'an error during server rendering. Switched to client rendering.',
2195+
]);
20992196
jest.runAllTimers();
21002197

21012198
expect(ref.current).toBe(span);
@@ -2193,6 +2290,11 @@ describe('ReactDOMServerPartialHydration', () => {
21932290
<ClassName.Provider value={'hello'}>
21942291
<App text="Hello" />
21952292
</ClassName.Provider>,
2293+
{
2294+
onRecoverableError(error) {
2295+
Scheduler.unstable_yieldValue(error.message);
2296+
},
2297+
},
21962298
);
21972299
Scheduler.unstable_flushAll();
21982300
jest.runAllTimers();
@@ -2212,6 +2314,12 @@ describe('ReactDOMServerPartialHydration', () => {
22122314
// This will force all expiration times to flush.
22132315
Scheduler.unstable_flushAll();
22142316
jest.runAllTimers();
2317+
expect(Scheduler).toHaveYielded([
2318+
'This Suspense boundary received an update before it finished ' +
2319+
'hydrating. This caused the boundary to switch to client rendering. ' +
2320+
'The usual way to fix this is to wrap the original update ' +
2321+
'in startTransition.',
2322+
]);
22152323

22162324
// This will now be a new span because we weren't able to hydrate before
22172325
const newSpan = container.getElementsByTagName('span')[0];
@@ -3232,12 +3340,11 @@ describe('ReactDOMServerPartialHydration', () => {
32323340
{withoutStack: 1},
32333341
);
32343342
expect(Scheduler).toHaveYielded([
3235-
'Log recoverable error: An error occurred during hydration. The server ' +
3236-
'HTML was replaced with client content',
3343+
'Log recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.',
32373344
// TODO: There were multiple mismatches in a single container. Should
32383345
// we attempt to de-dupe them?
3239-
'Log recoverable error: An error occurred during hydration. The server ' +
3240-
'HTML was replaced with client content',
3346+
'Log recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.',
3347+
'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.',
32413348
]);
32423349

32433350
// We show fallback state when mismatch happens at root

‎packages/react-reconciler/src/ReactFiberBeginWork.new.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ import {
200200
resetHydrationState,
201201
tryToClaimNextHydratableInstance,
202202
warnIfHydrating,
203+
queueHydrationError,
203204
} from './ReactFiberHydrationContext.new';
204205
import {
205206
adoptClassInstance,
@@ -2145,6 +2146,10 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
21452146
current,
21462147
workInProgress,
21472148
renderLanes,
2149+
new Error(
2150+
'There was an error while hydrating this Suspense boundary. ' +
2151+
'Switched to client rendering.',
2152+
),
21482153
);
21492154
} else if (
21502155
(workInProgress.memoizedState: null | SuspenseState) !== null
@@ -2531,7 +2536,19 @@ function retrySuspenseComponentWithoutHydrating(
25312536
current: Fiber,
25322537
workInProgress: Fiber,
25332538
renderLanes: Lanes,
2539+
recoverableError: Error | null,
25342540
) {
2541+
// Falling back to client rendering. Because this has performance
2542+
// implications, it's considered a recoverable error, even though the user
2543+
// likely won't observe anything wrong with the UI.
2544+
//
2545+
// The error is passed in as an argument to enforce that every caller provide
2546+
// a custom message, or explicitly opt out (currently the only path that opts
2547+
// out is legacy mode; every concurrent path provides an error).
2548+
if (recoverableError !== null) {
2549+
queueHydrationError(recoverableError);
2550+
}
2551+
25352552
// This will add the old fiber to the deletion list
25362553
reconcileChildFibers(workInProgress, current.child, null, renderLanes);
25372554

@@ -2648,6 +2665,10 @@ function updateDehydratedSuspenseComponent(
26482665
current,
26492666
workInProgress,
26502667
renderLanes,
2668+
// TODO: When we delete legacy mode, we should make this error argument
2669+
// required — every concurrent mode path that causes hydration to
2670+
// de-opt to client rendering should have an error message.
2671+
null,
26512672
);
26522673
}
26532674

@@ -2659,6 +2680,14 @@ function updateDehydratedSuspenseComponent(
26592680
current,
26602681
workInProgress,
26612682
renderLanes,
2683+
// TODO: The server should serialize the error message so we can log it
2684+
// here on the client. Or, in production, a hash/id that corresponds to
2685+
// the error.
2686+
new Error(
2687+
'The server could not finish this Suspense boundary, likely ' +
2688+
'due to an error during server rendering. Switched to ' +
2689+
'client rendering.',
2690+
),
26622691
);
26632692
}
26642693

@@ -2717,6 +2746,12 @@ function updateDehydratedSuspenseComponent(
27172746
current,
27182747
workInProgress,
27192748
renderLanes,
2749+
new Error(
2750+
'This Suspense boundary received an update before it finished ' +
2751+
'hydrating. This caused the boundary to switch to client rendering. ' +
2752+
'The usual way to fix this is to wrap the original update ' +
2753+
'in startTransition.',
2754+
),
27202755
);
27212756
} else if (isSuspenseInstancePending(suspenseInstance)) {
27222757
// This component is still pending more data from the server, so we can't hydrate its

‎packages/react-reconciler/src/ReactFiberBeginWork.old.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ import {
200200
resetHydrationState,
201201
tryToClaimNextHydratableInstance,
202202
warnIfHydrating,
203+
queueHydrationError,
203204
} from './ReactFiberHydrationContext.old';
204205
import {
205206
adoptClassInstance,
@@ -2145,6 +2146,10 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
21452146
current,
21462147
workInProgress,
21472148
renderLanes,
2149+
new Error(
2150+
'There was an error while hydrating this Suspense boundary. ' +
2151+
'Switched to client rendering.',
2152+
),
21482153
);
21492154
} else if (
21502155
(workInProgress.memoizedState: null | SuspenseState) !== null
@@ -2531,7 +2536,19 @@ function retrySuspenseComponentWithoutHydrating(
25312536
current: Fiber,
25322537
workInProgress: Fiber,
25332538
renderLanes: Lanes,
2539+
recoverableError: Error | null,
25342540
) {
2541+
// Falling back to client rendering. Because this has performance
2542+
// implications, it's considered a recoverable error, even though the user
2543+
// likely won't observe anything wrong with the UI.
2544+
//
2545+
// The error is passed in as an argument to enforce that every caller provide
2546+
// a custom message, or explicitly opt out (currently the only path that opts
2547+
// out is legacy mode; every concurrent path provides an error).
2548+
if (recoverableError !== null) {
2549+
queueHydrationError(recoverableError);
2550+
}
2551+
25352552
// This will add the old fiber to the deletion list
25362553
reconcileChildFibers(workInProgress, current.child, null, renderLanes);
25372554

@@ -2648,6 +2665,10 @@ function updateDehydratedSuspenseComponent(
26482665
current,
26492666
workInProgress,
26502667
renderLanes,
2668+
// TODO: When we delete legacy mode, we should make this error argument
2669+
// required — every concurrent mode path that causes hydration to
2670+
// de-opt to client rendering should have an error message.
2671+
null,
26512672
);
26522673
}
26532674

@@ -2659,6 +2680,14 @@ function updateDehydratedSuspenseComponent(
26592680
current,
26602681
workInProgress,
26612682
renderLanes,
2683+
// TODO: The server should serialize the error message so we can log it
2684+
// here on the client. Or, in production, a hash/id that corresponds to
2685+
// the error.
2686+
new Error(
2687+
'The server could not finish this Suspense boundary, likely ' +
2688+
'due to an error during server rendering. Switched to ' +
2689+
'client rendering.',
2690+
),
26622691
);
26632692
}
26642693

@@ -2717,6 +2746,12 @@ function updateDehydratedSuspenseComponent(
27172746
current,
27182747
workInProgress,
27192748
renderLanes,
2749+
new Error(
2750+
'This Suspense boundary received an update before it finished ' +
2751+
'hydrating. This caused the boundary to switch to client rendering. ' +
2752+
'The usual way to fix this is to wrap the original update ' +
2753+
'in startTransition.',
2754+
),
27202755
);
27212756
} else if (isSuspenseInstancePending(suspenseInstance)) {
27222757
// This component is still pending more data from the server, so we can't hydrate its

‎packages/react-reconciler/src/ReactFiberHydrationContext.new.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,8 @@ function shouldClientRenderOnMismatch(fiber: Fiber) {
358358

359359
function throwOnHydrationMismatch(fiber: Fiber) {
360360
throw new Error(
361-
'An error occurred during hydration. The server HTML was replaced with client content',
361+
'Hydration failed because the initial UI does not match what was ' +
362+
'rendered on the server.',
362363
);
363364
}
364365

‎packages/react-reconciler/src/ReactFiberHydrationContext.old.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,8 @@ function shouldClientRenderOnMismatch(fiber: Fiber) {
358358

359359
function throwOnHydrationMismatch(fiber: Fiber) {
360360
throw new Error(
361-
'An error occurred during hydration. The server HTML was replaced with client content',
361+
'Hydration failed because the initial UI does not match what was ' +
362+
'rendered on the server.',
362363
);
363364
}
364365

‎packages/react-reconciler/src/ReactFiberWorkLoop.new.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,6 @@ export function scheduleUpdateOnFiber(
518518

519519
if (root.isDehydrated && root.tag !== LegacyRoot) {
520520
// This root's shell hasn't hydrated yet. Revert to client rendering.
521-
// TODO: Log a recoverable error
522521
if (workInProgressRoot === root) {
523522
// If this happened during an interleaved event, interrupt the
524523
// in-progress hydration. Theoretically, we could attempt to force a
@@ -538,6 +537,12 @@ export function scheduleUpdateOnFiber(
538537
prepareFreshStack(root, NoLanes);
539538
}
540539
root.isDehydrated = false;
540+
const error = new Error(
541+
'This root received an early update, before anything was able ' +
542+
'hydrate. Switched the entire root to client rendering.',
543+
);
544+
const onRecoverableError = root.onRecoverableError;
545+
onRecoverableError(error);
541546
} else if (root === workInProgressRoot) {
542547
// TODO: Consolidate with `isInterleavedUpdate` check
543548

@@ -951,6 +956,12 @@ function recoverFromConcurrentError(root, errorRetryLanes) {
951956
if (__DEV__) {
952957
errorHydratingContainer(root.containerInfo);
953958
}
959+
const error = new Error(
960+
'There was an error while hydrating. Because the error happened outside ' +
961+
'of a Suspense boundary, the entire root will switch to ' +
962+
'client rendering.',
963+
);
964+
renderDidError(error);
954965
}
955966

956967
const errorsFromFirstAttempt = workInProgressRootConcurrentErrors;

‎packages/react-reconciler/src/ReactFiberWorkLoop.old.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,6 @@ export function scheduleUpdateOnFiber(
518518

519519
if (root.isDehydrated && root.tag !== LegacyRoot) {
520520
// This root's shell hasn't hydrated yet. Revert to client rendering.
521-
// TODO: Log a recoverable error
522521
if (workInProgressRoot === root) {
523522
// If this happened during an interleaved event, interrupt the
524523
// in-progress hydration. Theoretically, we could attempt to force a
@@ -538,6 +537,12 @@ export function scheduleUpdateOnFiber(
538537
prepareFreshStack(root, NoLanes);
539538
}
540539
root.isDehydrated = false;
540+
const error = new Error(
541+
'This root received an early update, before anything was able ' +
542+
'hydrate. Switched the entire root to client rendering.',
543+
);
544+
const onRecoverableError = root.onRecoverableError;
545+
onRecoverableError(error);
541546
} else if (root === workInProgressRoot) {
542547
// TODO: Consolidate with `isInterleavedUpdate` check
543548

@@ -951,6 +956,12 @@ function recoverFromConcurrentError(root, errorRetryLanes) {
951956
if (__DEV__) {
952957
errorHydratingContainer(root.containerInfo);
953958
}
959+
const error = new Error(
960+
'There was an error while hydrating. Because the error happened outside ' +
961+
'of a Suspense boundary, the entire root will switch to ' +
962+
'client rendering.',
963+
);
964+
renderDidError(error);
954965
}
955966

956967
const errorsFromFirstAttempt = workInProgressRootConcurrentErrors;

‎packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,9 @@ describe('useMutableSourceHydration', () => {
279279
'Log error: Cannot read from mutable source during the current ' +
280280
'render without tearing. This may be a bug in React. Please file ' +
281281
'an issue.',
282+
'Log error: There was an error while hydrating. Because the error ' +
283+
'happened outside of a Suspense boundary, the entire root will ' +
284+
'switch to client rendering.',
282285
]);
283286
expect(source.listenerCount).toBe(2);
284287
});
@@ -369,6 +372,9 @@ describe('useMutableSourceHydration', () => {
369372
'Log error: Cannot read from mutable source during the current ' +
370373
'render without tearing. This may be a bug in React. Please file ' +
371374
'an issue.',
375+
'Log error: There was an error while hydrating. Because the error ' +
376+
'happened outside of a Suspense boundary, the entire root will ' +
377+
'switch to client rendering.',
372378
]);
373379
});
374380
});

‎scripts/error-codes/codes.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,5 +403,10 @@
403403
"415": "Error parsing the data. It's probably an error code or network corruption.",
404404
"416": "This environment don't support binary chunks.",
405405
"417": "React currently only supports piping to one writable stream.",
406-
"418": "An error occurred during hydration. The server HTML was replaced with client content"
406+
"418": "Hydration failed because the initial UI does not match what was rendered on the server.",
407+
"419": "The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.",
408+
"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.",
409+
"421": "There was an error while hydrating this Suspense boundary. Switched to client rendering.",
410+
"422": "There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.",
411+
"423": "This root received an early update, before anything was able hydrate. Switched the entire root to client rendering."
407412
}

0 commit comments

Comments
 (0)
Please sign in to comment.