@samitouri / QOS-React / commits / ebd7ff65b6

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.