[Fizz] Handle nested SuspenseList (#33308)
Follow up to #33306. If we're nested inside a SuspenseList and we have a row, then we can point our last row to block the parent row and unblock the parent when the last child unblocks.
Sebastian Markbåge committed
May 20, 2025 at 09:39 UTC
c4676e72a630f3e93634c2b004b3be07b17a79c8
2 files changed
+85
-4
packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js
+73
@@ -324,4 +324,77 @@ describe('ReactDOMFizSuspenseList', () => {
324
</div>,
325
);
326
});
327
+
328
+ // @gate enableSuspenseList
329
+ it('waits for a nested SuspenseList to complete before resolving "forwards"', async () => {
330
+ const A = createAsyncText('A');
331
+ const B = createAsyncText('B');
332
+ const C = createAsyncText('C');
333
+
334
+ function Foo() {
335
+ return (
336
+ <div>
337
+ <SuspenseList revealOrder="forwards">
338
+ <SuspenseList revealOrder="backwards">
339
+ <Suspense fallback={<Text text="Loading A" />}>
340
+ <A />
341
+ </Suspense>
342
+ <Suspense fallback={<Text text="Loading B" />}>
343
+ <B />
344
+ </Suspense>
345
+ </SuspenseList>
346
+ <Suspense fallback={<Text text="Loading C" />}>
347
+ <C />
348
+ </Suspense>
349
+ </SuspenseList>
350
+ </div>
351
+ );
352
+ }
353
+
354
+ await C.resolve();
355
+
356
+ await serverAct(async () => {
357
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
358
+ pipe(writable);
359
+ });
360
+
361
+ assertLog([
362
+ 'Suspend! [B]',
363
+ 'Suspend! [A]',
364
+ 'C',
365
+ 'Loading B',
366
+ 'Loading A',
367
+ 'Loading C',
368
+ ]);
369
+
370
+ expect(getVisibleChildren(container)).toEqual(
371
+ <div>
372
+ <span>Loading A</span>
373
+ <span>Loading B</span>
374
+ <span>Loading C</span>
375
+ </div>,
376
+ );
377
+
378
+ await serverAct(() => A.resolve());
379
+ assertLog(['A']);
380
+
381
+ expect(getVisibleChildren(container)).toEqual(
382
+ <div>
383
+ <span>Loading A</span>
384
+ <span>Loading B</span>
385
+ <span>Loading C</span>
386
+ </div>,
387
+ );
388
+
389
+ await serverAct(() => B.resolve());
390
+ assertLog(['B']);
391
+
392
+ expect(getVisibleChildren(container)).toEqual(
393
+ <div>
394
+ <span>A</span>
395
+ <span>B</span>
396
+ <span>C</span>
397
+ </div>,
398
+ );
399
+ });
400
});
packages/react-server/src/ReactFizzServer.js
+12
-4
@@ -1732,12 +1732,12 @@ function renderSuspenseListRows(
1732
const prevRow = task.row;
1733
const totalChildren = rows.length;
1734
1735
+ let previousSuspenseListRow: null | SuspenseListRow = null;
1736
if (task.replay !== null) {
1737
// Replay
1738
// First we need to check if we have any resume slots at this level.
1739
const resumeSlots = task.replay.slots;
1740
if (resumeSlots !== null && typeof resumeSlots === 'object') {
1740
- let previousSuspenseListRow: null | SuspenseListRow = null;
1741
for (let n = 0; n < totalChildren; n++) {
1742
// Since we are going to resume into a slot whose order was already
1743
// determined by the prerender, we can safely resume it even in reverse
@@ -1763,7 +1763,6 @@ function renderSuspenseListRows(
1763
}
1764
}
1765
} else {
1766
- let previousSuspenseListRow: null | SuspenseListRow = null;
1766
for (let n = 0; n < totalChildren; n++) {
1767
// Since we are going to resume into a slot whose order was already
1768
// determined by the prerender, we can safely resume it even in reverse
@@ -1787,7 +1786,6 @@ function renderSuspenseListRows(
1786
task = ((task: any): RenderTask); // Refined
1787
if (revealOrder !== 'backwards') {
1788
// Forwards direction
1790
- let previousSuspenseListRow: null | SuspenseListRow = null;
1789
for (let i = 0; i < totalChildren; i++) {
1790
const node = rows[i];
1791
if (__DEV__) {
@@ -1809,7 +1807,6 @@ function renderSuspenseListRows(
1807
const parentSegment = task.blockedSegment;
1808
const childIndex = parentSegment.children.length;
1809
const insertionIndex = parentSegment.chunks.length;
1812
- let previousSuspenseListRow: null | SuspenseListRow = null;
1810
for (let i = totalChildren - 1; i >= 0; i--) {
1811
const node = rows[i];
1812
task.row = previousSuspenseListRow = createSuspenseListRow(
@@ -1859,6 +1856,17 @@ function renderSuspenseListRows(
1856
}
1857
}
1858
1859
+ if (
1860
+ prevRow !== null &&
1861
+ previousSuspenseListRow !== null &&
1862
+ previousSuspenseListRow.pendingTasks > 0
1863
+ ) {
1864
+ // If we are part of an outer SuspenseList and our last row is still pending, then that blocks
1865
+ // the parent row from completing. We can continue the chain.
1866
+ prevRow.pendingTasks++;
1867
+ previousSuspenseListRow.next = prevRow;
1868
+ }
1869
+
1870
// Because this context is always set right before rendering every child, we
1871
// only need to reset it to the previous value at the very end.
1872
task.treeContext = prevTreeContext;