@samitouri / QOS-React-1 / commits / 986323f8c6

[Fiber] SuspenseList with "hidden" tail row should "catch" suspense (#35042)

Normally if you suspend in a SuspenseList row above a Suspense boundary in that row, it'll suspend the parent. Which can itself delay the commit or resuspend a parent boundary. That's because SuspenseList mostly just coordinates the state of the inner boundaries and isn't a boundary itself. However, for tail "hidden" and "collapsed" this is not quite the case because the rows themselves can avoid being rendered. In the case of "collapsed" we require at least one Suspense boundary above to have successfully rendered before committing the list because the idea of this mode is that you should at least always show some indicator that things are still loading. Since we'd never try the next one after that at all, this just works. Expect there was an unrelated bug that meant that "suspend with delay" on a Retry didn't suspend the commit. This caused a scenario were it'd allow a commit proceed when it shouldn't. So I fixed that too. The counter intuitive thing here is that we won't actually show a previous completed row if the loading state of the next row is still loading. For tail "hidden" it's a little different because we don't actually require any loading indicator at all to be shown while it's loading. If we attempt a row and it suspends, we can just hide it (and the rest) and move to commit. Therefore this implements a path where if all the rest of the tail are new mounts (we wouldn't be required to unmount any existing boundaries) then we can treat the SuspenseList boundary itself as "catching" the suspense. This is more coherent semantics since any future row that we didn't attempt also wouldn't resuspend the parent. This allows simple cases like `<SuspenseList>{list}</SuspenseList>` to stream in each row without any indicator and no need for Suspense boundaries.

