diff --git a/fixtures/ssr/src/components/LargeContent.js b/fixtures/ssr/src/components/LargeContent.js index a5af3064b4917..7c4a6cf2258ef 100644 --- a/fixtures/ssr/src/components/LargeContent.js +++ b/fixtures/ssr/src/components/LargeContent.js @@ -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 ( - +

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.

-
+ ); } diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 8bb3e2f4b74b9..d6147d60e077c 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -1318,10 +1318,8 @@ describe('ReactDOMFizzServer', () => { expect(ref.current).toBe(null); 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 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++) {