diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index a93c32a947f10..c02c00a13c6f6 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -4573,6 +4573,24 @@ export function pushEndActivityBoundary( target.push(endActivityBoundary); } +// SuspenseList with hidden tails are encoded as comments. +const startSuspenseListBoundary = stringToPrecomputedChunk(''); +const endSuspenseListBoundary = stringToPrecomputedChunk(''); + +export function pushStartSuspenseListBoundary( + target: Array, + renderState: RenderState, +): void { + target.push(startSuspenseListBoundary); +} + +export function pushEndSuspenseListBoundary( + target: Array, + renderState: RenderState, +): void { + target.push(endSuspenseListBoundary); +} + // Suspense boundaries are encoded as comments. const startCompletedSuspenseBoundary = stringToPrecomputedChunk(''); const startPendingSuspenseBoundary1 = stringToPrecomputedChunk( diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js index d48e9a8dd932e..376c5c07bc8ad 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js @@ -24,6 +24,8 @@ import { pushSegmentFinale as pushSegmentFinaleImpl, pushStartActivityBoundary as pushStartActivityBoundaryImpl, pushEndActivityBoundary as pushEndActivityBoundaryImpl, + pushStartSuspenseListBoundary as pushStartSuspenseListBoundaryImpl, + pushEndSuspenseListBoundary as pushEndSuspenseListBoundaryImpl, writeStartCompletedSuspenseBoundary as writeStartCompletedSuspenseBoundaryImpl, writeStartClientRenderedSuspenseBoundary as writeStartClientRenderedSuspenseBoundaryImpl, writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl, @@ -259,6 +261,28 @@ export function pushEndActivityBoundary( pushEndActivityBoundaryImpl(target, renderState); } +export function pushStartSuspenseListBoundary( + target: Array, + renderState: RenderState, +): void { + if (renderState.generateStaticMarkup) { + // A completed boundary is done and doesn't need a representation in the HTML + // if we're not going to be hydrating it. + return; + } + pushStartSuspenseListBoundaryImpl(target, renderState); +} + +export function pushEndSuspenseListBoundary( + target: Array, + renderState: RenderState, +): void { + if (renderState.generateStaticMarkup) { + return; + } + pushEndSuspenseListBoundaryImpl(target, renderState); +} + export function writeStartCompletedSuspenseBoundary( destination: Destination, renderState: RenderState, diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js index 0e2fff5cd4510..15a2b5bacb414 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js @@ -134,7 +134,7 @@ describe('ReactDOMFizzSuspenseList', () => { } // @gate enableSuspenseList - it('shows content forwards by default', async () => { + it('shows content forwards but hidden tail by default', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); const C = createAsyncText('C'); @@ -173,13 +173,7 @@ describe('ReactDOMFizzSuspenseList', () => { 'Loading C', ]); - expect(getVisibleChildren(container)).toEqual( -
- Loading A - Loading B - Loading C -
, - ); + expect(getVisibleChildren(container)).toEqual(
); await serverAct(() => A.resolve()); assertLog(['A']); @@ -187,8 +181,6 @@ describe('ReactDOMFizzSuspenseList', () => { expect(getVisibleChildren(container)).toEqual(
A - Loading B - Loading C
, ); @@ -986,4 +978,610 @@ describe('ReactDOMFizzSuspenseList', () => { expect(hasErrored).toBe(false); expect(hasCompleted).toBe(true); }); + + // @gate enableSuspenseList + it('can stream in "forwards" with tail "hidden" with boundaries', 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(
); + + await serverAct(() => A.resolve()); + assertLog(['A']); + + expect(getVisibleChildren(container)).toEqual( +
+ A +
, + ); + + await serverAct(() => B.resolve()); + assertLog(['B']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + B + C +
, + ); + }); + + // @gate enableSuspenseList + it('can stream in "forwards" with tail "hidden" without boundaries', 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', + ]); + + expect(getVisibleChildren(container)).toEqual(
); + + await serverAct(() => A.resolve()); + assertLog(['A']); + + expect(getVisibleChildren(container)).toEqual( +
+ A +
, + ); + + await serverAct(() => B.resolve()); + assertLog(['B']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + B + C +
, + ); + }); + + // @gate enableSuspenseList + it('can stream in "backwards" with tail "hidden" with boundaries', 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(
); + + await serverAct(() => A.resolve()); + assertLog(['A']); + + expect(getVisibleChildren(container)).toEqual( +
+ A +
, + ); + + await serverAct(() => B.resolve()); + assertLog(['B']); + + expect(getVisibleChildren(container)).toEqual( +
+ C + B + A +
, + ); + }); + + // @gate enableSuspenseList + it('can stream in "backwards" with tail "hidden" without boundaries', 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', + ]); + + expect(getVisibleChildren(container)).toEqual(
); + + await serverAct(() => A.resolve()); + assertLog(['A']); + + expect(getVisibleChildren(container)).toEqual( +
+ A +
, + ); + + await serverAct(() => B.resolve()); + assertLog(['B']); + + expect(getVisibleChildren(container)).toEqual( +
+ C + B + A +
, + ); + }); + + // @gate enableSuspenseList + it('can stream in "forwards" with tail "collapsed"', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + const LoadingA = createAsyncText('Loading A'); + const LoadingB = createAsyncText('Loading B'); + const LoadingC = createAsyncText('Loading 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', + 'Suspend! [Loading A]', + 'Suspend! [Loading B]', + 'Suspend! [Loading C]', + ]); + + // We need the loading state for the first row before the shell completes. + expect(getVisibleChildren(container)).toEqual(undefined); + + await serverAct(() => LoadingA.resolve()); + + assertLog(['Loading A']); + + expect(getVisibleChildren(container)).toEqual( +
+ Loading A +
, + ); + + await serverAct(() => A.resolve()); + assertLog(['A']); + + // We can't show A yet because we don't yet have the loading state for the next row. + expect(getVisibleChildren(container)).toEqual( +
+ Loading A +
, + ); + + await serverAct(() => LoadingB.resolve()); + assertLog(['Loading B']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + Loading B +
, + ); + + await serverAct(() => B.resolve()); + assertLog(['B']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + B + C +
, + ); + }); + + // @gate enableSuspenseList + it('can stream in "backwards" with tail "collapsed"', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + const LoadingA = createAsyncText('Loading A'); + const LoadingB = createAsyncText('Loading B'); + const LoadingC = createAsyncText('Loading 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', + 'Suspend! [Loading A]', + 'Suspend! [Loading B]', + 'Suspend! [Loading C]', + ]); + + // We need the loading state for the first row before the shell completes. + expect(getVisibleChildren(container)).toEqual(undefined); + + await serverAct(() => LoadingA.resolve()); + + assertLog(['Loading A']); + + expect(getVisibleChildren(container)).toEqual( +
+ Loading A +
, + ); + + await serverAct(() => A.resolve()); + assertLog(['A']); + + // We can't show A yet because we don't yet have the loading state for the next row. + expect(getVisibleChildren(container)).toEqual( +
+ Loading A +
, + ); + + await serverAct(() => LoadingB.resolve()); + assertLog(['Loading B']); + + expect(getVisibleChildren(container)).toEqual( +
+ Loading B + A +
, + ); + + await serverAct(() => B.resolve()); + assertLog(['B']); + + expect(getVisibleChildren(container)).toEqual( +
+ C + B + A +
, + ); + }); + + // @gate enableSuspenseList + it('can stream in a single row "forwards" with tail "collapsed"', async () => { + // This is a special case since there's no second loading state to unblock the first row. + const A = createAsyncText('A'); + const LoadingA = createAsyncText('Loading A'); + + function Foo() { + return ( +
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + assertLog(['Suspend! [A]', 'Suspend! [Loading A]']); + + // We need the loading state for the first row before the shell completes. + expect(getVisibleChildren(container)).toEqual(undefined); + + await serverAct(() => LoadingA.resolve()); + + assertLog(['Loading A']); + + expect(getVisibleChildren(container)).toEqual( +
+ Loading A +
, + ); + + await serverAct(() => A.resolve()); + assertLog(['A']); + + expect(getVisibleChildren(container)).toEqual( +
+ A +
, + ); + }); + + // @gate enableSuspenseList + it('can stream in sync rows "forwards" with tail "collapsed"', async () => { + // Notably, this doesn't currently work if the fallbacks are blocked. + // We need the fallbacks to unblock previous rows and we don't know we won't + // need them until later. Needs some resolution at the end. + function Foo() { + return ( +
+ + }> + + + }> + + + }> + + + +
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + assertLog(['A', 'B', 'C', 'Loading A', 'Loading B']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + B + C +
, + ); + }); + + // @gate enableSuspenseList + it('can stream in sync rows "forwards" with tail "collapsed" without boundaries', async () => { + function Foo() { + return ( +
+ + + + + +
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + assertLog(['A', 'B', 'C']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + B + C +
, + ); + }); + + it('inserts text separators (comments) for text nodes (forwards)', async () => { + function Foo() { + return ( +
+ {['A', 'B', 'C']} +
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expect(getVisibleChildren(container)).toEqual(
{['A', 'B', 'C']}
); + + const textNodes = 3; + const boundaryComments = 2 * textNodes; // TODO: One we remove the comments around boundaries this should be zero. + const textSeparators = textNodes; // One after each node. + const suspenseListComments = 2; + expect(container.firstChild.childNodes.length).toBe( + textNodes + textSeparators + boundaryComments + suspenseListComments, + ); + }); + + it('inserts text separators (comments) for text nodes (backwards)', async () => { + function Foo() { + return ( +
+ {['C', 'B', 'A']} +
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expect(getVisibleChildren(container)).toEqual(
{['A', 'B', 'C']}
); + + const textNodes = 3; + const boundaryComments = 2 * textNodes; // TODO: One we remove the comments around boundaries this should be zero. + const textSeparators = textNodes; // One after each node. + const suspenseListComments = 2; + expect(container.firstChild.childNodes.length).toBe( + textNodes + textSeparators + boundaryComments + suspenseListComments, + ); + }); + + it('inserts text separators (comments) for text nodes (legacy)', async () => { + function Foo() { + return ( +
+ + {['A', 'B', 'C']} + +
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expect(getVisibleChildren(container)).toEqual(
{['A', 'B', 'C']}
); + + const textNodes = 3; + const boundaryComments = 2 * textNodes; // TODO: One we remove the comments around boundaries this should be zero. + const textSeparators = textNodes; // One after each node. + const suspenseListComments = 2; + expect(container.firstChild.childNodes.length).toBe( + textNodes + textSeparators + boundaryComments + suspenseListComments, + ); + }); }); diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js index 7dbe5592f3372..6fc10b27a20e8 100644 --- a/packages/react-markup/src/ReactFizzConfigMarkup.js +++ b/packages/react-markup/src/ReactFizzConfigMarkup.js @@ -182,6 +182,21 @@ export function pushEndActivityBoundary( return; } +export function pushStartSuspenseListBoundary( + target: Array, + renderState: RenderState, +): void { + // Markup doesn't have any instructions. + return; +} + +export function pushEndSuspenseListBoundary( + target: Array, + renderState: RenderState, +): void { + // Markup doesn't have any instructions. + return; +} export function writeStartCompletedSuspenseBoundary( destination: Destination, renderState: RenderState, diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 1793180cc7658..e492e9c216429 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -34,6 +34,10 @@ type ActivityInstance = { children: Array, }; +type SuspenseListInstance = { + children: Array, +}; + type SuspenseInstance = { state: 'pending' | 'complete' | 'client-render', children: Array, @@ -200,6 +204,23 @@ const ReactNoopServer = ReactFizzServer({ target.push(POP); }, + pushStartSuspenseListBoundary( + target: Array, + renderState: RenderState, + ): void { + const suspenseListInstance: SuspenseListInstance = { + children: [], + }; + target.push(Buffer.from(JSON.stringify(suspenseListInstance), 'utf8')); + }, + + pushEndSuspenseListBoundary( + target: Array, + renderState: RenderState, + ): void { + target.push(POP); + }, + writeStartCompletedSuspenseBoundary( destination: Destination, renderState: RenderState, diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 4ad48d79fba4f..6bf45d9c6dd01 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -13,6 +13,7 @@ import type { PrecomputedChunk, } from './ReactServerStreamConfig'; import type { + ReactKey, ReactNodeList, ReactContext, ReactConsumerType, @@ -27,7 +28,7 @@ import type { SuspenseProps, SuspenseListProps, SuspenseListRevealOrder, - ReactKey, + SuspenseListTailMode, } from 'shared/ReactTypes'; import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; import type { @@ -61,6 +62,8 @@ import { writePlaceholder, pushStartActivityBoundary, pushEndActivityBoundary, + pushStartSuspenseListBoundary, + pushEndSuspenseListBoundary, writeStartCompletedSuspenseBoundary, writeStartPendingSuspenseBoundary, writeStartClientRenderedSuspenseBoundary, @@ -234,12 +237,25 @@ type LegacyContext = { [key: string]: any, }; +// This type is only used for SuspenseLists that may have hidden tail rows and therefore +// add new segments to the end of the list as it streams. +type SuspenseList = { + id: number, + forwards: boolean, + completedRows: number, +}; + +type SuspenseListRowMode = 0 | 1; + +const INDEPENDENT = 0; // Each boundary is revealed independently and when they're all done, the next row can unblock. +const TOGETHER = 1; // All the boundaries within this row must be revealed together. + 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) hoistables: HoistableState, // Any dependencies that this row depends on. Future rows need to also depend on it. inheritedHoistables: null | HoistableState, // Any dependencies that previous row depend on, that new boundaries of this row needs. - together: boolean, // All the boundaries within this row must be revealed together. + mode: SuspenseListRowMode, next: null | SuspenseListRow, // The next row blocked by this one. }; @@ -250,7 +266,9 @@ type SuspenseBoundary = { rootSegmentID: number, parentFlushed: boolean, pendingTasks: number, // when it reaches zero we can show this boundary's content + list: null | SuspenseList, // if this boundary is an implicit boundary around a row, then this is the suspense list that it's added to. row: null | SuspenseListRow, // the row that this boundary blocks from completing. + preceedingRow: null | SuspenseListRow, // a preceeding row that can't complete until this boundary is complete (other than being blocked by that row). completedSegments: Array, // completed but not yet flushed segments. byteSize: number, // used to determine whether to inline children boundaries. defer: boolean, // never inline deferred boundaries @@ -795,6 +813,7 @@ function pingTask(request: Request, task: Task): void { function createSuspenseBoundary( request: Request, + list: null | SuspenseList, row: null | SuspenseListRow, fallbackAbortableTasks: Set, preamble: null | Preamble, @@ -805,7 +824,9 @@ function createSuspenseBoundary( rootSegmentID: -1, parentFlushed: false, pendingTasks: 0, + list: null, row: row, + preceedingRow: null, completedSegments: [], byteSize: 0, defer: defer, @@ -1295,6 +1316,7 @@ function renderSuspenseBoundary( const fallbackAbortSet: Set = new Set(); const newBoundary = createSuspenseBoundary( request, + null, task.row, fallbackAbortSet, canHavePreamble(task.formatContext) ? createPreamble() : null, @@ -1487,7 +1509,7 @@ function renderSuspenseBoundary( } } else { const boundaryRow = prevRow; - if (boundaryRow !== null && boundaryRow.together) { + if (boundaryRow !== null && boundaryRow.mode === TOGETHER) { tryToResolveTogetherRow(request, boundaryRow); } } @@ -1596,6 +1618,7 @@ function replaySuspenseBoundary( const fallbackAbortSet: Set = new Set(); const resumedBoundary = createSuspenseBoundary( request, + null, task.row, fallbackAbortSet, canHavePreamble(task.formatContext) ? createPreamble() : null, @@ -1730,6 +1753,15 @@ function unblockSuspenseListRow( ): void { // We do this in a loop to avoid stack overflow for very long lists that get unblocked. while (unblockedRow !== null) { + if (unblockedRow.pendingTasks === 0) { + // We already finished this task, perhaps below. + if (__DEV__) { + console.error( + 'It should not be possible to unblock a row that has no pending tasks. This is a bug in React.', + ); + } + break; + } if (inheritedHoistables !== null) { // Hoist any hoistables from the previous row into the next row so that it can be // later transferred to all the rows. @@ -1742,13 +1774,27 @@ function unblockSuspenseListRow( // 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) { + if ( + unblockedBoundaries !== null && + (unblockedRow.mode !== TOGETHER || + // Together rows are blocked on themselves. This is the last one, which will + // unblock the row itself. + unblockedRow.pendingTasks === 1) + ) { unblockedRow.boundaries = null; for (let i = 0; i < unblockedBoundaries.length; i++) { const unblockedBoundary = unblockedBoundaries[i]; if (inheritedHoistables !== null) { hoistHoistables(unblockedBoundary.contentState, inheritedHoistables); } + if ( + unblockedRow.mode === TOGETHER && + unblockedBoundary.pendingTasks === 1 + ) { + // We decrement the task count when we flush each boundary of a together row. + // We add it back here before finishing which decrements it again. + unblockedRow.pendingTasks++; + } finishedTask(request, unblockedBoundary, null, null); } } @@ -1807,10 +1853,20 @@ function tryToResolveTogetherRow( } } if (allCompleteAndInlinable) { + // Act as if we've flushed all but one and we're now flushing the last one. + togetherRow.pendingTasks -= boundaries.length - 1; unblockSuspenseListRow(request, togetherRow, togetherRow.hoistables); } } +function createSuspenseList(forwards: boolean): SuspenseList { + return { + id: -1, + forwards: forwards, + completedRows: 0, + }; +} + function createSuspenseListRow( previousRow: null | SuspenseListRow, ): SuspenseListRow { @@ -1819,7 +1875,7 @@ function createSuspenseListRow( boundaries: null, hoistables: createHoistableState(), inheritedHoistables: null, - together: false, + mode: INDEPENDENT, next: null, }; if (previousRow !== null && previousRow.pendingTasks > 0) { @@ -1838,6 +1894,7 @@ function renderSuspenseListRows( keyPath: KeyNode, rows: Array, revealOrder: void | 'forwards' | 'backwards' | 'unstable_legacy-backwards', + tailMode: void | 'visible' | 'collapsed' | 'hidden', ): void { // This is a fork of renderChildrenArray that's aware of tracking rows. const prevKeyPath = task.keyPath; @@ -1915,10 +1972,227 @@ function renderSuspenseListRows( } } else { task = ((task: any): RenderTask); // Refined - if ( + + const parentSegment = task.blockedSegment; + + const forwards = revealOrder !== 'backwards' && - revealOrder !== 'unstable_legacy-backwards' - ) { + revealOrder !== 'unstable_legacy-backwards'; + + if (tailMode !== 'visible') { + // For hidden tails, we need to create an instance to keep track of adding rows to + // the end. For visible tails, there's no need to represent the list itself. + const suspenseList = createSuspenseList(forwards); + pushStartSuspenseListBoundary(parentSegment.chunks, request.renderState); + + const prevContext = task.formatContext; + const parentBoundary = task.blockedBoundary; + const parentPreamble = task.blockedPreamble; + const parentHoistableState = task.hoistableState; + + const defer = false; // TODO: Should we have an option to defer every row? + const abortSet: Set = new Set(); // There is never any fallbacks to abort but we could abort content rows. + + // This doesn't vary by row so we can apply it only once. + task.formatContext = getSuspenseContentFormatContext( + request.resumableState, + task.formatContext, + ); + + // We then need to create one segment for each row. Each row gets its own implicit + // SuspenseBoundary so that we can hide it. Each new row is nested recursively into + // the next boundary so that they can only complete in order. + let previousRowBoundary: null | SuspenseBoundary = null; + let previousSegment = parentSegment; + + for (let n = 0; n < totalChildren; n++) { + const i = + revealOrder === 'unstable_legacy-backwards' + ? totalChildren - 1 - n + : n; + const node = rows[i]; + const suspenseListRow = createSuspenseListRow(previousSuspenseListRow); + if (tailMode === 'collapsed') { + if (previousSuspenseListRow === null && totalChildren > 1) { + // For collapsed mode, we'll block the first row from completing until the second row's + // loading state has completed. But only if there is a second row. + suspenseListRow.boundaries = []; + } + } else { + // Hidden mode effectively acts as a together row because we'll block it on its own boundary. + suspenseListRow.mode = TOGETHER; + if (previousSuspenseListRow === null) { + // If this is the first row, then it won't be blocked by any previous rows but + // for hidden mode, it'll be blocked by its own boundary. + suspenseListRow.boundaries = []; + } + } + + task.row = suspenseListRow; + task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i); + + const rowBoundary = createSuspenseBoundary( + request, + suspenseList, + tailMode === 'collapsed' && previousSuspenseListRow === null + ? null + : suspenseListRow, + abortSet, + canHavePreamble(prevContext) ? createPreamble() : null, + defer, + ); + + if (tailMode === 'collapsed' && previousSuspenseListRow !== null) { + previousSuspenseListRow.pendingTasks++; + rowBoundary.preceedingRow = previousSuspenseListRow; + } + + // In backwards mode, the next boundary segment is added before the content of the row. + // The first one is added to the end of the parent segment. + const insertionIndex = + forwards || n === 0 ? previousSegment.chunks.length : 0; + const boundarySegment = createPendingSegment( + request, + insertionIndex, + rowBoundary, + prevContext, + true, // Text embedding don't matter since this will never have any content + true, + ); + // The implicit boundary is immediately completed since it doesn't have any fallback content. + boundarySegment.status = COMPLETED; + if (forwards || n === 0) { + previousSegment.children.push(boundarySegment); + } else { + previousSegment.children.splice(0, 0, boundarySegment); + } + + if (previousRowBoundary !== null) { + // Once we've added the next boundary, we can finish the previous segment. + finishedSegment(request, previousRowBoundary, previousSegment); + queueCompletedSegment(previousRowBoundary, previousSegment); + } + if (previousSuspenseListRow !== null) { + // Unblock the initial task on the previous row itself. We do this after we have had a + // chance to add the next row as a blocker for the previous row. + if (--previousSuspenseListRow.pendingTasks === 0) { + finishSuspenseListRow(request, previousSuspenseListRow); + } + } + + const isTopSegment = forwards ? n === 0 : n === totalChildren - 1; + const rowSegment = createPendingSegment( + request, + 0, + null, + task.formatContext, + // The top segment is going to get emitted right after last pushed thing to the parent. + // For any other row, the one above it will assume it's text embedded and so will finish + // with a comment. Therefore we can assume that we start as not having pushed text. + isTopSegment ? parentSegment.lastPushedText : false, + // Every child might end up being text embedded depending on what the previous one does. + // Even the last one might end up text embedded if all the others render null and then + // there's text after the List. + true, + ); + // We mark the row segment as having its parent flushed. It's not really flushed but there is + // no parent segment so there's nothing to wait on. + rowSegment.parentFlushed = true; + + const blockedBoundary = + tailMode === 'collapsed' && previousSuspenseListRow === null + ? // The first row of a collapsed blocks the outer parent (or shell) from completing. + // We need to at least finish a loading state. + parentBoundary + : // For hidden, the blocked boundary is the row's implicit boundary itself. + rowBoundary; + if (blockedBoundary !== null) { + task.blockedBoundary = blockedBoundary; + task.blockedPreamble = + blockedBoundary.preamble === null + ? null + : blockedBoundary.preamble.content; + task.hoistableState = blockedBoundary.contentState; + } + task.blockedSegment = rowSegment; + rowSegment.status = RENDERING; + try { + if (__DEV__) { + warnForMissingKey(request, task, node); + } + renderNode(request, task, node, i); + pushSegmentFinale( + rowSegment.chunks, + request.renderState, + rowSegment.lastPushedText, + rowSegment.textEmbedded, + ); + rowSegment.status = COMPLETED; + if ( + rowBoundary.pendingTasks === 0 && + rowBoundary.status === PENDING + ) { + // This must have been the last segment we were waiting on. This boundary is now complete. + rowBoundary.status = COMPLETED; + if (!isEligibleForOutlining(request, rowBoundary)) { + // 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. + // Unblock the task for the implicit boundary. It will still be blocked by the row itself. + suspenseListRow.pendingTasks--; + } + } else { + const preceedingRow = rowBoundary.preceedingRow; + if (preceedingRow !== null && rowBoundary.pendingTasks === 1) { + // We're blocking a preceeding row on completing this boundary. We only have one task left. + // That must be the preceeding row that blocks us from showing. We can now unblock the preceeding row. + rowBoundary.preceedingRow = null; + if (--preceedingRow.pendingTasks === 0) { + finishSuspenseListRow(request, preceedingRow); + } + } + } + } catch (thrownValue: mixed) { + task.blockedSegment = parentSegment; + task.blockedPreamble = parentPreamble; + task.keyPath = prevKeyPath; + task.formatContext = prevContext; + if (request.status === ABORTING) { + rowSegment.status = ABORTED; + } else { + rowSegment.status = ERRORED; + } + throw thrownValue; + } + + // We nest each row into the previous row boundary's content. + previousSegment = rowSegment; + previousRowBoundary = rowBoundary; + previousSuspenseListRow = suspenseListRow; + } + if (previousRowBoundary !== null) { + // Once we've added the next boundary, we can finish the previous segment. + finishedSegment(request, previousRowBoundary, previousSegment); + queueCompletedSegment(previousRowBoundary, previousSegment); + } + if (previousSuspenseListRow !== null) { + // Unblock the initial task on the previous row itself. We do this after we have had a + // chance to add the next row as a blocker for the previous row. + if (--previousSuspenseListRow.pendingTasks === 0) { + finishSuspenseListRow(request, previousSuspenseListRow); + } + } + task.blockedBoundary = parentBoundary; + task.blockedPreamble = parentPreamble; + task.hoistableState = parentHoistableState; + task.blockedSegment = parentSegment; + task.formatContext = prevContext; + + // Reset lastPushedText for current Segment since the new Segments "consumed" it + parentSegment.lastPushedText = false; + + pushEndSuspenseListBoundary(parentSegment.chunks, request.renderState); + } else if (forwards) { // Forwards direction for (let i = 0; i < totalChildren; i++) { const node = rows[i]; @@ -1938,7 +2212,6 @@ function renderSuspenseListRows( // 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; for (let n = 0; n < totalChildren; n++) { @@ -1956,8 +2229,13 @@ function renderSuspenseListRows( insertionIndex, null, task.formatContext, - // Assume we are text embedded at the trailing edges - i === 0 ? parentSegment.lastPushedText : true, + // The last segment is going to get emitted right after last pushed thing to the parent. + // For any other row, the one above it will assume it's text embedded and so will finish + // with a comment. Therefore we can assume that we start as not having pushed text. + i === totalChildren - 1 ? parentSegment.lastPushedText : false, + // Every child might end up being text embedded depending on what the previous one does. + // Even the last one might end up text embedded if all the others render null and then + // there's text after the List. true, ); // Insert in the beginning of the sequence, which will insert before any previous rows. @@ -2024,12 +2302,18 @@ function renderSuspenseList( ): void { const children: any = props.children; const revealOrder: SuspenseListRevealOrder = props.revealOrder; - // TODO: Support tail hidden/collapsed modes. - // const tailMode: SuspenseListTailMode = props.tail; if (revealOrder !== 'independent' && revealOrder !== 'together') { // For ordered reveal, we need to produce rows from the children. + const tailMode: SuspenseListTailMode = props.tail; if (isArray(children)) { - renderSuspenseListRows(request, task, keyPath, children, revealOrder); + renderSuspenseListRows( + request, + task, + keyPath, + children, + revealOrder, + tailMode, + ); return; } const iteratorFn = getIteratorFn(children); @@ -2050,7 +2334,14 @@ function renderSuspenseList( rows.push(step.value); step = iterator.next(); } while (!step.done); - renderSuspenseListRows(request, task, keyPath, children, revealOrder); + renderSuspenseListRows( + request, + task, + keyPath, + children, + revealOrder, + tailMode, + ); } return; } @@ -2108,7 +2399,14 @@ function renderSuspenseList( step = unwrapThenable(iterator.next()); } } - renderSuspenseListRows(request, task, keyPath, rows, revealOrder); + renderSuspenseListRows( + request, + task, + keyPath, + rows, + revealOrder, + tailMode, + ); return; } } @@ -2122,7 +2420,7 @@ function renderSuspenseList( // This will cause boundaries to block on this row, but there's nothing to // unblock them. We'll use the partial flushing pass to unblock them. newRow.boundaries = []; - newRow.together = true; + newRow.mode = TOGETHER; task.keyPath = keyPath; renderNodeDestructive(request, task, children, -1); if (--newRow.pendingTasks === 0) { @@ -4401,6 +4699,7 @@ function abortRemainingSuspenseBoundary( const resumedBoundary = createSuspenseBoundary( request, null, + null, new Set(), null, false, @@ -4813,7 +5112,7 @@ function finishedTask( if (row !== null) { if (--row.pendingTasks === 0) { finishSuspenseListRow(request, row); - } else if (row.together) { + } else if (row.mode === TOGETHER) { tryToResolveTogetherRow(request, row); } } @@ -4928,9 +5227,22 @@ function finishedTask( } } const boundaryRow = boundary.row; - if (boundaryRow !== null && boundaryRow.together) { + if (boundaryRow !== null && boundaryRow.mode === TOGETHER) { tryToResolveTogetherRow(request, boundaryRow); } + const preceedingRow = boundary.preceedingRow; + if (preceedingRow !== null && boundary.pendingTasks === 1) { + // We're blocking a preceeding row on completing this boundary. We only have one task left. + // That must be the preceeding row that blocks us from showing. We can now unblock the preceeding row. + boundary.preceedingRow = null; + if (preceedingRow.pendingTasks === 1) { + unblockSuspenseListRow( + request, + preceedingRow, + preceedingRow.hoistables, + ); + } + } } } @@ -5438,6 +5750,7 @@ function flushSegment( // we don't want to reflush the boundary segment.boundary = null; boundary.parentFlushed = true; + // This segment is a Suspense boundary. We need to decide whether to // emit the content or the fallback now. if (boundary.status === CLIENT_RENDERED) { @@ -5682,7 +5995,7 @@ function flushPartialBoundary( completedSegments.splice(0, i); const row = boundary.row; - if (row !== null && row.together && boundary.pendingTasks === 1) { + if (row !== null && row.mode === TOGETHER && boundary.pendingTasks === 1) { // "together" rows are blocked on their own boundaries. // We have now flushed all the boundary's segments as partials. // We can now unblock it from blocking the row that will eventually diff --git a/packages/react-server/src/forks/ReactFizzConfig.custom.js b/packages/react-server/src/forks/ReactFizzConfig.custom.js index aa8ea94b57917..83c42d801e6b8 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.custom.js +++ b/packages/react-server/src/forks/ReactFizzConfig.custom.js @@ -67,6 +67,10 @@ export const writeCompletedRoot = $$$config.writeCompletedRoot; export const writePlaceholder = $$$config.writePlaceholder; export const pushStartActivityBoundary = $$$config.pushStartActivityBoundary; export const pushEndActivityBoundary = $$$config.pushEndActivityBoundary; +export const pushStartSuspenseListBoundary = + $$$config.pushStartSuspenseListBoundary; +export const pushEndSuspenseListBoundary = + $$$config.pushEndSuspenseListBoundary; export const writeStartCompletedSuspenseBoundary = $$$config.writeStartCompletedSuspenseBoundary; export const writeStartPendingSuspenseBoundary =