Skip to content

Commit 0a6cd05

Browse files
gaearonzhengjitf
authored andcommitted
Don't recreate the same fallback on the client if hydrating suspends (facebook#24236)
* Delay showing fallback if hydrating suspends * Fix up * Include all non-urgent lanes * Moar tests * Add test for transitions
1 parent 8c75b7d commit 0a6cd05

9 files changed

+315
-52
lines changed

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

+294-14
Original file line numberDiff line numberDiff line change
@@ -869,16 +869,16 @@ describe('ReactDOMFizzServer', () => {
869869
});
870870

871871
// 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([]);
876873
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
877874

878875
// We now resolve it on the client.
879876
resolveText('Hello');
880877

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+
]);
882882

883883
// The client rendered HTML is now in place.
884884
expect(getVisibleChildren(container)).toEqual(
@@ -2220,6 +2220,286 @@ describe('ReactDOMFizzServer', () => {
22202220
},
22212221
);
22222222

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+
22232503
// @gate experimental
22242504
it(
22252505
'errors during hydration force a client render at the nearest Suspense ' +
@@ -2293,25 +2573,25 @@ describe('ReactDOMFizzServer', () => {
22932573
},
22942574
});
22952575

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([]);
23032578
expect(getVisibleChildren(container)).toEqual(
23042579
<div>
23052580
<span />
2306-
Loading...
2581+
<span>Yay!</span>
23072582
<span />
23082583
</div>,
23092584
);
23102585

23112586
await act(async () => {
23122587
resolveText('Yay!');
23132588
});
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+
]);
23152595
expect(getVisibleChildren(container)).toEqual(
23162596
<div>
23172597
<span />

0 commit comments

Comments
 (0)