Repository: facebook/react
Pull request: [Fizz] Support basic SuspenseList forwards/backwards revealOrder

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:

- [x] 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. #33312
- [x] I need a second pass on nested SuspenseList semantics. #33308.
- [x] `revealOrder="together"`. #33311.
- [ ] Need to double check resuming semantics.
- [ ] `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.

Changed files:
filename: fixtures/ssr/src/components/LargeContent.js
patch: @@ -1,8 +1,12 @@
-import React, {Fragment, Suspense} from 'react';
+import React, {
+  Fragment,
+  Suspense,
+  unstable_SuspenseList as SuspenseList,
+} from 'react';
 
 export default function LargeContent() {
   return (
-    <Fragment>
+    <SuspenseList revealOrder="forwards">
       <Suspense fallback={null}>
         <p>
           Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris
@@ -286,6 +290,6 @@ export default function LargeContent() {
           interdum a. Proin nec odio in nulla vestibulum.
         </p>
       </Suspense>
-    </Fragment>
+    </SuspenseList>
   );
 }

filename: packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
patch: @@ -1318,10 +1318,8 @@ describe('ReactDOMFizzServer', () => {
     expect(ref.current).toBe(null);
     expect(getVisibleChildren(container)).toEqual(
       <div>
-        Loading A
-        {/* // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
-            // isn't implemented fully yet. */}
-        <span>B</span>
+        {'Loading A'}
+        {'Loading B'}
       </div>,
     );
 
@@ -1335,11 +1333,9 @@ describe('ReactDOMFizzServer', () => {
     // We haven't resolved yet.
     expect(getVisibleChildren(container)).toEqual(
       <div>
-        Loading A
-        {/* // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
-            // isn't implemented fully yet. */}
-        <span>B</span>
-        Loading C
+        {'Loading A'}
+        {'Loading B'}
+        {'Loading C'}
       </div>,
     );
 

filename: packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js
patch: @@ -0,0 +1,327 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @emails react-core
+ * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
+ */
+
+'use strict';
+import {
+  insertNodesAndExecuteScripts,
+  getVisibleChildren,
+} from '../test-utils/FizzTestUtils';
+
+let JSDOM;
+let React;
+let Suspense;
+let SuspenseList;
+let assertLog;
+let Scheduler;
+let ReactDOMFizzServer;
+let Stream;
+let document;
+let writable;
+let container;
+let buffer = '';
+let hasErrored = false;
+let fatalError = undefined;
+
+describe('ReactDOMFizSuspenseList', () => {
+  beforeEach(() => {
+    jest.resetModules();
+    JSDOM = require('jsdom').JSDOM;
+    React = require('react');
+    assertLog = require('internal-test-utils').assertLog;
+    ReactDOMFizzServer = require('react-dom/server');
+    Stream = require('stream');
+
+    Suspense = React.Suspense;
+    SuspenseList = React.unstable_SuspenseList;
+
+    Scheduler = require('scheduler');
+
+    // Test Environment
+    const jsdom = new JSDOM(
+      '<!DOCTYPE html><html><head></head><body><div id="container">',
+      {
+        runScripts: 'dangerously',
+      },
+    );
+    document = jsdom.window.document;
+    container = document.getElementById('container');
+    global.window = jsdom.window;
+    // The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it.
+    global.requestAnimationFrame = global.window.requestAnimationFrame = cb =>
+      setTimeout(cb);
+
+    buffer = '';
+    hasErrored = false;
+
+    writable = new Stream.PassThrough();
+    writable.setEncoding('utf8');
+    writable.on('data', chunk => {
+      buffer += chunk;
+    });
+    writable.on('error', error => {
+      hasErrored = true;
+      fatalError = error;
+    });
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  async function serverAct(callback) {
+    await callback();
+    // Await one turn around the event loop.
+    // This assumes that we'll flush everything we have so far.
+    await new Promise(resolve => {
+      setImmediate(resolve);
+    });
+    if (hasErrored) {
+      throw fatalError;
+    }
+    // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
+    // We also want to execute any scripts that are embedded.
+    // We assume that we have now received a proper fragment of HTML.
+    const bufferedContent = buffer;
+    buffer = '';
+    const temp = document.createElement('body');
+    temp.innerHTML = bufferedContent;
+    await insertNodesAndExecuteScripts(temp, container, null);
+    jest.runAllTimers();
+  }
+
+  function Text(props) {
+    Scheduler.log(props.text);
+    return <span>{props.text}</span>;
+  }
+
+  function createAsyncText(text) {
+    let resolved = false;
+    const Component = function () {
+      if (!resolved) {
+        Scheduler.log('Suspend! [' + text + ']');
+        throw promise;
+      }
+      return <Text text={text} />;
+    };
+    const promise = new Promise(resolve => {
+      Component.resolve = function () {
+        resolved = true;
+        return resolve();
+      };
+    });
+    return Component;
+  }
+
+  // @gate enableSuspenseList
+  it('shows content independently by default', async () => {
+    const A = createAsyncText('A');
+    const B = createAsyncText('B');
+    const C = createAsyncText('C');
+
+    function Foo() {
+      return (
+        <div>
+          <SuspenseList>
+            <Suspense fallback={<Text text="Loading A" />}>
+              <A />
+            </Suspense>
+            <Suspense fallback={<Text text="Loading B" />}>
+              <B />
+            </Suspense>
+            <Suspense fallback={<Text text="Loading C" />}>
+              <C />
+            </Suspense>
+          </SuspenseList>
+        </div>
+      );
+    }
+
+    await A.resolve();
+
+    await serverAct(async () => {
+      const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
+      pipe(writable);
+    });
+
+    assertLog(['A', 'Suspend! [B]', 'Suspend! [C]', 'Loading B', 'Loading C']);
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        <span>A</span>
+        <span>Loading B</span>
+        <span>Loading C</span>
+      </div>,
+    );
+
+    await serverAct(() => C.resolve());
+    assertLog(['C']);
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        <span>A</span>
+        <span>Loading B</span>
+        <span>C</span>
+      </div>,
+    );
+
+    await serverAct(() => B.resolve());
+    assertLog(['B']);
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        <span>A</span>
+        <span>B</span>
+        <span>C</span>
+      </div>,
+    );
+  });
+
+  // @gate enableSuspenseList
+  it('displays each items in "forwards" order', async () => {
+    const A = createAsyncText('A');
+    const B = createAsyncText('B');
+    const C = createAsyncText('C');
+
+    function Foo() {
+      return (
+        <div>
+          <SuspenseList revealOrder="forwards">
+            <Suspense fallback={<Text text="Loading A" />}>
+              <A />
+            </Suspense>
+            <Suspense fallback={<Text text="Loading B" />}>
+              <B />
+            </Suspense>
+            <Suspense fallback={<Text text="Loading C" />}>
+              <C />
+            </Suspense>
+          </SuspenseList>
+        </div>
+      );
+    }
+
+    await C.resolve();
+
+    await serverAct(async () => {
+      const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
+      pipe(writable);
+    });
+
+    assertLog([
+      'Suspend! [A]',
+      'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended,
+      'C',
+      'Loading A',
+      'Loading B',
+      'Loading C',
+    ]);
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        <span>Loading A</span>
+        <span>Loading B</span>
+        <span>Loading C</span>
+      </div>,
+    );
+
+    await serverAct(() => A.resolve());
+    assertLog(['A']);
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        <span>A</span>
+        <span>Loading B</span>
+        <span>Loading C</span>
+      </div>,
+    );
+
+    await serverAct(() => B.resolve());
+    assertLog(['B']);
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        <span>A</span>
+        <span>B</span>
+        <span>C</span>
+      </div>,
+    );
+  });
+
+  // @gate enableSuspenseList
+  it('displays each items in "backwards" order', async () => {
+    const A = createAsyncText('A');
+    const B = createAsyncText('B');
+    const C = createAsyncText('C');
+
+    function Foo() {
+      return (
+        <div>
+          <SuspenseList revealOrder="backwards">
+            <Suspense fallback={<Text text="Loading A" />}>
+              <A />
+            </Suspense>
+            <Suspense fallback={<Text text="Loading B" />}>
+              <B />
+            </Suspense>
+            <Suspense fallback={<Text text="Loading C" />}>
+              <C />
+            </Suspense>
+          </SuspenseList>
+        </div>
+      );
+    }
+
+    await A.resolve();
+
+    await serverAct(async () => {
+      const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
+      pipe(writable);
+    });
+
+    assertLog([
+      'Suspend! [C]',
+      'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended,
+      'A',
+      'Loading C',
+      'Loading B',
+      'Loading A',
+    ]);
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        <span>Loading A</span>
+        <span>Loading B</span>
+        <span>Loading C</span>
+      </div>,
+    );
+
+    await serverAct(() => C.resolve());
+    assertLog(['C']);
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        <span>Loading A</span>
+        <span>Loading B</span>
+        <span>C</span>
+      </div>,
+    );
+
+    await serverAct(() => B.resolve());
+    assertLog(['B']);
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        <span>A</span>
+        <span>B</span>
+        <span>C</span>
+      </div>,
+    );
+  });
+});

filename: packages/react-server/src/ReactFizzServer.js
patch: @@ -24,6 +24,8 @@ import type {
   ViewTransitionProps,
   ActivityProps,
   SuspenseProps,
+  SuspenseListProps,
+  SuspenseListRevealOrder,
 } from 'shared/ReactTypes';
 import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
 import type {
@@ -231,13 +233,20 @@ type LegacyContext = {
   [key: string]: any,
 };
 
+type SuspenseListRow = {
+  pendingTasks: number, // The number of tasks, previous rows and inner suspense boundaries blocking this row.
+  boundaries: null | Array<SuspenseBoundary>, // The boundaries in this row waiting to be unblocked by the previous row. (null means this row is not blocked)
+  next: null | SuspenseListRow, // The next row blocked by this one.
+};
+
 const CLIENT_RENDERED = 4; // if it errors or infinitely suspends
 
 type SuspenseBoundary = {
   status: 0 | 1 | 4 | 5,
   rootSegmentID: number,
   parentFlushed: boolean,
   pendingTasks: number, // when it reaches zero we can show this boundary's content
+  row: null | SuspenseListRow, // the row that this boundary blocks from completing.
   completedSegments: Array<Segment>, // completed but not yet flushed segments.
   byteSize: number, // used to determine whether to inline children boundaries.
   fallbackAbortableTasks: Set<Task>, // used to cancel task on the fallback if the boundary completes or gets canceled.
@@ -268,11 +277,12 @@ type RenderTask = {
   formatContext: FormatContext, // the format's specific context (e.g. HTML/SVG/MathML)
   context: ContextSnapshot, // the current new context that this task is executing in
   treeContext: TreeContext, // the current tree context that this task is executing in
+  row: null | SuspenseListRow, // the current SuspenseList row that this is rendering inside
   componentStack: null | ComponentStackNode, // stack frame description of the currently rendering component
   thenableState: null | ThenableState,
   legacyContext: LegacyContext, // the current legacy context that this task is executing in
   debugTask: null | ConsoleTask, // DEV only
-  // DON'T ANY MORE FIELDS. We at 16 already which otherwise requires converting to a constructor.
+  // DON'T ANY MORE FIELDS. We at 16 in prod already which otherwise requires converting to a constructor.
   // Consider splitting into multiple objects or consolidating some fields.
 };
 
@@ -298,12 +308,11 @@ type ReplayTask = {
   formatContext: FormatContext, // the format's specific context (e.g. HTML/SVG/MathML)
   context: ContextSnapshot, // the current new context that this task is executing in
   treeContext: TreeContext, // the current tree context that this task is executing in
+  row: null | SuspenseListRow, // the current SuspenseList row that this is rendering inside
   componentStack: null | ComponentStackNode, // stack frame description of the currently rendering component
   thenableState: null | ThenableState,
   legacyContext: LegacyContext, // the current legacy context that this task is executing in
   debugTask: null | ConsoleTask, // DEV only
-  // DON'T ANY MORE FIELDS. We at 16 already which otherwise requires converting to a constructor.
-  // Consider splitting into multiple objects or consolidating some fields.
 };
 
 export type Task = RenderTask | ReplayTask;
@@ -542,6 +551,7 @@ export function createRequest(
     rootContextSnapshot,
     emptyTreeContext,
     null,
+    null,
     emptyContextObject,
     null,
   );
@@ -647,6 +657,7 @@ export function resumeRequest(
       rootContextSnapshot,
       emptyTreeContext,
       null,
+      null,
       emptyContextObject,
       null,
     );
@@ -674,6 +685,7 @@ export function resumeRequest(
     rootContextSnapshot,
     emptyTreeContext,
     null,
+    null,
     emptyContextObject,
     null,
   );
@@ -739,6 +751,7 @@ function pingTask(request: Request, task: Task): void {
 
 function createSuspenseBoundary(
   request: Request,
+  row: null | SuspenseListRow,
   fallbackAbortableTasks: Set<Task>,
   contentPreamble: null | Preamble,
   fallbackPreamble: null | Preamble,
@@ -748,6 +761,7 @@ function createSuspenseBoundary(
     rootSegmentID: -1,
     parentFlushed: false,
     pendingTasks: 0,
+    row: row,
     completedSegments: [],
     byteSize: 0,
     fallbackAbortableTasks,
@@ -765,6 +779,17 @@ function createSuspenseBoundary(
     boundary.errorStack = null;
     boundary.errorComponentStack = null;
   }
+  if (row !== null) {
+    // This boundary will block this row from completing.
+    row.pendingTasks++;
+    const blockedBoundaries = row.boundaries;
+    if (blockedBoundaries !== null) {
+      // Previous rows will block this boundary itself from completing.
+      request.allPendingTasks++;
+      boundary.pendingTasks++;
+      blockedBoundaries.push(boundary);
+    }
+  }
   return boundary;
 }
 
@@ -782,6 +807,7 @@ function createRenderTask(
   formatContext: FormatContext,
   context: ContextSnapshot,
   treeContext: TreeContext,
+  row: null | SuspenseListRow,
   componentStack: null | ComponentStackNode,
   legacyContext: LegacyContext,
   debugTask: null | ConsoleTask,
@@ -792,6 +818,9 @@ function createRenderTask(
   } else {
     blockedBoundary.pendingTasks++;
   }
+  if (row !== null) {
+    row.pendingTasks++;
+  }
   const task: RenderTask = ({
     replay: null,
     node,
@@ -806,6 +835,7 @@ function createRenderTask(
     formatContext,
     context,
     treeContext,
+    row,
     componentStack,
     thenableState,
   }: any);
@@ -832,6 +862,7 @@ function createReplayTask(
   formatContext: FormatContext,
   context: ContextSnapshot,
   treeContext: TreeContext,
+  row: null | SuspenseListRow,
   componentStack: null | ComponentStackNode,
   legacyContext: LegacyContext,
   debugTask: null | ConsoleTask,
@@ -842,6 +873,9 @@ function createReplayTask(
   } else {
     blockedBoundary.pendingTasks++;
   }
+  if (row !== null) {
+    row.pendingTasks++;
+  }
   replay.pendingTasks++;
   const task: ReplayTask = ({
     replay,
@@ -857,6 +891,7 @@ function createReplayTask(
     formatContext,
     context,
     treeContext,
+    row,
     componentStack,
     thenableState,
   }: any);
@@ -1145,17 +1180,20 @@ function renderSuspenseBoundary(
     // so we can just render through it.
     const prevKeyPath = someTask.keyPath;
     const prevContext = someTask.formatContext;
+    const prevRow = someTask.row;
     someTask.keyPath = keyPath;
     someTask.formatContext = getSuspenseContentFormatContext(
       request.resumableState,
       prevContext,
     );
+    someTask.row = null;
     const content: ReactNodeList = props.children;
     try {
       renderNode(request, someTask, content, -1);
     } finally {
       someTask.keyPath = prevKeyPath;
       someTask.formatContext = prevContext;
+      someTask.row = prevRow;
     }
     return;
   }
@@ -1164,6 +1202,7 @@ function renderSuspenseBoundary(
 
   const prevKeyPath = task.keyPath;
   const prevContext = task.formatContext;
+  const prevRow = task.row;
   const parentBoundary = task.blockedBoundary;
   const parentPreamble = task.blockedPreamble;
   const parentHoistableState = task.hoistableState;
@@ -1181,12 +1220,19 @@ function renderSuspenseBoundary(
   if (canHavePreamble(task.formatContext)) {
     newBoundary = createSuspenseBoundary(
       request,
+      task.row,
       fallbackAbortSet,
       createPreambleState(),
       createPreambleState(),
     );
   } else {
-    newBoundary = createSuspenseBoundary(request, fallbackAbortSet, null, null);
+    newBoundary = createSuspenseBoundary(
+      request,
+      task.row,
+      fallbackAbortSet,
+      null,
+      null,
+    );
   }
   if (request.trackedPostpones !== null) {
     newBoundary.trackedContentKeyPath = keyPath;
@@ -1290,6 +1336,7 @@ function renderSuspenseBoundary(
       ),
       task.context,
       task.treeContext,
+      null, // The row gets reset inside the Suspense boundary.
       task.componentStack,
       !disableLegacyContext ? task.legacyContext : emptyContextObject,
       __DEV__ ? task.debugTask : null,
@@ -1318,6 +1365,7 @@ function renderSuspenseBoundary(
       request.resumableState,
       prevContext,
     );
+    task.row = null;
     contentRootSegment.status = RENDERING;
 
     try {
@@ -1339,6 +1387,14 @@ function renderSuspenseBoundary(
         // the fallback. However, if this boundary ended up big enough to be eligible for outlining
         // we can't do that because we might still need the fallback if we outline it.
         if (!isEligibleForOutlining(request, newBoundary)) {
+          if (prevRow !== null) {
+            // If we have synchronously completed the boundary and it's not eligible for outlining
+            // then we don't have to wait for it to be flushed before we unblock future rows.
+            // This lets us inline small rows in order.
+            if (--prevRow.pendingTasks === 0) {
+              finishSuspenseListRow(request, prevRow);
+            }
+          }
           if (request.pendingRootTasks === 0 && task.blockedPreamble) {
             // The root is complete and this boundary may contribute part of the preamble.
             // We eagerly attempt to prepare the preamble here because we expect most requests
@@ -1405,6 +1461,7 @@ function renderSuspenseBoundary(
       task.blockedSegment = parentSegment;
       task.keyPath = prevKeyPath;
       task.formatContext = prevContext;
+      task.row = prevRow;
     }
 
     const fallbackKeyPath = [keyPath[0], 'Suspense Fallback', keyPath[2]];
@@ -1427,6 +1484,7 @@ function renderSuspenseBoundary(
       ),
       task.context,
       task.treeContext,
+      task.row,
       task.componentStack,
       !disableLegacyContext ? task.legacyContext : emptyContextObject,
       __DEV__ ? task.debugTask : null,
@@ -1451,6 +1509,7 @@ function replaySuspenseBoundary(
 ): void {
   const prevKeyPath = task.keyPath;
   const prevContext = task.formatContext;
+  const prevRow = task.row;
   const previousReplaySet: ReplaySet = task.replay;
 
   const parentBoundary = task.blockedBoundary;
@@ -1464,13 +1523,15 @@ function replaySuspenseBoundary(
   if (canHavePreamble(task.formatContext)) {
     resumedBoundary = createSuspenseBoundary(
       request,
+      task.row,
       fallbackAbortSet,
       createPreambleState(),
       createPreambleState(),
     );
   } else {
     resumedBoundary = createSuspenseBoundary(
       request,
+      task.row,
       fallbackAbortSet,
       null,
       null,
@@ -1490,6 +1551,7 @@ function replaySuspenseBoundary(
     request.resumableState,
     prevContext,
   );
+  task.row = null;
   task.replay = {nodes: childNodes, slots: childSlots, pendingTasks: 1};
 
   try {
@@ -1566,6 +1628,7 @@ function replaySuspenseBoundary(
     task.replay = previousReplaySet;
     task.keyPath = prevKeyPath;
     task.formatContext = prevContext;
+    task.row = prevRow;
   }
 
   const fallbackKeyPath = [keyPath[0], 'Suspense Fallback', keyPath[2]];
@@ -1593,6 +1656,7 @@ function replaySuspenseBoundary(
     ),
     task.context,
     task.treeContext,
+    task.row,
     task.componentStack,
     !disableLegacyContext ? task.legacyContext : emptyContextObject,
     __DEV__ ? task.debugTask : null,
@@ -1604,6 +1668,317 @@ function replaySuspenseBoundary(
   request.pingedTasks.push(suspendedFallbackTask);
 }
 
+function finishSuspenseListRow(request: Request, row: SuspenseListRow): void {
+  // This row finished. Now we have to unblock all the next rows that were blocked on this.
+  // We do this in a loop to avoid stack overflow for very long lists that get unblocked.
+  let unblockedRow = row.next;
+  while (unblockedRow !== null) {
+    // Unblocking the boundaries will decrement the count of this row but we keep it above
+    // zero so they never finish this row recursively.
+    const unblockedBoundaries = unblockedRow.boundaries;
+    if (unblockedBoundaries !== null) {
+      unblockedRow.boundaries = null;
+      for (let i = 0; i < unblockedBoundaries.length; i++) {
+        finishedTask(request, unblockedBoundaries[i], null, null);
+      }
+    }
+    // Instead we decrement at the end to keep it all in this loop.
+    unblockedRow.pendingTasks--;
+    if (unblockedRow.pendingTasks > 0) {
+      // Still blocked.
+      break;
+    }
+    unblockedRow = unblockedRow.next;
+  }
+}
+
+function createSuspenseListRow(
+  previousRow: null | SuspenseListRow,
+): SuspenseListRow {
+  const newRow: SuspenseListRow = {
+    pendingTasks: 1, // At first the row is blocked on attempting rendering itself.
+    boundaries: null,
+    next: null,
+  };
+  if (previousRow !== null && previousRow.pendingTasks > 0) {
+    // If the previous row is not done yet, we add ourselves to be blocked on it.
+    // When it finishes, we'll decrement our pending tasks.
+    newRow.pendingTasks++;
+    newRow.boundaries = [];
+    previousRow.next = newRow;
+  }
+  return newRow;
+}
+
+function renderSuspenseListRows(
+  request: Request,
+  task: Task,
+  keyPath: KeyNode,
+  rows: Array<ReactNodeList>,
+  revealOrder: 'forwards' | 'backwards',
+): void {
+  // This is a fork of renderChildrenArray that's aware of tracking rows.
+  const prevKeyPath = task.keyPath;
+  const previousComponentStack = task.componentStack;
+  let previousDebugTask = null;
+  if (__DEV__) {
+    previousDebugTask = task.debugTask;
+    // We read debugInfo from task.node.props.children instead of rows because it
+    // might have been an unwrapped iterable so we read from the original node.
+    pushServerComponentStack(task, (task.node: any).props.children._debugInfo);
+  }
+
+  const prevTreeContext = task.treeContext;
+  const prevRow = task.row;
+  const totalChildren = rows.length;
+
+  if (task.replay !== null) {
+    // Replay
+    // First we need to check if we have any resume slots at this level.
+    const resumeSlots = task.replay.slots;
+    if (resumeSlots !== null && typeof resumeSlots === 'object') {
+      let previousSuspenseListRow: null | SuspenseListRow = null;
+      for (let n = 0; n < totalChildren; n++) {
+        // Since we are going to resume into a slot whose order was already
+        // determined by the prerender, we can safely resume it even in reverse
+        // render order.
+        const i = revealOrder !== 'backwards' ? n : totalChildren - 1 - n;
+        const node = rows[i];
+        task.row = previousSuspenseListRow = createSuspenseListRow(
+          previousSuspenseListRow,
+        );
+        task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
+        const resumeSegmentID = resumeSlots[i];
+        // TODO: If this errors we should still continue with the next sibling.
+        if (typeof resumeSegmentID === 'number') {
+          resumeNode(request, task, resumeSegmentID, node, i);
+          // We finished rendering this node, so now we can consume this
+          // slot. This must happen after in case we rerender this task.
+          delete resumeSlots[i];
+        } else {
+          renderNode(request, task, node, i);
+        }
+        if (--previousSuspenseListRow.pendingTasks === 0) {
+          finishSuspenseListRow(request, previousSuspenseListRow);
+        }
+      }
+    } else {
+      let previousSuspenseListRow: null | SuspenseListRow = null;
+      for (let n = 0; n < totalChildren; n++) {
+        // Since we are going to resume into a slot whose order was already
+        // determined by the prerender, we can safely resume it even in reverse
+        // render order.
+        const i = revealOrder !== 'backwards' ? n : totalChildren - 1 - n;
+        const node = rows[i];
+        if (__DEV__) {
+          warnForMissingKey(request, task, node);
+        }
+        task.row = previousSuspenseListRow = createSuspenseListRow(
+          previousSuspenseListRow,
+        );
+        task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
+        renderNode(request, task, node, i);
+        if (--previousSuspenseListRow.pendingTasks === 0) {
+          finishSuspenseListRow(request, previousSuspenseListRow);
+        }
+      }
+    }
+  } else {
+    task = ((task: any): RenderTask); // Refined
+    if (revealOrder !== 'backwards') {
+      // Forwards direction
+      let previousSuspenseListRow: null | SuspenseListRow = null;
+      for (let i = 0; i < totalChildren; i++) {
+        const node = rows[i];
+        if (__DEV__) {
+          warnForMissingKey(request, task, node);
+        }
+        task.row = previousSuspenseListRow = createSuspenseListRow(
+          previousSuspenseListRow,
+        );
+        task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
+        renderNode(request, task, node, i);
+        if (--previousSuspenseListRow.pendingTasks === 0) {
+          finishSuspenseListRow(request, previousSuspenseListRow);
+        }
+      }
+    } else {
+      // For backwards direction we need to do things a bit differently.
+      // We give each row its own segment so that we can render the content in
+      // reverse order but still emit it in the right order when we flush.
+      const parentSegment = task.blockedSegment;
+      const childIndex = parentSegment.children.length;
+      const insertionIndex = parentSegment.chunks.length;
+      let previousSuspenseListRow: null | SuspenseListRow = null;
+      for (let i = totalChildren - 1; i >= 0; i--) {
+        const node = rows[i];
+        task.row = previousSuspenseListRow = createSuspenseListRow(
+          previousSuspenseListRow,
+        );
+        task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
+        const newSegment = createPendingSegment(
+          request,
+          insertionIndex,
+          null,
+          task.formatContext,
+          // Assume we are text embedded at the trailing edges
+          i === 0 ? parentSegment.lastPushedText : true,
+          true,
+        );
+        // Insert in the beginning of the sequence, which will insert before any previous rows.
+        parentSegment.children.splice(childIndex, 0, newSegment);
+        task.blockedSegment = newSegment;
+        if (__DEV__) {
+          warnForMissingKey(request, task, node);
+        }
+        try {
+          renderNode(request, task, node, i);
+          pushSegmentFinale(
+            newSegment.chunks,
+            request.renderState,
+            newSegment.lastPushedText,
+            newSegment.textEmbedded,
+          );
+          newSegment.status = COMPLETED;
+          finishedSegment(request, task.blockedBoundary, newSegment);
+          if (--previousSuspenseListRow.pendingTasks === 0) {
+            finishSuspenseListRow(request, previousSuspenseListRow);
+          }
+        } catch (thrownValue: mixed) {
+          if (request.status === ABORTING) {
+            newSegment.status = ABORTED;
+          } else {
+            newSegment.status = ERRORED;
+          }
+          throw thrownValue;
+        }
+      }
+      task.blockedSegment = parentSegment;
+      // Reset lastPushedText for current Segment since the new Segments "consumed" it
+      parentSegment.lastPushedText = false;
+    }
+  }
+
+  // Because this context is always set right before rendering every child, we
+  // only need to reset it to the previous value at the very end.
+  task.treeContext = prevTreeContext;
+  task.row = prevRow;
+  task.keyPath = prevKeyPath;
+  if (__DEV__) {
+    task.componentStack = previousComponentStack;
+    task.debugTask = previousDebugTask;
+  }
+}
+
+function renderSuspenseList(
+  request: Request,
+  task: Task,
+  keyPath: KeyNode,
+  props: SuspenseListProps,
+): void {
+  const children: any = props.children;
+  const revealOrder: SuspenseListRevealOrder = props.revealOrder;
+  // TODO: Support tail hidden/collapsed modes.
+  // const tailMode: SuspenseListTailMode = props.tail;
+  if (revealOrder === 'forwards' || revealOrder === 'backwards') {
+    // For ordered reveal, we need to produce rows from the children.
+    if (isArray(children)) {
+      renderSuspenseListRows(request, task, keyPath, children, revealOrder);
+      return;
+    }
+    const iteratorFn = getIteratorFn(children);
+    if (iteratorFn) {
+      const iterator = iteratorFn.call(children);
+      if (iterator) {
+        if (__DEV__) {
+          validateIterable(task, children, -1, iterator, iteratorFn);
+        }
+        // TODO: We currently use the same id algorithm as regular nodes
+        // but we need a new algorithm for SuspenseList that doesn't require
+        // a full set to be loaded up front to support Async Iterable.
+        // When we have that, we shouldn't buffer anymore.
+        let step = iterator.next();
+        if (!step.done) {
+          const rows = [];
+          do {
+            rows.push(step.value);
+            step = iterator.next();
+          } while (!step.done);
+          renderSuspenseListRows(request, task, keyPath, children, revealOrder);
+        }
+        return;
+      }
+    }
+    if (
+      enableAsyncIterableChildren &&
+      typeof (children: any)[ASYNC_ITERATOR] === 'function'
+    ) {
+      const iterator: AsyncIterator<ReactNodeList> = (children: any)[
+        ASYNC_ITERATOR
+      ]();
+      if (iterator) {
+        if (__DEV__) {
+          validateAsyncIterable(task, (children: any), -1, iterator);
+        }
+        // TODO: Update the task.children to be the iterator to avoid asking
+        // for new iterators, but we currently warn for rendering these
+        // so needs some refactoring to deal with the warning.
+
+        // Restore the thenable state before resuming.
+        const prevThenableState = task.thenableState;
+        task.thenableState = null;
+        prepareToUseThenableState(prevThenableState);
+
+        // We need to know how many total rows are in this set, so that we
+        // can allocate enough id slots to acommodate them. So we must exhaust
+        // the iterator before we start recursively rendering the rows.
+        // TODO: This is not great but I think it's inherent to the id
+        // generation algorithm.
+
+        const rows = [];
+
+        let done = false;
+
+        if (iterator === children) {
+          // If it's an iterator we need to continue reading where we left
+          // off. We can do that by reading the first few rows from the previous
+          // thenable state.
+          // $FlowFixMe
+          let step = readPreviousThenableFromState();
+          while (step !== undefined) {
+            if (step.done) {
+              done = true;
+              break;
+            }
+            rows.push(step.value);
+            step = readPreviousThenableFromState();
+          }
+        }
+
+        if (!done) {
+          let step = unwrapThenable(iterator.next());
+          while (!step.done) {
+            rows.push(step.value);
+            step = unwrapThenable(iterator.next());
+          }
+        }
+        renderSuspenseListRows(request, task, keyPath, rows, revealOrder);
+        return;
+      }
+    }
+    // This case will warn on the client. It's the same as independent revealOrder.
+  }
+
+  if (revealOrder === 'together') {
+    // TODO
+  }
+  // For other reveal order modes, we just render it as a fragment.
+  const prevKeyPath = task.keyPath;
+  task.keyPath = keyPath;
+  renderNodeDestructive(request, task, children, -1);
+  task.keyPath = prevKeyPath;
+}
+
 function renderPreamble(
   request: Request,
   task: Task,
@@ -1634,6 +2009,7 @@ function renderPreamble(
     task.formatContext,
     task.context,
     task.treeContext,
+    task.row,
     task.componentStack,
     !disableLegacyContext ? task.legacyContext : emptyContextObject,
     __DEV__ ? task.debugTask : null,
@@ -2383,11 +2759,7 @@ function renderElement(
       return;
     }
     case REACT_SUSPENSE_LIST_TYPE: {
-      // TODO: SuspenseList should control the boundaries.
-      const prevKeyPath = task.keyPath;
-      task.keyPath = keyPath;
-      renderNodeDestructive(request, task, props.children, -1);
-      task.keyPath = prevKeyPath;
+      renderSuspenseList(request, task, keyPath, props);
       return;
     }
     case REACT_VIEW_TRANSITION_TYPE: {
@@ -3537,6 +3909,7 @@ function spawnNewSuspendedReplayTask(
     task.formatContext,
     task.context,
     task.treeContext,
+    task.row,
     task.componentStack,
     !disableLegacyContext ? task.legacyContext : emptyContextObject,
     __DEV__ ? task.debugTask : null,
@@ -3578,6 +3951,7 @@ function spawnNewSuspendedRenderTask(
     task.formatContext,
     task.context,
     task.treeContext,
+    task.row,
     task.componentStack,
     !disableLegacyContext ? task.legacyContext : emptyContextObject,
     __DEV__ ? task.debugTask : null,
@@ -3886,10 +4260,19 @@ function erroredReplay(
 function erroredTask(
   request: Request,
   boundary: Root | SuspenseBoundary,
+  row: null | SuspenseListRow,
   error: mixed,
   errorInfo: ThrownInfo,
   debugTask: null | ConsoleTask,
 ) {
+  if (row !== null) {
+    if (--row.pendingTasks === 0) {
+      finishSuspenseListRow(request, row);
+    }
+  }
+
+  request.allPendingTasks--;
+
   // Report the error to a global handler.
   let errorDigest;
   // We don't handle halts here because we only halt when prerendering and
@@ -3941,7 +4324,6 @@ function erroredTask(
     }
   }
 
-  request.allPendingTasks--;
   if (request.allPendingTasks === 0) {
     completeAll(request);
   }
@@ -3956,7 +4338,7 @@ function abortTaskSoft(this: Request, task: Task): void {
   const segment = task.blockedSegment;
   if (segment !== null) {
     segment.status = ABORTED;
-    finishedTask(request, boundary, segment);
+    finishedTask(request, boundary, task.row, segment);
   }
 }
 
@@ -3970,6 +4352,7 @@ function abortRemainingSuspenseBoundary(
 ): void {
   const resumedBoundary = createSuspenseBoundary(
     request,
+    null,
     new Set(),
     null,
     null,
@@ -4069,6 +4452,13 @@ function abortTask(task: Task, request: Request, error: mixed): void {
     segment.status = ABORTED;
   }
 
+  const row = task.row;
+  if (row !== null) {
+    if (--row.pendingTasks === 0) {
+      finishSuspenseListRow(request, row);
+    }
+  }
+
   const errorInfo = getThrownInfo(task.componentStack);
 
   if (boundary === null) {
@@ -4091,7 +4481,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
             // we just need to mark it as postponed.
             logPostpone(request, postponeInstance.message, errorInfo, null);
             trackPostpone(request, trackedPostpones, task, segment);
-            finishedTask(request, null, segment);
+            finishedTask(request, null, row, segment);
           } else {
             const fatal = new Error(
               '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 {
           // We log the error but we still resolve the prerender
           logRecoverableError(request, error, errorInfo, null);
           trackPostpone(request, trackedPostpones, task, segment);
-          finishedTask(request, null, segment);
+          finishedTask(request, null, row, segment);
         } else {
           logRecoverableError(request, error, errorInfo, null);
           fatalError(request, error, errorInfo, null);
@@ -4182,7 +4572,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
             abortTask(fallbackTask, request, error),
           );
           boundary.fallbackAbortableTasks.clear();
-          return finishedTask(request, boundary, segment);
+          return finishedTask(request, boundary, row, segment);
         }
       }
       boundary.status = CLIENT_RENDERED;
@@ -4199,7 +4589,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
         logPostpone(request, postponeInstance.message, errorInfo, null);
         if (request.trackedPostpones !== null && segment !== null) {
           trackPostpone(request, request.trackedPostpones, task, segment);
-          finishedTask(request, task.blockedBoundary, segment);
+          finishedTask(request, task.blockedBoundary, row, segment);
 
           // If this boundary was still pending then we haven't already cancelled its fallbacks.
           // We'll need to abort the fallbacks, which will also error that parent boundary.
@@ -4355,8 +4745,14 @@ function finishedSegment(
 function finishedTask(
   request: Request,
   boundary: Root | SuspenseBoundary,
+  row: null | SuspenseListRow,
   segment: null | Segment,
 ) {
+  if (row !== null) {
+    if (--row.pendingTasks === 0) {
+      finishSuspenseListRow(request, row);
+    }
+  }
   request.allPendingTasks--;
   if (boundary === null) {
     if (segment !== null && segment.parentFlushed) {
@@ -4405,6 +4801,13 @@ function finishedTask(
         if (!isEligibleForOutlining(request, boundary)) {
           boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request);
           boundary.fallbackAbortableTasks.clear();
+          const boundaryRow = boundary.row;
+          if (boundaryRow !== null) {
+            // If we aren't eligible for outlining, we don't have to wait until we flush it.
+            if (--boundaryRow.pendingTasks === 0) {
+              finishSuspenseListRow(request, boundaryRow);
+            }
+          }
         }
 
         if (
@@ -4503,7 +4906,7 @@ function retryRenderTask(
     task.abortSet.delete(task);
     segment.status = COMPLETED;
     finishedSegment(request, task.blockedBoundary, segment);
-    finishedTask(request, task.blockedBoundary, segment);
+    finishedTask(request, task.blockedBoundary, task.row, segment);
   } catch (thrownValue: mixed) {
     resetHooksState();
 
@@ -4556,7 +4959,7 @@ function retryRenderTask(
       }
 
       trackPostpone(request, trackedPostpones, task, segment);
-      finishedTask(request, task.blockedBoundary, segment);
+      finishedTask(request, task.blockedBoundary, task.row, segment);
       return;
     }
 
@@ -4590,7 +4993,7 @@ function retryRenderTask(
           __DEV__ ? task.debugTask : null,
         );
         trackPostpone(request, trackedPostpones, task, segment);
-        finishedTask(request, task.blockedBoundary, segment);
+        finishedTask(request, task.blockedBoundary, task.row, segment);
         return;
       }
     }
@@ -4602,6 +5005,7 @@ function retryRenderTask(
     erroredTask(
       request,
       task.blockedBoundary,
+      task.row,
       x,
       errorInfo,
       __DEV__ ? task.debugTask : null,
@@ -4649,7 +5053,7 @@ function retryReplayTask(request: Request, task: ReplayTask): void {
     task.replay.pendingTasks--;
 
     task.abortSet.delete(task);
-    finishedTask(request, task.blockedBoundary, null);
+    finishedTask(request, task.blockedBoundary, task.row, null);
   } catch (thrownValue) {
     resetHooksState();
 
@@ -4961,6 +5365,16 @@ function flushSegment(
     // Emit a client rendered suspense boundary wrapper.
     // We never queue the inner boundary so we'll never emit its content or partial segments.
 
+    const row = boundary.row;
+    if (row !== null) {
+      // Since this boundary end up client rendered, we can unblock future suspense list rows.
+      // This means that they may appear out of order if the future rows succeed but this is
+      // a client rendered row.
+      if (--row.pendingTasks === 0) {
+        finishSuspenseListRow(request, row);
+      }
+    }
+
     if (__DEV__) {
       writeStartClientRenderedSuspenseBoundary(
         destination,
@@ -5049,6 +5463,16 @@ function flushSegment(
     if (hoistableState) {
       hoistHoistables(hoistableState, boundary.contentState);
     }
+
+    const row = boundary.row;
+    if (row !== null && isEligibleForOutlining(request, boundary)) {
+      // Once we have written the boundary, we can unblock the row and let future
+      // rows be written. This may schedule new completed boundaries.
+      if (--row.pendingTasks === 0) {
+        finishSuspenseListRow(request, row);
+      }
+    }
+
     // We can inline this boundary's content as a complete boundary.
     writeStartCompletedSuspenseBoundary(destination, request.renderState);
 
@@ -5127,6 +5551,15 @@ function flushCompletedBoundary(
   }
   completedSegments.length = 0;
 
+  const row = boundary.row;
+  if (row !== null && isEligibleForOutlining(request, boundary)) {
+    // Once we have written the boundary, we can unblock the row and let future
+    // rows be written. This may schedule new completed boundaries.
+    if (--row.pendingTasks === 0) {
+      finishSuspenseListRow(request, row);
+    }
+  }
+
   writeHoistablesForBoundary(
     destination,
     boundary.contentState,
@@ -5319,6 +5752,7 @@ function flushCompletedQueues(
 
     // Next we check the completed boundaries again. This may have had
     // boundaries added to it in case they were too larged to be inlined.
+    // SuspenseListRows might have been unblocked as well.
     // New ones might be added in this loop.
     const largeBoundaries = request.completedBoundaries;
     for (i = 0; i < largeBoundaries.length; i++) {