@samitouri / QOS-React-1 / commits / 5dc1b212c3

[Fizz] Support basic SuspenseList forwards/backwards revealOrder (#33306)

Basically we track a `SuspenseListRow` on the task. These keep track of "pending tasks" that block the row. A row is blocked by: - First itself completing rendering. - A previous row completing. - Any tasks inside the row and before the Suspense boundary inside the row. This is mainly because we don't yet know if we'll discover more SuspenseBoundaries. - Previous row's SuspenseBoundaries completing. If a boundary might get outlined, then we can't consider it completed until we have written it because it determined whether other future boundaries in the row can finish. This is just handling basic semantics. Features not supported yet that need follow ups later: - CSS dependencies of previous rows should be added as dependencies of future row's suspense boundary. Because otherwise if the client is blocked on CSS then a previous row could be blocked but the server doesn't know it. - I need a second pass on nested SuspenseList semantics. - `revealOrder="together"` - `tail="hidden"`/`tail="collapsed"`. This needs some new runtime semantics to the Fizz runtime and to allow the hydration to handle missing rows in the HTML. This should also be future compatible with AsyncIterable where we don't know how many rows upfront. - Need to double check resuming semantics. --------- Co-authored-by: Sebastian "Sebbie" Silbermann <silbermann.sebastian@gmail.com>

Sebastian Markbåge committed May 19, 2025 at 15:16 UTC 5dc1b212c330b6f456789f8d58e0f87abefbb86c
4 files changed +792 -31
fixtures/ssr/src/components/LargeContent.js
+7 -3
@@ -1,8 +1,12 @@
1 -import React, {Fragment, Suspense} from 'react';
1 +import React, {
2 + Fragment,
3 + Suspense,
4 + unstable_SuspenseList as SuspenseList,
5 +} from 'react';
6
7 export default function LargeContent() {
8 return (
5 - <Fragment>
9 + <SuspenseList revealOrder="forwards">
10 <Suspense fallback={null}>
11 <p>
12 Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris
@@ -286,6 +290,6 @@ export default function LargeContent() {
290 interdum a. Proin nec odio in nulla vestibulum.
291 </p>
292 </Suspense>
289 - </Fragment>
293 + </SuspenseList>
294 );
295 }
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+5 -9
@@ -1318,10 +1318,8 @@ describe('ReactDOMFizzServer', () => {
1318 expect(ref.current).toBe(null);
1319 expect(getVisibleChildren(container)).toEqual(
1320 <div>
1321 - Loading A
1322 - {/* // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
1323 - // isn't implemented fully yet. */}
1324 - <span>B</span>
1321 + {'Loading A'}
1322 + {'Loading B'}
1323 </div>,
1324 );
1325
@@ -1335,11 +1333,9 @@ describe('ReactDOMFizzServer', () => {
1333 // We haven't resolved yet.
1334 expect(getVisibleChildren(container)).toEqual(
1335 <div>
1338 - Loading A
1339 - {/* // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
1340 - // isn't implemented fully yet. */}
1341 - <span>B</span>
1342 - Loading C
1336 + {'Loading A'}
1337 + {'Loading B'}
1338 + {'Loading C'}
1339 </div>,
1340 );
1341
packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js new
+327
@@ -0,0 +1,327 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @emails react-core
8 + * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
9 + */
10 +
11 +'use strict';
12 +import {
13 + insertNodesAndExecuteScripts,
14 + getVisibleChildren,
15 +} from '../test-utils/FizzTestUtils';
16 +
17 +let JSDOM;
18 +let React;
19 +let Suspense;
20 +let SuspenseList;
21 +let assertLog;
22 +let Scheduler;
23 +let ReactDOMFizzServer;
24 +let Stream;
25 +let document;
26 +let writable;
27 +let container;
28 +let buffer = '';
29 +let hasErrored = false;
30 +let fatalError = undefined;
31 +
32 +describe('ReactDOMFizSuspenseList', () => {
33 + beforeEach(() => {
34 + jest.resetModules();
35 + JSDOM = require('jsdom').JSDOM;
36 + React = require('react');
37 + assertLog = require('internal-test-utils').assertLog;
38 + ReactDOMFizzServer = require('react-dom/server');
39 + Stream = require('stream');
40 +
41 + Suspense = React.Suspense;
42 + SuspenseList = React.unstable_SuspenseList;
43 +
44 + Scheduler = require('scheduler');
45 +
46 + // Test Environment
47 + const jsdom = new JSDOM(
48 + '<!DOCTYPE html><html><head></head><body><div id="container">',
49 + {
50 + runScripts: 'dangerously',
51 + },
52 + );
53 + document = jsdom.window.document;
54 + container = document.getElementById('container');
55 + global.window = jsdom.window;
56 + // The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it.
57 + global.requestAnimationFrame = global.window.requestAnimationFrame = cb =>
58 + setTimeout(cb);
59 +
60 + buffer = '';
61 + hasErrored = false;
62 +
63 + writable = new Stream.PassThrough();
64 + writable.setEncoding('utf8');
65 + writable.on('data', chunk => {
66 + buffer += chunk;
67 + });
68 + writable.on('error', error => {
69 + hasErrored = true;
70 + fatalError = error;
71 + });
72 + });
73 +
74 + afterEach(() => {
75 + jest.restoreAllMocks();
76 + });
77 +
78 + async function serverAct(callback) {
79 + await callback();
80 + // Await one turn around the event loop.
81 + // This assumes that we'll flush everything we have so far.
82 + await new Promise(resolve => {
83 + setImmediate(resolve);
84 + });
85 + if (hasErrored) {
86 + throw fatalError;
87 + }
88 + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
89 + // We also want to execute any scripts that are embedded.
90 + // We assume that we have now received a proper fragment of HTML.
91 + const bufferedContent = buffer;
92 + buffer = '';
93 + const temp = document.createElement('body');
94 + temp.innerHTML = bufferedContent;
95 + await insertNodesAndExecuteScripts(temp, container, null);
96 + jest.runAllTimers();
97 + }
98 +
99 + function Text(props) {
100 + Scheduler.log(props.text);
101 + return <span>{props.text}</span>;
102 + }
103 +
104 + function createAsyncText(text) {
105 + let resolved = false;
106 + const Component = function () {
107 + if (!resolved) {
108 + Scheduler.log('Suspend! [' + text + ']');
109 + throw promise;
110 + }
111 + return <Text text={text} />;
112 + };
113 + const promise = new Promise(resolve => {
114 + Component.resolve = function () {
115 + resolved = true;
116 + return resolve();
117 + };
118 + });
119 + return Component;
120 + }
121 +
122 + // @gate enableSuspenseList
123 + it('shows content independently by default', async () => {
124 + const A = createAsyncText('A');
125 + const B = createAsyncText('B');
126 + const C = createAsyncText('C');
127 +
128 + function Foo() {
129 + return (
130 + <div>
131 + <SuspenseList>
132 + <Suspense fallback={<Text text="Loading A" />}>
133 + <A />
134 + </Suspense>
135 + <Suspense fallback={<Text text="Loading B" />}>
136 + <B />
137 + </Suspense>
138 + <Suspense fallback={<Text text="Loading C" />}>
139 + <C />
140 + </Suspense>
141 + </SuspenseList>
142 + </div>
143 + );
144 + }
145 +
146 + await A.resolve();
147 +
148 + await serverAct(async () => {
149 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
150 + pipe(writable);
151 + });
152 +
153 + assertLog(['A', 'Suspend! [B]', 'Suspend! [C]', 'Loading B', 'Loading C']);
154 +
155 + expect(getVisibleChildren(container)).toEqual(
156 + <div>
157 + <span>A</span>
158 + <span>Loading B</span>
159 + <span>Loading C</span>
160 + </div>,
161 + );
162 +
163 + await serverAct(() => C.resolve());
164 + assertLog(['C']);
165 +
166 + expect(getVisibleChildren(container)).toEqual(
167 + <div>
168 + <span>A</span>
169 + <span>Loading B</span>
170 + <span>C</span>
171 + </div>,
172 + );
173 +
174 + await serverAct(() => B.resolve());
175 + assertLog(['B']);
176 +
177 + expect(getVisibleChildren(container)).toEqual(
178 + <div>
179 + <span>A</span>
180 + <span>B</span>
181 + <span>C</span>
182 + </div>,
183 + );
184 + });
185 +
186 + // @gate enableSuspenseList
187 + it('displays each items in "forwards" order', async () => {
188 + const A = createAsyncText('A');
189 + const B = createAsyncText('B');
190 + const C = createAsyncText('C');
191 +
192 + function Foo() {
193 + return (
194 + <div>
195 + <SuspenseList revealOrder="forwards">
196 + <Suspense fallback={<Text text="Loading A" />}>
197 + <A />
198 + </Suspense>
199 + <Suspense fallback={<Text text="Loading B" />}>
200 + <B />
201 + </Suspense>
202 + <Suspense fallback={<Text text="Loading C" />}>
203 + <C />
204 + </Suspense>
205 + </SuspenseList>
206 + </div>
207 + );
208 + }
209 +
210 + await C.resolve();
211 +
212 + await serverAct(async () => {
213 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
214 + pipe(writable);
215 + });
216 +
217 + assertLog([
218 + 'Suspend! [A]',
219 + 'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended,
220 + 'C',
221 + 'Loading A',
222 + 'Loading B',
223 + 'Loading C',
224 + ]);
225 +
226 + expect(getVisibleChildren(container)).toEqual(
227 + <div>
228 + <span>Loading A</span>
229 + <span>Loading B</span>
230 + <span>Loading C</span>
231 + </div>,
232 + );
233 +
234 + await serverAct(() => A.resolve());
235 + assertLog(['A']);
236 +
237 + expect(getVisibleChildren(container)).toEqual(
238 + <div>
239 + <span>A</span>
240 + <span>Loading B</span>
241 + <span>Loading C</span>
242 + </div>,
243 + );
244 +
245 + await serverAct(() => B.resolve());
246 + assertLog(['B']);
247 +
248 + expect(getVisibleChildren(container)).toEqual(
249 + <div>
250 + <span>A</span>
251 + <span>B</span>
252 + <span>C</span>
253 + </div>,
254 + );
255 + });
256 +
257 + // @gate enableSuspenseList
258 + it('displays each items in "backwards" order', async () => {
259 + const A = createAsyncText('A');
260 + const B = createAsyncText('B');
261 + const C = createAsyncText('C');
262 +
263 + function Foo() {
264 + return (
265 + <div>
266 + <SuspenseList revealOrder="backwards">
267 + <Suspense fallback={<Text text="Loading A" />}>
268 + <A />
269 + </Suspense>
270 + <Suspense fallback={<Text text="Loading B" />}>
271 + <B />
272 + </Suspense>
273 + <Suspense fallback={<Text text="Loading C" />}>
274 + <C />
275 + </Suspense>
276 + </SuspenseList>
277 + </div>
278 + );
279 + }
280 +
281 + await A.resolve();
282 +
283 + await serverAct(async () => {
284 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
285 + pipe(writable);
286 + });
287 +
288 + assertLog([
289 + 'Suspend! [C]',
290 + 'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended,
291 + 'A',
292 + 'Loading C',
293 + 'Loading B',
294 + 'Loading A',
295 + ]);
296 +
297 + expect(getVisibleChildren(container)).toEqual(
298 + <div>
299 + <span>Loading A</span>
300 + <span>Loading B</span>
301 + <span>Loading C</span>
302 + </div>,
303 + );
304 +
305 + await serverAct(() => C.resolve());
306 + assertLog(['C']);
307 +
308 + expect(getVisibleChildren(container)).toEqual(
309 + <div>
310 + <span>Loading A</span>
311 + <span>Loading B</span>
312 + <span>C</span>
313 + </div>,
314 + );
315 +
316 + await serverAct(() => B.resolve());
317 + assertLog(['B']);
318 +
319 + expect(getVisibleChildren(container)).toEqual(
320 + <div>
321 + <span>A</span>
322 + <span>B</span>
323 + <span>C</span>
324 + </div>,
325 + );
326 + });
327 +});
packages/react-server/src/ReactFizzServer.js
+453 -19
@@ -24,6 +24,8 @@ import type {
24 ViewTransitionProps,
25 ActivityProps,
26 SuspenseProps,
27 + SuspenseListProps,
28 + SuspenseListRevealOrder,
29 } from 'shared/ReactTypes';
30 import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
31 import type {
@@ -231,6 +233,12 @@ type LegacyContext = {
233 [key: string]: any,
234 };
235
236 +type SuspenseListRow = {
237 + pendingTasks: number, // The number of tasks, previous rows and inner suspense boundaries blocking this row.
238 + boundaries: null | Array<SuspenseBoundary>, // The boundaries in this row waiting to be unblocked by the previous row. (null means this row is not blocked)
239 + next: null | SuspenseListRow, // The next row blocked by this one.
240 +};
241 +
242 const CLIENT_RENDERED = 4; // if it errors or infinitely suspends
243
244 type SuspenseBoundary = {
@@ -238,6 +246,7 @@ type SuspenseBoundary = {
246 rootSegmentID: number,
247 parentFlushed: boolean,
248 pendingTasks: number, // when it reaches zero we can show this boundary's content
249 + row: null | SuspenseListRow, // the row that this boundary blocks from completing.
250 completedSegments: Array<Segment>, // completed but not yet flushed segments.
251 byteSize: number, // used to determine whether to inline children boundaries.
252 fallbackAbortableTasks: Set<Task>, // used to cancel task on the fallback if the boundary completes or gets canceled.
@@ -268,11 +277,12 @@ type RenderTask = {
277 formatContext: FormatContext, // the format's specific context (e.g. HTML/SVG/MathML)
278 context: ContextSnapshot, // the current new context that this task is executing in
279 treeContext: TreeContext, // the current tree context that this task is executing in
280 + row: null | SuspenseListRow, // the current SuspenseList row that this is rendering inside
281 componentStack: null | ComponentStackNode, // stack frame description of the currently rendering component
282 thenableState: null | ThenableState,
283 legacyContext: LegacyContext, // the current legacy context that this task is executing in
284 debugTask: null | ConsoleTask, // DEV only
275 - // DON'T ANY MORE FIELDS. We at 16 already which otherwise requires converting to a constructor.
285 + // DON'T ANY MORE FIELDS. We at 16 in prod already which otherwise requires converting to a constructor.
286 // Consider splitting into multiple objects or consolidating some fields.
287 };
288
@@ -298,12 +308,11 @@ type ReplayTask = {
308 formatContext: FormatContext, // the format's specific context (e.g. HTML/SVG/MathML)
309 context: ContextSnapshot, // the current new context that this task is executing in
310 treeContext: TreeContext, // the current tree context that this task is executing in
311 + row: null | SuspenseListRow, // the current SuspenseList row that this is rendering inside
312 componentStack: null | ComponentStackNode, // stack frame description of the currently rendering component
313 thenableState: null | ThenableState,
314 legacyContext: LegacyContext, // the current legacy context that this task is executing in
315 debugTask: null | ConsoleTask, // DEV only
305 - // DON'T ANY MORE FIELDS. We at 16 already which otherwise requires converting to a constructor.
306 - // Consider splitting into multiple objects or consolidating some fields.
316 };
317
318 export type Task = RenderTask | ReplayTask;
@@ -542,6 +551,7 @@ export function createRequest(
551 rootContextSnapshot,
552 emptyTreeContext,
553 null,
554 + null,
555 emptyContextObject,
556 null,
557 );
@@ -647,6 +657,7 @@ export function resumeRequest(
657 rootContextSnapshot,
658 emptyTreeContext,
659 null,
660 + null,
661 emptyContextObject,
662 null,
663 );
@@ -674,6 +685,7 @@ export function resumeRequest(
685 rootContextSnapshot,
686 emptyTreeContext,
687 null,
688 + null,
689 emptyContextObject,
690 null,
691 );
@@ -739,6 +751,7 @@ function pingTask(request: Request, task: Task): void {
751
752 function createSuspenseBoundary(
753 request: Request,
754 + row: null | SuspenseListRow,
755 fallbackAbortableTasks: Set<Task>,
756 contentPreamble: null | Preamble,
757 fallbackPreamble: null | Preamble,
@@ -748,6 +761,7 @@ function createSuspenseBoundary(
761 rootSegmentID: -1,
762 parentFlushed: false,
763 pendingTasks: 0,
764 + row: row,
765 completedSegments: [],
766 byteSize: 0,
767 fallbackAbortableTasks,
@@ -765,6 +779,17 @@ function createSuspenseBoundary(
779 boundary.errorStack = null;
780 boundary.errorComponentStack = null;
781 }
782 + if (row !== null) {
783 + // This boundary will block this row from completing.
784 + row.pendingTasks++;
785 + const blockedBoundaries = row.boundaries;
786 + if (blockedBoundaries !== null) {
787 + // Previous rows will block this boundary itself from completing.
788 + request.allPendingTasks++;
789 + boundary.pendingTasks++;
790 + blockedBoundaries.push(boundary);
791 + }
792 + }
793 return boundary;
794 }
795
@@ -782,6 +807,7 @@ function createRenderTask(
807 formatContext: FormatContext,
808 context: ContextSnapshot,
809 treeContext: TreeContext,
810 + row: null | SuspenseListRow,
811 componentStack: null | ComponentStackNode,
812 legacyContext: LegacyContext,
813 debugTask: null | ConsoleTask,
@@ -792,6 +818,9 @@ function createRenderTask(
818 } else {
819 blockedBoundary.pendingTasks++;
820 }
821 + if (row !== null) {
822 + row.pendingTasks++;
823 + }
824 const task: RenderTask = ({
825 replay: null,
826 node,
@@ -806,6 +835,7 @@ function createRenderTask(
835 formatContext,
836 context,
837 treeContext,
838 + row,
839 componentStack,
840 thenableState,
841 }: any);
@@ -832,6 +862,7 @@ function createReplayTask(
862 formatContext: FormatContext,
863 context: ContextSnapshot,
864 treeContext: TreeContext,
865 + row: null | SuspenseListRow,
866 componentStack: null | ComponentStackNode,
867 legacyContext: LegacyContext,
868 debugTask: null | ConsoleTask,
@@ -842,6 +873,9 @@ function createReplayTask(
873 } else {
874 blockedBoundary.pendingTasks++;
875 }
876 + if (row !== null) {
877 + row.pendingTasks++;
878 + }
879 replay.pendingTasks++;
880 const task: ReplayTask = ({
881 replay,
@@ -857,6 +891,7 @@ function createReplayTask(
891 formatContext,
892 context,
893 treeContext,
894 + row,
895 componentStack,
896 thenableState,
897 }: any);
@@ -1145,17 +1180,20 @@ function renderSuspenseBoundary(
1180 // so we can just render through it.
1181 const prevKeyPath = someTask.keyPath;
1182 const prevContext = someTask.formatContext;
1183 + const prevRow = someTask.row;
1184 someTask.keyPath = keyPath;
1185 someTask.formatContext = getSuspenseContentFormatContext(
1186 request.resumableState,
1187 prevContext,
1188 );
1189 + someTask.row = null;
1190 const content: ReactNodeList = props.children;
1191 try {
1192 renderNode(request, someTask, content, -1);
1193 } finally {
1194 someTask.keyPath = prevKeyPath;
1195 someTask.formatContext = prevContext;
1196 + someTask.row = prevRow;
1197 }
1198 return;
1199 }
@@ -1164,6 +1202,7 @@ function renderSuspenseBoundary(
1202
1203 const prevKeyPath = task.keyPath;
1204 const prevContext = task.formatContext;
1205 + const prevRow = task.row;
1206 const parentBoundary = task.blockedBoundary;
1207 const parentPreamble = task.blockedPreamble;
1208 const parentHoistableState = task.hoistableState;
@@ -1181,12 +1220,19 @@ function renderSuspenseBoundary(
1220 if (canHavePreamble(task.formatContext)) {
1221 newBoundary = createSuspenseBoundary(
1222 request,
1223 + task.row,
1224 fallbackAbortSet,
1225 createPreambleState(),
1226 createPreambleState(),
1227 );
1228 } else {
1189 - newBoundary = createSuspenseBoundary(request, fallbackAbortSet, null, null);
1229 + newBoundary = createSuspenseBoundary(
1230 + request,
1231 + task.row,
1232 + fallbackAbortSet,
1233 + null,
1234 + null,
1235 + );
1236 }
1237 if (request.trackedPostpones !== null) {
1238 newBoundary.trackedContentKeyPath = keyPath;
@@ -1290,6 +1336,7 @@ function renderSuspenseBoundary(
1336 ),
1337 task.context,
1338 task.treeContext,
1339 + null, // The row gets reset inside the Suspense boundary.
1340 task.componentStack,
1341 !disableLegacyContext ? task.legacyContext : emptyContextObject,
1342 __DEV__ ? task.debugTask : null,
@@ -1318,6 +1365,7 @@ function renderSuspenseBoundary(
1365 request.resumableState,
1366 prevContext,
1367 );
1368 + task.row = null;
1369 contentRootSegment.status = RENDERING;
1370
1371 try {
@@ -1339,6 +1387,14 @@ function renderSuspenseBoundary(
1387 // the fallback. However, if this boundary ended up big enough to be eligible for outlining
1388 // we can't do that because we might still need the fallback if we outline it.
1389 if (!isEligibleForOutlining(request, newBoundary)) {
1390 + if (prevRow !== null) {
1391 + // If we have synchronously completed the boundary and it's not eligible for outlining
1392 + // then we don't have to wait for it to be flushed before we unblock future rows.
1393 + // This lets us inline small rows in order.
1394 + if (--prevRow.pendingTasks === 0) {
1395 + finishSuspenseListRow(request, prevRow);
1396 + }
1397 + }
1398 if (request.pendingRootTasks === 0 && task.blockedPreamble) {
1399 // The root is complete and this boundary may contribute part of the preamble.
1400 // We eagerly attempt to prepare the preamble here because we expect most requests
@@ -1405,6 +1461,7 @@ function renderSuspenseBoundary(
1461 task.blockedSegment = parentSegment;
1462 task.keyPath = prevKeyPath;
1463 task.formatContext = prevContext;
1464 + task.row = prevRow;
1465 }
1466
1467 const fallbackKeyPath = [keyPath[0], 'Suspense Fallback', keyPath[2]];
@@ -1427,6 +1484,7 @@ function renderSuspenseBoundary(
1484 ),
1485 task.context,
1486 task.treeContext,
1487 + task.row,
1488 task.componentStack,
1489 !disableLegacyContext ? task.legacyContext : emptyContextObject,
1490 __DEV__ ? task.debugTask : null,
@@ -1451,6 +1509,7 @@ function replaySuspenseBoundary(
1509 ): void {
1510 const prevKeyPath = task.keyPath;
1511 const prevContext = task.formatContext;
1512 + const prevRow = task.row;
1513 const previousReplaySet: ReplaySet = task.replay;
1514
1515 const parentBoundary = task.blockedBoundary;
@@ -1464,6 +1523,7 @@ function replaySuspenseBoundary(
1523 if (canHavePreamble(task.formatContext)) {
1524 resumedBoundary = createSuspenseBoundary(
1525 request,
1526 + task.row,
1527 fallbackAbortSet,
1528 createPreambleState(),
1529 createPreambleState(),
@@ -1471,6 +1531,7 @@ function replaySuspenseBoundary(
1531 } else {
1532 resumedBoundary = createSuspenseBoundary(
1533 request,
1534 + task.row,
1535 fallbackAbortSet,
1536 null,
1537 null,
@@ -1490,6 +1551,7 @@ function replaySuspenseBoundary(
1551 request.resumableState,
1552 prevContext,
1553 );
1554 + task.row = null;
1555 task.replay = {nodes: childNodes, slots: childSlots, pendingTasks: 1};
1556
1557 try {
@@ -1566,6 +1628,7 @@ function replaySuspenseBoundary(
1628 task.replay = previousReplaySet;
1629 task.keyPath = prevKeyPath;
1630 task.formatContext = prevContext;
1631 + task.row = prevRow;
1632 }
1633
1634 const fallbackKeyPath = [keyPath[0], 'Suspense Fallback', keyPath[2]];
@@ -1593,6 +1656,7 @@ function replaySuspenseBoundary(
1656 ),
1657 task.context,
1658 task.treeContext,
1659 + task.row,
1660 task.componentStack,
1661 !disableLegacyContext ? task.legacyContext : emptyContextObject,
1662 __DEV__ ? task.debugTask : null,
@@ -1604,6 +1668,317 @@ function replaySuspenseBoundary(
1668 request.pingedTasks.push(suspendedFallbackTask);
1669 }
1670
1671 +function finishSuspenseListRow(request: Request, row: SuspenseListRow): void {
1672 + // This row finished. Now we have to unblock all the next rows that were blocked on this.
1673 + // We do this in a loop to avoid stack overflow for very long lists that get unblocked.
1674 + let unblockedRow = row.next;
1675 + while (unblockedRow !== null) {
1676 + // Unblocking the boundaries will decrement the count of this row but we keep it above
1677 + // zero so they never finish this row recursively.
1678 + const unblockedBoundaries = unblockedRow.boundaries;
1679 + if (unblockedBoundaries !== null) {
1680 + unblockedRow.boundaries = null;
1681 + for (let i = 0; i < unblockedBoundaries.length; i++) {
1682 + finishedTask(request, unblockedBoundaries[i], null, null);
1683 + }
1684 + }
1685 + // Instead we decrement at the end to keep it all in this loop.
1686 + unblockedRow.pendingTasks--;
1687 + if (unblockedRow.pendingTasks > 0) {
1688 + // Still blocked.
1689 + break;
1690 + }
1691 + unblockedRow = unblockedRow.next;
1692 + }
1693 +}
1694 +
1695 +function createSuspenseListRow(
1696 + previousRow: null | SuspenseListRow,
1697 +): SuspenseListRow {
1698 + const newRow: SuspenseListRow = {
1699 + pendingTasks: 1, // At first the row is blocked on attempting rendering itself.
1700 + boundaries: null,
1701 + next: null,
1702 + };
1703 + if (previousRow !== null && previousRow.pendingTasks > 0) {
1704 + // If the previous row is not done yet, we add ourselves to be blocked on it.
1705 + // When it finishes, we'll decrement our pending tasks.
1706 + newRow.pendingTasks++;
1707 + newRow.boundaries = [];
1708 + previousRow.next = newRow;
1709 + }
1710 + return newRow;
1711 +}
1712 +
1713 +function renderSuspenseListRows(
1714 + request: Request,
1715 + task: Task,
1716 + keyPath: KeyNode,
1717 + rows: Array<ReactNodeList>,
1718 + revealOrder: 'forwards' | 'backwards',
1719 +): void {
1720 + // This is a fork of renderChildrenArray that's aware of tracking rows.
1721 + const prevKeyPath = task.keyPath;
1722 + const previousComponentStack = task.componentStack;
1723 + let previousDebugTask = null;
1724 + if (__DEV__) {
1725 + previousDebugTask = task.debugTask;
1726 + // We read debugInfo from task.node.props.children instead of rows because it
1727 + // might have been an unwrapped iterable so we read from the original node.
1728 + pushServerComponentStack(task, (task.node: any).props.children._debugInfo);
1729 + }
1730 +
1731 + const prevTreeContext = task.treeContext;
1732 + const prevRow = task.row;
1733 + const totalChildren = rows.length;
1734 +
1735 + if (task.replay !== null) {
1736 + // Replay
1737 + // First we need to check if we have any resume slots at this level.
1738 + const resumeSlots = task.replay.slots;
1739 + 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
1744 + // render order.
1745 + const i = revealOrder !== 'backwards' ? n : totalChildren - 1 - n;
1746 + const node = rows[i];
1747 + task.row = previousSuspenseListRow = createSuspenseListRow(
1748 + previousSuspenseListRow,
1749 + );
1750 + task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
1751 + const resumeSegmentID = resumeSlots[i];
1752 + // TODO: If this errors we should still continue with the next sibling.
1753 + if (typeof resumeSegmentID === 'number') {
1754 + resumeNode(request, task, resumeSegmentID, node, i);
1755 + // We finished rendering this node, so now we can consume this
1756 + // slot. This must happen after in case we rerender this task.
1757 + delete resumeSlots[i];
1758 + } else {
1759 + renderNode(request, task, node, i);
1760 + }
1761 + if (--previousSuspenseListRow.pendingTasks === 0) {
1762 + finishSuspenseListRow(request, previousSuspenseListRow);
1763 + }
1764 + }
1765 + } else {
1766 + let previousSuspenseListRow: null | SuspenseListRow = null;
1767 + for (let n = 0; n < totalChildren; n++) {
1768 + // Since we are going to resume into a slot whose order was already
1769 + // determined by the prerender, we can safely resume it even in reverse
1770 + // render order.
1771 + const i = revealOrder !== 'backwards' ? n : totalChildren - 1 - n;
1772 + const node = rows[i];
1773 + if (__DEV__) {
1774 + warnForMissingKey(request, task, node);
1775 + }
1776 + task.row = previousSuspenseListRow = createSuspenseListRow(
1777 + previousSuspenseListRow,
1778 + );
1779 + task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
1780 + renderNode(request, task, node, i);
1781 + if (--previousSuspenseListRow.pendingTasks === 0) {
1782 + finishSuspenseListRow(request, previousSuspenseListRow);
1783 + }
1784 + }
1785 + }
1786 + } else {
1787 + task = ((task: any): RenderTask); // Refined
1788 + if (revealOrder !== 'backwards') {
1789 + // Forwards direction
1790 + let previousSuspenseListRow: null | SuspenseListRow = null;
1791 + for (let i = 0; i < totalChildren; i++) {
1792 + const node = rows[i];
1793 + if (__DEV__) {
1794 + warnForMissingKey(request, task, node);
1795 + }
1796 + task.row = previousSuspenseListRow = createSuspenseListRow(
1797 + previousSuspenseListRow,
1798 + );
1799 + task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
1800 + renderNode(request, task, node, i);
1801 + if (--previousSuspenseListRow.pendingTasks === 0) {
1802 + finishSuspenseListRow(request, previousSuspenseListRow);
1803 + }
1804 + }
1805 + } else {
1806 + // For backwards direction we need to do things a bit differently.
1807 + // We give each row its own segment so that we can render the content in
1808 + // reverse order but still emit it in the right order when we flush.
1809 + const parentSegment = task.blockedSegment;
1810 + const childIndex = parentSegment.children.length;
1811 + const insertionIndex = parentSegment.chunks.length;
1812 + let previousSuspenseListRow: null | SuspenseListRow = null;
1813 + for (let i = totalChildren - 1; i >= 0; i--) {
1814 + const node = rows[i];
1815 + task.row = previousSuspenseListRow = createSuspenseListRow(
1816 + previousSuspenseListRow,
1817 + );
1818 + task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
1819 + const newSegment = createPendingSegment(
1820 + request,
1821 + insertionIndex,
1822 + null,
1823 + task.formatContext,
1824 + // Assume we are text embedded at the trailing edges
1825 + i === 0 ? parentSegment.lastPushedText : true,
1826 + true,
1827 + );
1828 + // Insert in the beginning of the sequence, which will insert before any previous rows.
1829 + parentSegment.children.splice(childIndex, 0, newSegment);
1830 + task.blockedSegment = newSegment;
1831 + if (__DEV__) {
1832 + warnForMissingKey(request, task, node);
1833 + }
1834 + try {
1835 + renderNode(request, task, node, i);
1836 + pushSegmentFinale(
1837 + newSegment.chunks,
1838 + request.renderState,
1839 + newSegment.lastPushedText,
1840 + newSegment.textEmbedded,
1841 + );
1842 + newSegment.status = COMPLETED;
1843 + finishedSegment(request, task.blockedBoundary, newSegment);
1844 + if (--previousSuspenseListRow.pendingTasks === 0) {
1845 + finishSuspenseListRow(request, previousSuspenseListRow);
1846 + }
1847 + } catch (thrownValue: mixed) {
1848 + if (request.status === ABORTING) {
1849 + newSegment.status = ABORTED;
1850 + } else {
1851 + newSegment.status = ERRORED;
1852 + }
1853 + throw thrownValue;
1854 + }
1855 + }
1856 + task.blockedSegment = parentSegment;
1857 + // Reset lastPushedText for current Segment since the new Segments "consumed" it
1858 + parentSegment.lastPushedText = false;
1859 + }
1860 + }
1861 +
1862 + // Because this context is always set right before rendering every child, we
1863 + // only need to reset it to the previous value at the very end.
1864 + task.treeContext = prevTreeContext;
1865 + task.row = prevRow;
1866 + task.keyPath = prevKeyPath;
1867 + if (__DEV__) {
1868 + task.componentStack = previousComponentStack;
1869 + task.debugTask = previousDebugTask;
1870 + }
1871 +}
1872 +
1873 +function renderSuspenseList(
1874 + request: Request,
1875 + task: Task,
1876 + keyPath: KeyNode,
1877 + props: SuspenseListProps,
1878 +): void {
1879 + const children: any = props.children;
1880 + const revealOrder: SuspenseListRevealOrder = props.revealOrder;
1881 + // TODO: Support tail hidden/collapsed modes.
1882 + // const tailMode: SuspenseListTailMode = props.tail;
1883 + if (revealOrder === 'forwards' || revealOrder === 'backwards') {
1884 + // For ordered reveal, we need to produce rows from the children.
1885 + if (isArray(children)) {
1886 + renderSuspenseListRows(request, task, keyPath, children, revealOrder);
1887 + return;
1888 + }
1889 + const iteratorFn = getIteratorFn(children);
1890 + if (iteratorFn) {
1891 + const iterator = iteratorFn.call(children);
1892 + if (iterator) {
1893 + if (__DEV__) {
1894 + validateIterable(task, children, -1, iterator, iteratorFn);
1895 + }
1896 + // TODO: We currently use the same id algorithm as regular nodes
1897 + // but we need a new algorithm for SuspenseList that doesn't require
1898 + // a full set to be loaded up front to support Async Iterable.
1899 + // When we have that, we shouldn't buffer anymore.
1900 + let step = iterator.next();
1901 + if (!step.done) {
1902 + const rows = [];
1903 + do {
1904 + rows.push(step.value);
1905 + step = iterator.next();
1906 + } while (!step.done);
1907 + renderSuspenseListRows(request, task, keyPath, children, revealOrder);
1908 + }
1909 + return;
1910 + }
1911 + }
1912 + if (
1913 + enableAsyncIterableChildren &&
1914 + typeof (children: any)[ASYNC_ITERATOR] === 'function'
1915 + ) {
1916 + const iterator: AsyncIterator<ReactNodeList> = (children: any)[
1917 + ASYNC_ITERATOR
1918 + ]();
1919 + if (iterator) {
1920 + if (__DEV__) {
1921 + validateAsyncIterable(task, (children: any), -1, iterator);
1922 + }
1923 + // TODO: Update the task.children to be the iterator to avoid asking
1924 + // for new iterators, but we currently warn for rendering these
1925 + // so needs some refactoring to deal with the warning.
1926 +
1927 + // Restore the thenable state before resuming.
1928 + const prevThenableState = task.thenableState;
1929 + task.thenableState = null;
1930 + prepareToUseThenableState(prevThenableState);
1931 +
1932 + // We need to know how many total rows are in this set, so that we
1933 + // can allocate enough id slots to acommodate them. So we must exhaust
1934 + // the iterator before we start recursively rendering the rows.
1935 + // TODO: This is not great but I think it's inherent to the id
1936 + // generation algorithm.
1937 +
1938 + const rows = [];
1939 +
1940 + let done = false;
1941 +
1942 + if (iterator === children) {
1943 + // If it's an iterator we need to continue reading where we left
1944 + // off. We can do that by reading the first few rows from the previous
1945 + // thenable state.
1946 + // $FlowFixMe
1947 + let step = readPreviousThenableFromState();
1948 + while (step !== undefined) {
1949 + if (step.done) {
1950 + done = true;
1951 + break;
1952 + }
1953 + rows.push(step.value);
1954 + step = readPreviousThenableFromState();
1955 + }
1956 + }
1957 +
1958 + if (!done) {
1959 + let step = unwrapThenable(iterator.next());
1960 + while (!step.done) {
1961 + rows.push(step.value);
1962 + step = unwrapThenable(iterator.next());
1963 + }
1964 + }
1965 + renderSuspenseListRows(request, task, keyPath, rows, revealOrder);
1966 + return;
1967 + }
1968 + }
1969 + // This case will warn on the client. It's the same as independent revealOrder.
1970 + }
1971 +
1972 + if (revealOrder === 'together') {
1973 + // TODO
1974 + }
1975 + // For other reveal order modes, we just render it as a fragment.
1976 + const prevKeyPath = task.keyPath;
1977 + task.keyPath = keyPath;
1978 + renderNodeDestructive(request, task, children, -1);
1979 + task.keyPath = prevKeyPath;
1980 +}
1981 +
1982 function renderPreamble(
1983 request: Request,
1984 task: Task,
@@ -1634,6 +2009,7 @@ function renderPreamble(
2009 task.formatContext,
2010 task.context,
2011 task.treeContext,
2012 + task.row,
2013 task.componentStack,
2014 !disableLegacyContext ? task.legacyContext : emptyContextObject,
2015 __DEV__ ? task.debugTask : null,
@@ -2383,11 +2759,7 @@ function renderElement(
2759 return;
2760 }
2761 case REACT_SUSPENSE_LIST_TYPE: {
2386 - // TODO: SuspenseList should control the boundaries.
2387 - const prevKeyPath = task.keyPath;
2388 - task.keyPath = keyPath;
2389 - renderNodeDestructive(request, task, props.children, -1);
2390 - task.keyPath = prevKeyPath;
2762 + renderSuspenseList(request, task, keyPath, props);
2763 return;
2764 }
2765 case REACT_VIEW_TRANSITION_TYPE: {
@@ -3537,6 +3909,7 @@ function spawnNewSuspendedReplayTask(
3909 task.formatContext,
3910 task.context,
3911 task.treeContext,
3912 + task.row,
3913 task.componentStack,
3914 !disableLegacyContext ? task.legacyContext : emptyContextObject,
3915 __DEV__ ? task.debugTask : null,
@@ -3578,6 +3951,7 @@ function spawnNewSuspendedRenderTask(
3951 task.formatContext,
3952 task.context,
3953 task.treeContext,
3954 + task.row,
3955 task.componentStack,
3956 !disableLegacyContext ? task.legacyContext : emptyContextObject,
3957 __DEV__ ? task.debugTask : null,
@@ -3886,10 +4260,19 @@ function erroredReplay(
4260 function erroredTask(
4261 request: Request,
4262 boundary: Root | SuspenseBoundary,
4263 + row: null | SuspenseListRow,
4264 error: mixed,
4265 errorInfo: ThrownInfo,
4266 debugTask: null | ConsoleTask,
4267 ) {
4268 + if (row !== null) {
4269 + if (--row.pendingTasks === 0) {
4270 + finishSuspenseListRow(request, row);
4271 + }
4272 + }
4273 +
4274 + request.allPendingTasks--;
4275 +
4276 // Report the error to a global handler.
4277 let errorDigest;
4278 // We don't handle halts here because we only halt when prerendering and
@@ -3941,7 +4324,6 @@ function erroredTask(
4324 }
4325 }
4326
3944 - request.allPendingTasks--;
4327 if (request.allPendingTasks === 0) {
4328 completeAll(request);
4329 }
@@ -3956,7 +4338,7 @@ function abortTaskSoft(this: Request, task: Task): void {
4338 const segment = task.blockedSegment;
4339 if (segment !== null) {
4340 segment.status = ABORTED;
3959 - finishedTask(request, boundary, segment);
4341 + finishedTask(request, boundary, task.row, segment);
4342 }
4343 }
4344
@@ -3970,6 +4352,7 @@ function abortRemainingSuspenseBoundary(
4352 ): void {
4353 const resumedBoundary = createSuspenseBoundary(
4354 request,
4355 + null,
4356 new Set(),
4357 null,
4358 null,
@@ -4069,6 +4452,13 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4452 segment.status = ABORTED;
4453 }
4454
4455 + const row = task.row;
4456 + if (row !== null) {
4457 + if (--row.pendingTasks === 0) {
4458 + finishSuspenseListRow(request, row);
4459 + }
4460 + }
4461 +
4462 const errorInfo = getThrownInfo(task.componentStack);
4463
4464 if (boundary === null) {
@@ -4091,7 +4481,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4481 // we just need to mark it as postponed.
4482 logPostpone(request, postponeInstance.message, errorInfo, null);
4483 trackPostpone(request, trackedPostpones, task, segment);
4094 - finishedTask(request, null, segment);
4484 + finishedTask(request, null, row, segment);
4485 } else {
4486 const fatal = new Error(
4487 'The render was aborted with postpone when the shell is incomplete. Reason: ' +
@@ -4110,7 +4500,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4500 // We log the error but we still resolve the prerender
4501 logRecoverableError(request, error, errorInfo, null);
4502 trackPostpone(request, trackedPostpones, task, segment);
4113 - finishedTask(request, null, segment);
4503 + finishedTask(request, null, row, segment);
4504 } else {
4505 logRecoverableError(request, error, errorInfo, null);
4506 fatalError(request, error, errorInfo, null);
@@ -4182,7 +4572,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4572 abortTask(fallbackTask, request, error),
4573 );
4574 boundary.fallbackAbortableTasks.clear();
4185 - return finishedTask(request, boundary, segment);
4575 + return finishedTask(request, boundary, row, segment);
4576 }
4577 }
4578 boundary.status = CLIENT_RENDERED;
@@ -4199,7 +4589,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4589 logPostpone(request, postponeInstance.message, errorInfo, null);
4590 if (request.trackedPostpones !== null && segment !== null) {
4591 trackPostpone(request, request.trackedPostpones, task, segment);
4202 - finishedTask(request, task.blockedBoundary, segment);
4592 + finishedTask(request, task.blockedBoundary, row, segment);
4593
4594 // If this boundary was still pending then we haven't already cancelled its fallbacks.
4595 // We'll need to abort the fallbacks, which will also error that parent boundary.
@@ -4355,8 +4745,14 @@ function finishedSegment(
4745 function finishedTask(
4746 request: Request,
4747 boundary: Root | SuspenseBoundary,
4748 + row: null | SuspenseListRow,
4749 segment: null | Segment,
4750 ) {
4751 + if (row !== null) {
4752 + if (--row.pendingTasks === 0) {
4753 + finishSuspenseListRow(request, row);
4754 + }
4755 + }
4756 request.allPendingTasks--;
4757 if (boundary === null) {
4758 if (segment !== null && segment.parentFlushed) {
@@ -4405,6 +4801,13 @@ function finishedTask(
4801 if (!isEligibleForOutlining(request, boundary)) {
4802 boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request);
4803 boundary.fallbackAbortableTasks.clear();
4804 + const boundaryRow = boundary.row;
4805 + if (boundaryRow !== null) {
4806 + // If we aren't eligible for outlining, we don't have to wait until we flush it.
4807 + if (--boundaryRow.pendingTasks === 0) {
4808 + finishSuspenseListRow(request, boundaryRow);
4809 + }
4810 + }
4811 }
4812
4813 if (
@@ -4503,7 +4906,7 @@ function retryRenderTask(
4906 task.abortSet.delete(task);
4907 segment.status = COMPLETED;
4908 finishedSegment(request, task.blockedBoundary, segment);
4506 - finishedTask(request, task.blockedBoundary, segment);
4909 + finishedTask(request, task.blockedBoundary, task.row, segment);
4910 } catch (thrownValue: mixed) {
4911 resetHooksState();
4912
@@ -4556,7 +4959,7 @@ function retryRenderTask(
4959 }
4960
4961 trackPostpone(request, trackedPostpones, task, segment);
4559 - finishedTask(request, task.blockedBoundary, segment);
4962 + finishedTask(request, task.blockedBoundary, task.row, segment);
4963 return;
4964 }
4965
@@ -4590,7 +4993,7 @@ function retryRenderTask(
4993 __DEV__ ? task.debugTask : null,
4994 );
4995 trackPostpone(request, trackedPostpones, task, segment);
4593 - finishedTask(request, task.blockedBoundary, segment);
4996 + finishedTask(request, task.blockedBoundary, task.row, segment);
4997 return;
4998 }
4999 }
@@ -4602,6 +5005,7 @@ function retryRenderTask(
5005 erroredTask(
5006 request,
5007 task.blockedBoundary,
5008 + task.row,
5009 x,
5010 errorInfo,
5011 __DEV__ ? task.debugTask : null,
@@ -4649,7 +5053,7 @@ function retryReplayTask(request: Request, task: ReplayTask): void {
5053 task.replay.pendingTasks--;
5054
5055 task.abortSet.delete(task);
4652 - finishedTask(request, task.blockedBoundary, null);
5056 + finishedTask(request, task.blockedBoundary, task.row, null);
5057 } catch (thrownValue) {
5058 resetHooksState();
5059
@@ -4961,6 +5365,16 @@ function flushSegment(
5365 // Emit a client rendered suspense boundary wrapper.
5366 // We never queue the inner boundary so we'll never emit its content or partial segments.
5367
5368 + const row = boundary.row;
5369 + if (row !== null) {
5370 + // Since this boundary end up client rendered, we can unblock future suspense list rows.
5371 + // This means that they may appear out of order if the future rows succeed but this is
5372 + // a client rendered row.
5373 + if (--row.pendingTasks === 0) {
5374 + finishSuspenseListRow(request, row);
5375 + }
5376 + }
5377 +
5378 if (__DEV__) {
5379 writeStartClientRenderedSuspenseBoundary(
5380 destination,
@@ -5049,6 +5463,16 @@ function flushSegment(
5463 if (hoistableState) {
5464 hoistHoistables(hoistableState, boundary.contentState);
5465 }
5466 +
5467 + const row = boundary.row;
5468 + if (row !== null && isEligibleForOutlining(request, boundary)) {
5469 + // Once we have written the boundary, we can unblock the row and let future
5470 + // rows be written. This may schedule new completed boundaries.
5471 + if (--row.pendingTasks === 0) {
5472 + finishSuspenseListRow(request, row);
5473 + }
5474 + }
5475 +
5476 // We can inline this boundary's content as a complete boundary.
5477 writeStartCompletedSuspenseBoundary(destination, request.renderState);
5478
@@ -5127,6 +5551,15 @@ function flushCompletedBoundary(
5551 }
5552 completedSegments.length = 0;
5553
5554 + const row = boundary.row;
5555 + if (row !== null && isEligibleForOutlining(request, boundary)) {
5556 + // Once we have written the boundary, we can unblock the row and let future
5557 + // rows be written. This may schedule new completed boundaries.
5558 + if (--row.pendingTasks === 0) {
5559 + finishSuspenseListRow(request, row);
5560 + }
5561 + }
5562 +
5563 writeHoistablesForBoundary(
5564 destination,
5565 boundary.contentState,
@@ -5319,6 +5752,7 @@ function flushCompletedQueues(
5752
5753 // Next we check the completed boundaries again. This may have had
5754 // boundaries added to it in case they were too larged to be inlined.
5755 + // SuspenseListRows might have been unblocked as well.
5756 // New ones might be added in this loop.
5757 const largeBoundaries = request.completedBoundaries;
5758 for (i = 0; i < largeBoundaries.length; i++) {