Don't recreate the same fallback on the client if hydrating suspends (#24236)
* Delay showing fallback if hydrating suspends * Fix up * Include all non-urgent lanes * Moar tests * Add test for transitions
dan committed
Apr 1, 2022 at 02:49 UTC
ebd7ff65b6fea73313c210709c88224910e86339
9 files changed
+315
-52
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+294
-14
@@ -869,16 +869,16 @@ describe('ReactDOMFizzServer', () => {
869
});
870
871
// We still can't render it on the client.
872
- expect(Scheduler).toFlushAndYield([
873
- 'The server could not finish this Suspense boundary, likely due to an ' +
874
- 'error during server rendering. Switched to client rendering.',
875
- ]);
872
+ expect(Scheduler).toFlushAndYield([]);
873
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
874
875
// We now resolve it on the client.
876
resolveText('Hello');
877
881
- Scheduler.unstable_flushAll();
878
+ expect(Scheduler).toFlushAndYield([
879
+ 'The server could not finish this Suspense boundary, likely due to an ' +
880
+ 'error during server rendering. Switched to client rendering.',
881
+ ]);
882
883
// The client rendered HTML is now in place.
884
expect(getVisibleChildren(container)).toEqual(
@@ -2220,6 +2220,286 @@ describe('ReactDOMFizzServer', () => {
2220
},
2221
);
2222
2223
+ // @gate experimental
2224
+ it('does not recreate the fallback if server errors and hydration suspends', async () => {
2225
+ let isClient = false;
2226
+
2227
+ function Child() {
2228
+ if (isClient) {
2229
+ readText('Yay!');
2230
+ } else {
2231
+ throw Error('Oops.');
2232
+ }
2233
+ Scheduler.unstable_yieldValue('Yay!');
2234
+ return 'Yay!';
2235
+ }
2236
+
2237
+ const fallbackRef = React.createRef();
2238
+ function App() {
2239
+ return (
2240
+ <div>
2241
+ <Suspense fallback={<p ref={fallbackRef}>Loading...</p>}>
2242
+ <span>
2243
+ <Child />
2244
+ </span>
2245
+ </Suspense>
2246
+ </div>
2247
+ );
2248
+ }
2249
+ await act(async () => {
2250
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
2251
+ onError(error) {
2252
+ Scheduler.unstable_yieldValue('[s!] ' + error.message);
2253
+ },
2254
+ });
2255
+ pipe(writable);
2256
+ });
2257
+ expect(Scheduler).toHaveYielded(['[s!] Oops.']);
2258
+
2259
+ // The server could not complete this boundary, so we'll retry on the client.
2260
+ const serverFallback = container.getElementsByTagName('p')[0];
2261
+ expect(serverFallback.innerHTML).toBe('Loading...');
2262
+
2263
+ // Hydrate the tree. This will suspend.
2264
+ isClient = true;
2265
+ ReactDOMClient.hydrateRoot(container, <App />, {
2266
+ onRecoverableError(error) {
2267
+ Scheduler.unstable_yieldValue('[c!] ' + error.message);
2268
+ },
2269
+ });
2270
+ // This should not report any errors yet.
2271
+ expect(Scheduler).toFlushAndYield([]);
2272
+ expect(getVisibleChildren(container)).toEqual(
2273
+ <div>
2274
+ <p>Loading...</p>
2275
+ </div>,
2276
+ );
2277
+
2278
+ // Normally, hydrating after server error would force a clean client render.
2279
+ // However, it suspended so at best we'd only get the same fallback anyway.
2280
+ // We don't want to recreate the same fallback in the DOM again because
2281
+ // that's extra work and would restart animations etc. Check we don't do that.
2282
+ const clientFallback = container.getElementsByTagName('p')[0];
2283
+ expect(serverFallback).toBe(clientFallback);
2284
+
2285
+ // When we're able to fully hydrate, we expect a clean client render.
2286
+ await act(async () => {
2287
+ resolveText('Yay!');
2288
+ });
2289
+ expect(Scheduler).toFlushAndYield([
2290
+ 'Yay!',
2291
+ '[c!] The server could not finish this Suspense boundary, ' +
2292
+ 'likely due to an error during server rendering. ' +
2293
+ 'Switched to client rendering.',
2294
+ ]);
2295
+ expect(getVisibleChildren(container)).toEqual(
2296
+ <div>
2297
+ <span>Yay!</span>
2298
+ </div>,
2299
+ );
2300
+ });
2301
+
2302
+ // @gate experimental
2303
+ it(
2304
+ 'does not recreate the fallback if server errors and hydration suspends ' +
2305
+ 'and root receives a transition',
2306
+ async () => {
2307
+ let isClient = false;
2308
+
2309
+ function Child({color}) {
2310
+ if (isClient) {
2311
+ readText('Yay!');
2312
+ } else {
2313
+ throw Error('Oops.');
2314
+ }
2315
+ Scheduler.unstable_yieldValue('Yay! (' + color + ')');
2316
+ return 'Yay! (' + color + ')';
2317
+ }
2318
+
2319
+ const fallbackRef = React.createRef();
2320
+ function App({color}) {
2321
+ return (
2322
+ <div>
2323
+ <Suspense fallback={<p ref={fallbackRef}>Loading...</p>}>
2324
+ <span>
2325
+ <Child color={color} />
2326
+ </span>
2327
+ </Suspense>
2328
+ </div>
2329
+ );
2330
+ }
2331
+ await act(async () => {
2332
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
2333
+ <App color="red" />,
2334
+ {
2335
+ onError(error) {
2336
+ Scheduler.unstable_yieldValue('[s!] ' + error.message);
2337
+ },
2338
+ },
2339
+ );
2340
+ pipe(writable);
2341
+ });
2342
+ expect(Scheduler).toHaveYielded(['[s!] Oops.']);
2343
+
2344
+ // The server could not complete this boundary, so we'll retry on the client.
2345
+ const serverFallback = container.getElementsByTagName('p')[0];
2346
+ expect(serverFallback.innerHTML).toBe('Loading...');
2347
+
2348
+ // Hydrate the tree. This will suspend.
2349
+ isClient = true;
2350
+ const root = ReactDOMClient.hydrateRoot(container, <App color="red" />, {
2351
+ onRecoverableError(error) {
2352
+ Scheduler.unstable_yieldValue('[c!] ' + error.message);
2353
+ },
2354
+ });
2355
+ // This should not report any errors yet.
2356
+ expect(Scheduler).toFlushAndYield([]);
2357
+ expect(getVisibleChildren(container)).toEqual(
2358
+ <div>
2359
+ <p>Loading...</p>
2360
+ </div>,
2361
+ );
2362
+
2363
+ // Normally, hydrating after server error would force a clean client render.
2364
+ // However, it suspended so at best we'd only get the same fallback anyway.
2365
+ // We don't want to recreate the same fallback in the DOM again because
2366
+ // that's extra work and would restart animations etc. Check we don't do that.
2367
+ const clientFallback = container.getElementsByTagName('p')[0];
2368
+ expect(serverFallback).toBe(clientFallback);
2369
+
2370
+ // Transition updates shouldn't recreate the fallback either.
2371
+ React.startTransition(() => {
2372
+ root.render(<App color="blue" />);
2373
+ });
2374
+ Scheduler.unstable_flushAll();
2375
+ jest.runAllTimers();
2376
+ const clientFallback2 = container.getElementsByTagName('p')[0];
2377
+ expect(clientFallback2).toBe(serverFallback);
2378
+
2379
+ // When we're able to fully hydrate, we expect a clean client render.
2380
+ await act(async () => {
2381
+ resolveText('Yay!');
2382
+ });
2383
+ expect(Scheduler).toFlushAndYield([
2384
+ 'Yay! (red)',
2385
+ '[c!] The server could not finish this Suspense boundary, ' +
2386
+ 'likely due to an error during server rendering. ' +
2387
+ 'Switched to client rendering.',
2388
+ 'Yay! (blue)',
2389
+ ]);
2390
+ expect(getVisibleChildren(container)).toEqual(
2391
+ <div>
2392
+ <span>Yay! (blue)</span>
2393
+ </div>,
2394
+ );
2395
+ },
2396
+ );
2397
+
2398
+ // @gate experimental
2399
+ it(
2400
+ 'recreates the fallback if server errors and hydration suspends but ' +
2401
+ 'client receives new props',
2402
+ async () => {
2403
+ let isClient = false;
2404
+
2405
+ function Child() {
2406
+ const value = 'Yay!';
2407
+ if (isClient) {
2408
+ readText(value);
2409
+ } else {
2410
+ throw Error('Oops.');
2411
+ }
2412
+ Scheduler.unstable_yieldValue(value);
2413
+ return value;
2414
+ }
2415
+
2416
+ const fallbackRef = React.createRef();
2417
+ function App({fallbackText}) {
2418
+ return (
2419
+ <div>
2420
+ <Suspense fallback={<p ref={fallbackRef}>{fallbackText}</p>}>
2421
+ <span>
2422
+ <Child />
2423
+ </span>
2424
+ </Suspense>
2425
+ </div>
2426
+ );
2427
+ }
2428
+
2429
+ await act(async () => {
2430
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
2431
+ <App fallbackText="Loading..." />,
2432
+ {
2433
+ onError(error) {
2434
+ Scheduler.unstable_yieldValue('[s!] ' + error.message);
2435
+ },
2436
+ },
2437
+ );
2438
+ pipe(writable);
2439
+ });
2440
+ expect(Scheduler).toHaveYielded(['[s!] Oops.']);
2441
+
2442
+ const serverFallback = container.getElementsByTagName('p')[0];
2443
+ expect(serverFallback.innerHTML).toBe('Loading...');
2444
+
2445
+ // Hydrate the tree. This will suspend.
2446
+ isClient = true;
2447
+ const root = ReactDOMClient.hydrateRoot(
2448
+ container,
2449
+ <App fallbackText="Loading..." />,
2450
+ {
2451
+ onRecoverableError(error) {
2452
+ Scheduler.unstable_yieldValue('[c!] ' + error.message);
2453
+ },
2454
+ },
2455
+ );
2456
+ // This should not report any errors yet.
2457
+ expect(Scheduler).toFlushAndYield([]);
2458
+ expect(getVisibleChildren(container)).toEqual(
2459
+ <div>
2460
+ <p>Loading...</p>
2461
+ </div>,
2462
+ );
2463
+
2464
+ // Normally, hydration after server error would force a clean client render.
2465
+ // However, that suspended so at best we'd only get a fallback anyway.
2466
+ // We don't want to replace a fallback with the same fallback because
2467
+ // that's extra work and would restart animations etc. Verify we don't do that.
2468
+ const clientFallback1 = container.getElementsByTagName('p')[0];
2469
+ expect(serverFallback).toBe(clientFallback1);
2470
+
2471
+ // However, an update may have changed the fallback props. In that case we have to
2472
+ // actually force it to re-render on the client and throw away the server one.
2473
+ root.render(<App fallbackText="More loading..." />);
2474
+ Scheduler.unstable_flushAll();
2475
+ jest.runAllTimers();
2476
+ expect(Scheduler).toHaveYielded([
2477
+ '[c!] The server could not finish this Suspense boundary, ' +
2478
+ 'likely due to an error during server rendering. ' +
2479
+ 'Switched to client rendering.',
2480
+ ]);
2481
+ expect(getVisibleChildren(container)).toEqual(
2482
+ <div>
2483
+ <p>More loading...</p>
2484
+ </div>,
2485
+ );
2486
+ // This should be a clean render without reusing DOM.
2487
+ const clientFallback2 = container.getElementsByTagName('p')[0];
2488
+ expect(clientFallback2).not.toBe(clientFallback1);
2489
+
2490
+ // Verify we can still do a clean content render after.
2491
+ await act(async () => {
2492
+ resolveText('Yay!');
2493
+ });
2494
+ expect(Scheduler).toFlushAndYield(['Yay!']);
2495
+ expect(getVisibleChildren(container)).toEqual(
2496
+ <div>
2497
+ <span>Yay!</span>
2498
+ </div>,
2499
+ );
2500
+ },
2501
+ );
2502
+
2503
// @gate experimental
2504
it(
2505
'errors during hydration force a client render at the nearest Suspense ' +
@@ -2293,17 +2573,12 @@ describe('ReactDOMFizzServer', () => {
2573
},
2574
});
2575
2296
- // An error logged but instead of surfacing it to the UI, we switched
2297
- // to client rendering.
2298
- expect(Scheduler).toFlushAndYield([
2299
- 'Hydration error',
2300
- 'There was an error while hydrating this Suspense boundary. Switched ' +
2301
- 'to client rendering.',
2302
- ]);
2576
+ // An error happened but instead of surfacing it to the UI, we suspended.
2577
+ expect(Scheduler).toFlushAndYield([]);
2578
expect(getVisibleChildren(container)).toEqual(
2579
<div>
2580
<span />
2306
- Loading...
2581
+ <span>Yay!</span>
2582
<span />
2583
</div>,
2584
);
@@ -2311,7 +2586,12 @@ describe('ReactDOMFizzServer', () => {
2586
await act(async () => {
2587
resolveText('Yay!');
2588
});
2314
- expect(Scheduler).toFlushAndYield(['Yay!']);
2589
+ expect(Scheduler).toFlushAndYield([
2590
+ 'Yay!',
2591
+ 'Hydration error',
2592
+ 'There was an error while hydrating this Suspense boundary. Switched ' +
2593
+ 'to client rendering.',
2594
+ ]);
2595
expect(getVisibleChildren(container)).toEqual(
2596
<div>
2597
<span />
packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js
+8
-26
@@ -1046,7 +1046,7 @@ describe('ReactDOMServerHydration', () => {
1046
});
1047
1048
// @gate __DEV__
1049
- it('warns when client renders an extra node inside Suspense fallback', () => {
1049
+ it('does not warn when client renders an extra node inside Suspense fallback', () => {
1050
function Mismatch({isClient}) {
1051
return (
1052
<div className="parent">
@@ -1063,27 +1063,18 @@ describe('ReactDOMServerHydration', () => {
1063
</div>
1064
);
1065
}
1066
- // TODO: Why does this not show a fallback mismatch?
1067
- // And why is this message different from the other ones?
1066
if (
1067
gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
1068
) {
1071
- expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
1072
- Array [
1073
- "Caught [The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.]",
1074
- ]
1075
- `);
1069
+ // There is no error because we don't actually hydrate fallbacks.
1070
+ expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`Array []`);
1071
} else {
1077
- expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
1078
- Array [
1079
- "Caught [The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.]",
1080
- ]
1081
- `);
1072
+ expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`Array []`);
1073
}
1074
});
1075
1076
// @gate __DEV__
1086
- it('warns when server renders an extra node inside Suspense fallback', () => {
1077
+ it('does not warn when server renders an extra node inside Suspense fallback', () => {
1078
function Mismatch({isClient}) {
1079
return (
1080
<div className="parent">
@@ -1100,22 +1091,13 @@ describe('ReactDOMServerHydration', () => {
1091
</div>
1092
);
1093
}
1103
- // TODO: Why does this not show a fallback mismatch?
1104
- // And why is this message different from the other ones?
1094
if (
1095
gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
1096
) {
1108
- expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
1109
- Array [
1110
- "Caught [The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.]",
1111
- ]
1112
- `);
1097
+ // There is no error because we don't actually hydrate fallbacks.
1098
+ expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`Array []`);
1099
} else {
1114
- expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
1115
- Array [
1116
- "Caught [The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.]",
1117
- ]
1118
- `);
1100
+ expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`Array []`);
1101
}
1102
});
1103
});
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+1
-4
@@ -2137,10 +2137,7 @@ describe('ReactDOMServerPartialHydration', () => {
2137
});
2138
2139
suspend = true;
2140
- expect(Scheduler).toFlushAndYield([
2141
- 'The server could not finish this Suspense boundary, likely due to ' +
2142
- 'an error during server rendering. Switched to client rendering.',
2143
- ]);
2140
+ expect(Scheduler).toFlushAndYield([]);
2141
2142
// We haven't hydrated the second child but the placeholder is still in the list.
2143
expect(container.textContent).toBe('ALoading B');
packages/react-reconciler/src/ReactFiberBeginWork.new.js
+1
@@ -2241,6 +2241,7 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
2241
} else {
2242
// Suspended but we should no longer be in dehydrated mode.
2243
// Therefore we now have to render the fallback.
2244
+ renderDidSuspendDelayIfPossible();
2245
const nextPrimaryChildren = nextProps.children;
2246
const nextFallbackChildren = nextProps.fallback;
2247
const fallbackChildFragment = mountSuspenseFallbackAfterRetryWithoutHydrating(
packages/react-reconciler/src/ReactFiberBeginWork.old.js
+1
@@ -2241,6 +2241,7 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
2241
} else {
2242
// Suspended but we should no longer be in dehydrated mode.
2243
// Therefore we now have to render the fallback.
2244
+ renderDidSuspendDelayIfPossible();
2245
const nextPrimaryChildren = nextProps.children;
2246
const nextFallbackChildren = nextProps.fallback;
2247
const fallbackChildFragment = mountSuspenseFallbackAfterRetryWithoutHydrating(
packages/react-reconciler/src/ReactFiberLane.new.js
+3
-2
@@ -458,8 +458,9 @@ export function includesNonIdleWork(lanes: Lanes) {
458
export function includesOnlyRetries(lanes: Lanes) {
459
return (lanes & RetryLanes) === lanes;
460
}
461
-export function includesOnlyTransitions(lanes: Lanes) {
462
- return (lanes & TransitionLanes) === lanes;
461
+export function includesOnlyNonUrgentLanes(lanes: Lanes) {
462
+ const UrgentLanes = SyncLane | InputContinuousLane | DefaultLane;
463
+ return (lanes & UrgentLanes) === NoLanes;
464
}
465
466
export function includesBlockingLane(root: FiberRoot, lanes: Lanes) {
packages/react-reconciler/src/ReactFiberLane.old.js
+3
-2
@@ -458,8 +458,9 @@ export function includesNonIdleWork(lanes: Lanes) {
458
export function includesOnlyRetries(lanes: Lanes) {
459
return (lanes & RetryLanes) === lanes;
460
}
461
-export function includesOnlyTransitions(lanes: Lanes) {
462
- return (lanes & TransitionLanes) === lanes;
461
+export function includesOnlyNonUrgentLanes(lanes: Lanes) {
462
+ const UrgentLanes = SyncLane | InputContinuousLane | DefaultLane;
463
+ return (lanes & UrgentLanes) === NoLanes;
464
}
465
466
export function includesBlockingLane(root: FiberRoot, lanes: Lanes) {
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+2
-2
@@ -132,7 +132,7 @@ import {
132
pickArbitraryLane,
133
includesNonIdleWork,
134
includesOnlyRetries,
135
- includesOnlyTransitions,
135
+ includesOnlyNonUrgentLanes,
136
includesBlockingLane,
137
includesExpiredLane,
138
getNextLanes,
@@ -1110,7 +1110,7 @@ function finishConcurrentRender(root, exitStatus, lanes) {
1110
case RootSuspendedWithDelay: {
1111
markRootSuspended(root, lanes);
1112
1113
- if (includesOnlyTransitions(lanes)) {
1113
+ if (includesOnlyNonUrgentLanes(lanes)) {
1114
// This is a transition, so we should exit without committing a
1115
// placeholder and without scheduling a timeout. Delay indefinitely
1116
// until we receive more data.
packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+2
-2
@@ -132,7 +132,7 @@ import {
132
pickArbitraryLane,
133
includesNonIdleWork,
134
includesOnlyRetries,
135
- includesOnlyTransitions,
135
+ includesOnlyNonUrgentLanes,
136
includesBlockingLane,
137
includesExpiredLane,
138
getNextLanes,
@@ -1110,7 +1110,7 @@ function finishConcurrentRender(root, exitStatus, lanes) {
1110
case RootSuspendedWithDelay: {
1111
markRootSuspended(root, lanes);
1112
1113
- if (includesOnlyTransitions(lanes)) {
1113
+ if (includesOnlyNonUrgentLanes(lanes)) {
1114
// This is a transition, so we should exit without committing a
1115
// placeholder and without scheduling a timeout. Delay indefinitely
1116
// until we receive more data.