- Loading A
- {/* // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
- // isn't implemented fully yet. */}
- B
+ {'Loading A'}
+ {'Loading B'}
,
);
@@ -1335,11 +1333,9 @@ describe('ReactDOMFizzServer', () => {
// We haven't resolved yet.
expect(getVisibleChildren(container)).toEqual(
- Loading A
- {/* // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
- // isn't implemented fully yet. */}
- B
- Loading C
+ {'Loading A'}
+ {'Loading B'}
+ {'Loading C'}
,
);
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js
new file mode 100644
index 0000000000000..90ced7d677dbb
--- /dev/null
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js
@@ -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(
+ '',
+ {
+ 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
{props.text};
+ }
+
+ function createAsyncText(text) {
+ let resolved = false;
+ const Component = function () {
+ if (!resolved) {
+ Scheduler.log('Suspend! [' + text + ']');
+ throw promise;
+ }
+ return
;
+ };
+ 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 (
+
+
+ }>
+
+
+ }>
+
+
+ }>
+
+
+
+
+ );
+ }
+
+ await A.resolve();
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
);
+ pipe(writable);
+ });
+
+ assertLog(['A', 'Suspend! [B]', 'Suspend! [C]', 'Loading B', 'Loading C']);
+
+ expect(getVisibleChildren(container)).toEqual(
+
+ A
+ Loading B
+ Loading C
+
,
+ );
+
+ await serverAct(() => C.resolve());
+ assertLog(['C']);
+
+ expect(getVisibleChildren(container)).toEqual(
+
+ A
+ Loading B
+ C
+
,
+ );
+
+ await serverAct(() => B.resolve());
+ assertLog(['B']);
+
+ expect(getVisibleChildren(container)).toEqual(
+
+ A
+ B
+ C
+
,
+ );
+ });
+
+ // @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 (
+
+
+ }>
+
+
+ }>
+
+
+ }>
+
+
+
+
+ );
+ }
+
+ await C.resolve();
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
);
+ 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(
+
+ Loading A
+ Loading B
+ Loading C
+
,
+ );
+
+ await serverAct(() => A.resolve());
+ assertLog(['A']);
+
+ expect(getVisibleChildren(container)).toEqual(
+
+ A
+ Loading B
+ Loading C
+
,
+ );
+
+ await serverAct(() => B.resolve());
+ assertLog(['B']);
+
+ expect(getVisibleChildren(container)).toEqual(
+
+ A
+ B
+ C
+
,
+ );
+ });
+
+ // @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 (
+
+
+ }>
+
+
+ }>
+
+
+ }>
+
+
+
+
+ );
+ }
+
+ await A.resolve();
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
);
+ 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(
+
+ Loading A
+ Loading B
+ Loading C
+
,
+ );
+
+ await serverAct(() => C.resolve());
+ assertLog(['C']);
+
+ expect(getVisibleChildren(container)).toEqual(
+
+ Loading A
+ Loading B
+ C
+
,
+ );
+
+ await serverAct(() => B.resolve());
+ assertLog(['B']);
+
+ expect(getVisibleChildren(container)).toEqual(
+
+ A
+ B
+ C
+
,
+ );
+ });
+});
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index 247e21076e5b6..b8f5c1be75db0 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -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,6 +233,12 @@ 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
, // 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 = {
@@ -238,6 +246,7 @@ type SuspenseBoundary = {
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, // completed but not yet flushed segments.
byteSize: number, // used to determine whether to inline children boundaries.
fallbackAbortableTasks: Set, // 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,
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,6 +1523,7 @@ function replaySuspenseBoundary(
if (canHavePreamble(task.formatContext)) {
resumedBoundary = createSuspenseBoundary(
request,
+ task.row,
fallbackAbortSet,
createPreambleState(),
createPreambleState(),
@@ -1471,6 +1531,7 @@ function replaySuspenseBoundary(
} 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,
+ 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 = (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++) {