Sebastian Markbåge committed Nov 4, 2025 at 22:11 UTC 986323f8c65927490036183357d644974a14b8a3
7 files changed +337 -12
packages/react-reconciler/src/ReactFiberBeginWork.js
+15
@@ -3397,6 +3397,13 @@ function updateSuspenseListComponent(
3397
3398 let suspenseContext: SuspenseContext = suspenseStackCursor.current;
3399
3400 + if (workInProgress.flags & DidCapture) {
3401 + // This is the second pass after having suspended in a row. Proceed directly
3402 + // to the complete phase.
3403 + pushSuspenseListContext(workInProgress, suspenseContext);
3404 + return null;
3405 + }
3406 +
3407 const shouldForceFallback = hasSuspenseListContext(
3408 suspenseContext,
3409 (ForceSuspenseFallback: SuspenseContext),
@@ -4011,6 +4018,14 @@ function attemptEarlyBailoutIfNoScheduledUpdate(
4018 break;
4019 }
4020 case SuspenseListComponent: {
4021 + if (workInProgress.flags & DidCapture) {
4022 + // Second pass caught.
4023 + return updateSuspenseListComponent(
4024 + current,
4025 + workInProgress,
4026 + renderLanes,
4027 + );
4028 + }
4029 const didSuspendBefore = (current.flags & DidCapture) !== NoFlags;
4030
4031 let hasChildWork = includesSomeLane(
packages/react-reconciler/src/ReactFiberCompleteWork.js
+35 -1
@@ -138,6 +138,7 @@ import {
138 popSuspenseListContext,
139 popSuspenseHandler,
140 pushSuspenseListContext,
141 + pushSuspenseListCatch,
142 setShallowSuspenseListContext,
143 ForceSuspenseFallback,
144 setDefaultShallowSuspenseListContext,
@@ -765,6 +766,17 @@ function cutOffTailIfNeeded(
766 }
767 }
768
769 +function isOnlyNewMounts(tail: Fiber): boolean {
770 + let fiber: null | Fiber = tail;
771 + while (fiber !== null) {
772 + if (fiber.alternate !== null) {
773 + return false;
774 + }
775 + fiber = fiber.sibling;
776 + }
777 + return true;
778 +}
779 +
780 function bubbleProperties(completedWork: Fiber) {
781 const didBailout =
782 completedWork.alternate !== null &&
@@ -1855,7 +1867,10 @@ function completeWork(
1867 if (renderState.tail !== null) {
1868 // We still have tail rows to render.
1869 // Pop a row.
1870 + // TODO: Consider storing the first of the new mount tail in the state so
1871 + // that we don't have to recompute this for every row in the list.
1872 const next = renderState.tail;
1873 + const onlyNewMounts = isOnlyNewMounts(next);
1874 renderState.rendering = next;
1875 renderState.tail = next.sibling;
1876 renderState.renderingStartTime = now();
@@ -1874,7 +1889,26 @@ function completeWork(
1889 suspenseContext =
1890 setDefaultShallowSuspenseListContext(suspenseContext);
1891 }
1877 - pushSuspenseListContext(workInProgress, suspenseContext);
1892 + if (
1893 + renderState.tailMode === 'visible' ||
1894 + renderState.tailMode === 'collapsed' ||
1895 + !onlyNewMounts ||
1896 + // TODO: While hydrating, we still let it suspend the parent. Tail mode hidden has broken
1897 + // hydration anyway right now but this preserves the previous semantics out of caution.
1898 + // Once proper hydration is implemented, this special case should be removed as it should
1899 + // never be needed.
1900 + getIsHydrating()
1901 + ) {
1902 + pushSuspenseListContext(workInProgress, suspenseContext);
1903 + } else {
1904 + // If we are rendering in 'hidden' (default) tail mode, then we if we suspend in the
1905 + // tail itself, we can delete it rather than suspend the parent. So we act as a catch in that
1906 + // case. For 'collapsed' we need to render at least one in suspended state, after which we'll
1907 + // have cut off the rest to never attempt it so it never hits this case.
1908 + // If this is an updated node, we cannot delete it from the tail so it's effectively visible.
1909 + // As a consequence, if it resuspends it actually suspends the parent by taking the other path.
1910 + pushSuspenseListCatch(workInProgress, suspenseContext);
1911 + }
1912 // Do a pass over the next row.
1913 if (getIsHydrating()) {
1914 // Re-apply tree fork since we popped the tree fork context in the beginning of this function.
packages/react-reconciler/src/ReactFiberSuspenseContext.js
+30 -6
@@ -48,9 +48,10 @@ export function pushPrimaryTreeSuspenseHandler(handler: Fiber): void {
48 // Shallow Suspense context fields, like ForceSuspenseFallback, should only be
49 // propagated a single level. For example, when ForceSuspenseFallback is set,
50 // it should only force the nearest Suspense boundary into fallback mode.
51 - pushSuspenseListContext(
52 - handler,
51 + push(
52 + suspenseStackCursor,
53 setDefaultShallowSuspenseListContext(suspenseStackCursor.current),
54 + handler,
55 );
56
57 // Experimental feature: Some Suspense boundaries are marked as having an
@@ -113,7 +114,7 @@ export function pushDehydratedActivitySuspenseHandler(fiber: Fiber): void {
114 // Reuse the current value on the stack.
115 // TODO: We can avoid needing to push here by by forking popSuspenseHandler
116 // into separate functions for Activity, Suspense and Offscreen.
116 - pushSuspenseListContext(fiber, suspenseStackCursor.current);
117 + push(suspenseStackCursor, suspenseStackCursor.current, fiber);
118 push(suspenseHandlerStackCursor, fiber, fiber);
119 if (shellBoundary === null) {
120 // We can contain any suspense inside the Activity boundary.
@@ -127,7 +128,7 @@ export function pushOffscreenSuspenseHandler(fiber: Fiber): void {
128 // Reuse the current value on the stack.
129 // TODO: We can avoid needing to push here by by forking popSuspenseHandler
130 // into separate functions for Activity, Suspense and Offscreen.
130 - pushSuspenseListContext(fiber, suspenseStackCursor.current);
131 + push(suspenseStackCursor, suspenseStackCursor.current, fiber);
132 push(suspenseHandlerStackCursor, fiber, fiber);
133 if (shellBoundary === null) {
134 // We're rendering hidden content. If it suspends, we can handle it by
@@ -141,7 +142,7 @@ export function pushOffscreenSuspenseHandler(fiber: Fiber): void {
142 }
143
144 export function reuseSuspenseHandlerOnStack(fiber: Fiber) {
144 - pushSuspenseListContext(fiber, suspenseStackCursor.current);
145 + push(suspenseStackCursor, suspenseStackCursor.current, fiber);
146 push(suspenseHandlerStackCursor, getSuspenseHandler(), fiber);
147 }
148
@@ -155,7 +156,7 @@ export function popSuspenseHandler(fiber: Fiber): void {
156 // Popping back into the shell.
157 shellBoundary = null;
158 }
158 - popSuspenseListContext(fiber);
159 + pop(suspenseStackCursor, fiber);
160 }
161
162 // SuspenseList context
@@ -201,9 +202,32 @@ export function pushSuspenseListContext(
202 fiber: Fiber,
203 newContext: SuspenseContext,
204 ): void {
205 + // Push the current handler in this case since we're not catching at the SuspenseList
206 + // for typical rows.
207 + const handlerOnStack = suspenseHandlerStackCursor.current;
208 + push(suspenseHandlerStackCursor, handlerOnStack, fiber);
209 + push(suspenseStackCursor, newContext, fiber);
210 +}
211 +
212 +export function pushSuspenseListCatch(
213 + fiber: Fiber,
214 + newContext: SuspenseContext,
215 +): void {
216 + // In this case we do want to handle catching suspending on the actual boundary itself.
217 + // This is used for rows that are allowed to be hidden anyway.
218 + push(suspenseHandlerStackCursor, fiber, fiber);
219 push(suspenseStackCursor, newContext, fiber);
220 + if (shellBoundary === null) {
221 + // We can contain the effects to hiding the current row.
222 + shellBoundary = fiber;
223 + }
224 }
225
226 export function popSuspenseListContext(fiber: Fiber): void {
227 pop(suspenseStackCursor, fiber);
228 + pop(suspenseHandlerStackCursor, fiber);
229 + if (shellBoundary === fiber) {
230 + // Popping back into the shell.
231 + shellBoundary = null;
232 + }
233 }
packages/react-reconciler/src/ReactFiberThrow.js
+10 -1
@@ -27,6 +27,7 @@ import {
27 ActivityComponent,
28 SuspenseComponent,
29 OffscreenComponent,
30 + SuspenseListComponent,
31 } from './ReactWorkTags';
32 import {
33 DidCapture,
@@ -400,7 +401,8 @@ function throwException(
401 if (suspenseBoundary !== null) {
402 switch (suspenseBoundary.tag) {
403 case ActivityComponent:
403 - case SuspenseComponent: {
404 + case SuspenseComponent:
405 + case SuspenseListComponent: {
406 // If this suspense/activity boundary is not already showing a fallback, mark
407 // the in-progress render as suspended. We try to perform this logic
408 // as soon as soon as possible during the render phase, so the work
@@ -561,6 +563,13 @@ function throwException(
563 // Instead of surfacing the error, find the nearest Suspense boundary
564 // and render it again without hydration.
565 if (hydrationBoundary !== null) {
566 + if (__DEV__) {
567 + if (hydrationBoundary.tag === SuspenseListComponent) {
568 + console.error(
569 + 'SuspenseList should never catch while hydrating. This is a bug in React.',
570 + );
571 + }
572 + }
573 if ((hydrationBoundary.flags & ShouldCapture) === NoFlags) {
574 // Set a flag to indicate that we should try rendering the normal
575 // children again, not the fallback.
packages/react-reconciler/src/ReactFiberUnwindWork.js
+25 -3
@@ -11,7 +11,10 @@ import type {ReactContext} from 'shared/ReactTypes';
11 import type {Fiber, FiberRoot} from './ReactInternalTypes';
12 import type {Lanes} from './ReactFiberLane';
13 import type {ActivityState} from './ReactFiberActivityComponent';
14 -import type {SuspenseState} from './ReactFiberSuspenseComponent';
14 +import type {
15 + SuspenseState,
16 + SuspenseListRenderState,
17 +} from './ReactFiberSuspenseComponent';
18 import type {Cache} from './ReactFiberCacheComponent';
19 import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent';
20
@@ -31,7 +34,7 @@ import {
34 CacheComponent,
35 TracingMarkerComponent,
36 } from './ReactWorkTags';
34 -import {DidCapture, NoFlags, ShouldCapture} from './ReactFiberFlags';
37 +import {DidCapture, NoFlags, ShouldCapture, Update} from './ReactFiberFlags';
38 import {NoMode, ProfileMode} from './ReactTypeOfMode';
39 import {
40 enableProfilerTimer,
@@ -180,8 +183,27 @@ function unwindWork(
183 }
184 case SuspenseListComponent: {
185 popSuspenseListContext(workInProgress);
183 - // SuspenseList doesn't actually catch anything. It should've been
186 + // SuspenseList doesn't normally catch anything. It should've been
187 // caught by a nested boundary. If not, it should bubble through.
188 + const flags = workInProgress.flags;
189 + if (flags & ShouldCapture) {
190 + workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
191 + // If we caught something on the SuspenseList itself it's because
192 + // we want to ignore something. Re-enter the cycle and handle it
193 + // in the complete phase.
194 + const renderState: null | SuspenseListRenderState =
195 + workInProgress.memoizedState;
196 + if (renderState !== null) {
197 + // Cut off any remaining tail work and don't commit the rendering one.
198 + // This assumes that we have already confirmed that none of these are
199 + // already mounted.
200 + renderState.rendering = null;
201 + renderState.tail = null;
202 + }
203 + // Schedule the commit phase to attach retry listeners.
204 + workInProgress.flags |= Update;
205 + return workInProgress;
206 + }
207 return null;
208 }
209 case HostPortal:
packages/react-reconciler/src/ReactFiberWorkLoop.js
+1 -1
@@ -1355,7 +1355,7 @@ function finishConcurrentRender(
1355 throw new Error('Root did not complete. This is a bug in React.');
1356 }
1357 case RootSuspendedWithDelay: {
1358 - if (!includesOnlyTransitions(lanes)) {
1358 + if (!includesOnlyTransitions(lanes) && !includesOnlyRetries(lanes)) {
1359 // Commit the placeholder.
1360 break;
1361 }
packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js
+221
@@ -2346,6 +2346,227 @@ describe('ReactSuspenseList', () => {
2346 );
2347 });
2348
2349 + // @gate enableSuspenseList
2350 + it('reveals "hidden" rows one by one without suspense boundaries', async () => {
2351 + const A = createAsyncText('A');
2352 + const B = createAsyncText('B');
2353 + const C = createAsyncText('C');
2354 +
2355 + function Foo() {
2356 + return (
2357 + <SuspenseList revealOrder="forwards" tail="hidden">
2358 + <div>
2359 + <A />
2360 + </div>
2361 + <B />
2362 + <C />
2363 + </SuspenseList>
2364 + );
2365 + }
2366 +
2367 + ReactNoop.render(
2368 + <Suspense fallback="Loading root">
2369 + <Foo />
2370 + </Suspense>,
2371 + );
2372 +
2373 + await waitForAll(['Suspend! [A]']);
2374 +
2375 + // We can commit without any rows at all leaving empty.
2376 + expect(ReactNoop).toMatchRenderedOutput(null);
2377 +
2378 + await act(() => A.resolve());
2379 + assertLog(['A', 'Suspend! [B]']);
2380 +
2381 + expect(ReactNoop).toMatchRenderedOutput(
2382 + <div>
2383 + <span>A</span>
2384 + </div>,
2385 + );
2386 +
2387 + await act(() => B.resolve());
2388 + assertLog(['B', 'Suspend! [C]']);
2389 +
2390 + // Incremental loading is suspended.
2391 + jest.advanceTimersByTime(500);
2392 +
2393 + expect(ReactNoop).toMatchRenderedOutput(
2394 + <>
2395 + <div>
2396 + <span>A</span>
2397 + </div>
2398 + <span>B</span>
2399 + </>,
2400 + );
2401 +
2402 + await act(() => C.resolve());
2403 + assertLog(['C']);
2404 +
2405 + expect(ReactNoop).toMatchRenderedOutput(
2406 + <>
2407 + <div>
2408 + <span>A</span>
2409 + </div>
2410 + <span>B</span>
2411 + <span>C</span>
2412 + </>,
2413 + );
2414 + });
2415 +
2416 + // @gate enableSuspenseList
2417 + it('preserves already mounted rows when a new hidden on is inserted in the tail', async () => {
2418 + const B = createAsyncText('B');
2419 + const C = createAsyncText('C');
2420 +
2421 + let count = 0;
2422 + function MountCount({children}) {
2423 + // This component should only mount once.
2424 + React.useLayoutEffect(() => {
2425 + count++;
2426 + }, []);
2427 + return children;
2428 + }
2429 +
2430 + function Foo({insert}) {
2431 + return (
2432 + <SuspenseList
2433 + revealOrder="forwards"
2434 + tail={insert ? 'hidden' : 'visible'}>
2435 + <Text text="A" />
2436 + {insert ? <B /> : null}
2437 + <MountCount>
2438 + <Suspense fallback={<Text text="Loading C" />}>
2439 + <C />
2440 + </Suspense>
2441 + </MountCount>
2442 + </SuspenseList>
2443 + );
2444 + }
2445 +
2446 + await act(() => {
2447 + ReactNoop.render(<Foo insert={false} />);
2448 + });
2449 + assertLog(['A', 'Suspend! [C]', 'Loading C', 'Suspend! [C]']);
2450 +
2451 + expect(count).toBe(1);
2452 +
2453 + expect(ReactNoop).toMatchRenderedOutput(
2454 + <>
2455 + <span>A</span>
2456 + <span>Loading C</span>
2457 + </>,
2458 + );
2459 +
2460 + await act(() => {
2461 + ReactNoop.render(<Foo insert={true} />);
2462 + });
2463 +
2464 + assertLog(['A', 'Suspend! [B]', 'A', 'Suspend! [B]']);
2465 +
2466 + expect(count).toBe(1);
2467 +
2468 + expect(ReactNoop).toMatchRenderedOutput(
2469 + <>
2470 + <span>A</span>
2471 + <span>Loading C</span>
2472 + </>,
2473 + );
2474 +
2475 + await act(async () => {
2476 + await B.resolve();
2477 + await C.resolve();
2478 + });
2479 +
2480 + assertLog(['A', 'B', 'C']);
2481 +
2482 + expect(count).toBe(1);
2483 +
2484 + expect(ReactNoop).toMatchRenderedOutput(
2485 + <>
2486 + <span>A</span>
2487 + <span>B</span>
2488 + <span>C</span>
2489 + </>,
2490 + );
2491 + });
2492 +
2493 + // @gate enableSuspenseList
2494 + it('reveals "collapsed" rows one by one after the first without boundaries', async () => {
2495 + const A = createAsyncText('A');
2496 + const B = createAsyncText('B');
2497 + const C = createAsyncText('C');
2498 +
2499 + function Foo() {
2500 + return (
2501 + <SuspenseList revealOrder="forwards" tail="collapsed">
2502 + <A />
2503 + <Suspense fallback={<Text text="Loading B" />}>
2504 + <B />
2505 + </Suspense>
2506 + <C />
2507 + </SuspenseList>
2508 + );
2509 + }
2510 +
2511 + await act(async () => {
2512 + ReactNoop.render(
2513 + <Suspense fallback="Loading root">
2514 + <Foo />
2515 + </Suspense>,
2516 + );
2517 + await waitForAll(['Suspend! [A]', 'Suspend! [A]']);
2518 + });
2519 +
2520 + // The root is still blocked on the first row.
2521 + expect(ReactNoop).toMatchRenderedOutput('Loading root');
2522 +
2523 + await A.resolve();
2524 +
2525 + await waitForAll(['A', 'Suspend! [B]', 'Loading B']);
2526 +
2527 + // Incremental loading is suspended.
2528 + jest.advanceTimersByTime(500);
2529 +
2530 + // Because we have a Suspense boundary that can commit we can now unblock the rest.
2531 + // If it wasn't a boundary then we couldn't make progress because it would commit
2532 + // without any loading state.
2533 + expect(ReactNoop).toMatchRenderedOutput(
2534 + <>
2535 + <span>A</span>
2536 + <span>Loading B</span>
2537 + </>,
2538 + );
2539 +
2540 + await act(() => B.resolve());
2541 + assertLog(['B', 'Suspend! [C]', 'B', 'Suspend! [C]']);
2542 +
2543 + // Incremental loading is suspended.
2544 + jest.advanceTimersByTime(500);
2545 +
2546 + // Surprisingly unsuspending B actually causes the parent to resuspend
2547 + // because C is now unblocked which resuspends the parent. Preventing the
2548 + // Retry from committing. That's because we don't want to commit into a
2549 + // state that doesn't have any loading indicators at all. That's what
2550 + // "collapsed" is for. To ensure there's always a loading indicator.
2551 + expect(ReactNoop).toMatchRenderedOutput(
2552 + <>
2553 + <span>A</span>
2554 + <span>Loading B</span>
2555 + </>,
2556 + );
2557 +
2558 + await act(() => C.resolve());
2559 + assertLog(['B', 'C']);
2560 +
2561 + expect(ReactNoop).toMatchRenderedOutput(
2562 + <>
2563 + <span>A</span>
2564 + <span>B</span>
2565 + <span>C</span>
2566 + </>,
2567 + );
2568 + });
2569 +
2570 // @gate enableSuspenseList
2571 it('eventually resolves a nested forwards suspense list', async () => {
2572 const B = createAsyncText('B');