From 174cd30047601bf450dc2387129028e7f5cf4c16 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 3 Nov 2025 09:14:35 -0500 Subject: [PATCH 1/9] Test hidden --- .../ReactDOMFizzSuspenseList-test.js | 129 ++++++++++++++++-- 1 file changed, 119 insertions(+), 10 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js index 0e2fff5cd4510..91d8407e8a57f 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,121 @@ 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 +
, + ); + }); }); From 186c76d359aaef877ccd879a2c8965c3bca79ae8 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 6 Nov 2025 23:41:25 -0500 Subject: [PATCH 2/9] Add SuspenseList type This will be used to keep track of a list that need rows added to it. It's also used as a marker on the SuspenseBoundary if the boundary is an implicit boundary added around each row in a tail hidden/collapsed. --- packages/react-server/src/ReactFizzServer.js | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 4ad48d79fba4f..8904dfe4f8e35 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -234,6 +234,14 @@ 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 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) @@ -250,6 +258,7 @@ 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. completedSegments: Array, // completed but not yet flushed segments. byteSize: number, // used to determine whether to inline children boundaries. @@ -795,6 +804,7 @@ function pingTask(request: Request, task: Task): void { function createSuspenseBoundary( request: Request, + list: null | SuspenseList, row: null | SuspenseListRow, fallbackAbortableTasks: Set, preamble: null | Preamble, @@ -805,6 +815,7 @@ function createSuspenseBoundary( rootSegmentID: -1, parentFlushed: false, pendingTasks: 0, + list: null, row: row, completedSegments: [], byteSize: 0, @@ -1295,6 +1306,7 @@ function renderSuspenseBoundary( const fallbackAbortSet: Set = new Set(); const newBoundary = createSuspenseBoundary( request, + null, task.row, fallbackAbortSet, canHavePreamble(task.formatContext) ? createPreamble() : null, @@ -1596,6 +1608,7 @@ function replaySuspenseBoundary( const fallbackAbortSet: Set = new Set(); const resumedBoundary = createSuspenseBoundary( request, + null, task.row, fallbackAbortSet, canHavePreamble(task.formatContext) ? createPreamble() : null, @@ -1811,6 +1824,14 @@ function tryToResolveTogetherRow( } } +function createSuspenseList(mode: SuspenseListRevealOrder): SuspenseList { + return { + id: -1, + forwards: mode !== 'backwards' && mode !== 'unstable_legacy-backwards', + completedRows: 0, + }; +} + function createSuspenseListRow( previousRow: null | SuspenseListRow, ): SuspenseListRow { @@ -4401,6 +4422,7 @@ function abortRemainingSuspenseBoundary( const resumedBoundary = createSuspenseBoundary( request, null, + null, new Set(), null, false, From 3a52d2133efaec12c937e533d91b7843753ffe11 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 7 Nov 2025 13:57:56 -0500 Subject: [PATCH 3/9] Add marker around SuspenseList instance This will allow us to skip past a SuspenseList's rows when hydrating it if we don't yet have all the rows satisfied. --- .../src/server/ReactFizzConfigDOM.js | 18 ++++++++++++++ .../src/server/ReactFizzConfigDOMLegacy.js | 24 +++++++++++++++++++ .../react-markup/src/ReactFizzConfigMarkup.js | 15 ++++++++++++ .../src/ReactNoopServer.js | 21 ++++++++++++++++ packages/react-server/src/ReactFizzServer.js | 2 ++ .../src/forks/ReactFizzConfig.custom.js | 4 ++++ 6 files changed, 84 insertions(+) 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-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 8904dfe4f8e35..f14c8c3fed03e 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -61,6 +61,8 @@ import { writePlaceholder, pushStartActivityBoundary, pushEndActivityBoundary, + pushStartSuspenseListBoundary, + pushEndSuspenseListBoundary, writeStartCompletedSuspenseBoundary, writeStartPendingSuspenseBoundary, writeStartClientRenderedSuspenseBoundary, 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 = From 181dacb8788db08364cb6ec7397a4a0db99cdba1 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 7 Nov 2025 15:11:30 -0500 Subject: [PATCH 4/9] Create a SuspenseList instance and emit start/end markers for lists with potentially hidden rows --- packages/react-server/src/ReactFizzServer.js | 61 ++++++++++++++++---- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index f14c8c3fed03e..908f1c4be1f48 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 { @@ -1826,10 +1827,10 @@ function tryToResolveTogetherRow( } } -function createSuspenseList(mode: SuspenseListRevealOrder): SuspenseList { +function createSuspenseList(forwards: boolean): SuspenseList { return { id: -1, - forwards: mode !== 'backwards' && mode !== 'unstable_legacy-backwards', + forwards: forwards, completedRows: 0, }; } @@ -1861,6 +1862,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; @@ -1938,10 +1940,22 @@ 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'; + + let suspenseList: null | SuspenseList = null; + 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. + suspenseList = createSuspenseList(forwards); + pushStartSuspenseListBoundary(parentSegment.chunks, request.renderState); + } + + if (forwards) { // Forwards direction for (let i = 0; i < totalChildren; i++) { const node = rows[i]; @@ -1961,7 +1975,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++) { @@ -2015,6 +2028,10 @@ function renderSuspenseListRows( // Reset lastPushedText for current Segment since the new Segments "consumed" it parentSegment.lastPushedText = false; } + + if (suspenseList !== null) { + pushEndSuspenseListBoundary(parentSegment.chunks, request.renderState); + } } if ( @@ -2047,12 +2064,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); @@ -2073,7 +2096,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; } @@ -2131,7 +2161,14 @@ function renderSuspenseList( step = unwrapThenable(iterator.next()); } } - renderSuspenseListRows(request, task, keyPath, rows, revealOrder); + renderSuspenseListRows( + request, + task, + keyPath, + rows, + revealOrder, + tailMode, + ); return; } } From dc07789eb2a9daa0bc2e59fe1a4f10cf20256847 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 9 Nov 2025 14:36:06 -0500 Subject: [PATCH 5/9] Create a segment and implicit boundary for each row --- packages/react-server/src/ReactFizzServer.js | 194 ++++++++++++++++++- 1 file changed, 185 insertions(+), 9 deletions(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 908f1c4be1f48..e04b4728cf147 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -1746,6 +1746,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. @@ -1758,13 +1767,24 @@ 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.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.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); } } @@ -1823,6 +1843,8 @@ 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); } } @@ -1947,15 +1969,172 @@ function renderSuspenseListRows( revealOrder !== 'backwards' && revealOrder !== 'unstable_legacy-backwards'; - let suspenseList: null | SuspenseList = null; 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. - suspenseList = createSuspenseList(forwards); + const suspenseList = createSuspenseList(forwards); pushStartSuspenseListBoundary(parentSegment.chunks, request.renderState); - } - if (forwards) { + 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); + // This effectively acts as a together row because we'll block it on its own boundary. + // TODO: For "collapsed" this should be different. + suspenseListRow.together = true; + 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. + // TODO: For "collapsed" this should be different. + suspenseListRow.boundaries = []; + } + + task.row = suspenseListRow; + task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i); + + const rowBoundary = createSuspenseBoundary( + request, + suspenseList, + suspenseListRow, // For "hidden" the boundary blocks itself from completing. TODO: For "collapsed" it's different. + abortSet, + canHavePreamble(prevContext) ? createPreamble() : null, + defer, + ); + rowBoundary.rootSegmentID = request.nextSegmentId++; + + // 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, + // Assume we are text embedded at the trailing edges + i === 0 ? previousSegment.lastPushedText : true, + 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); + } + + const rowSegment = createPendingSegment( + request, + 0, + null, + task.formatContext, + // Assume we are text embedded at the trailing edges + i === 0 ? previousSegment.lastPushedText : true, + 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; + + task.blockedBoundary = rowBoundary; + task.blockedPreamble = + rowBoundary.preamble === null ? null : rowBoundary.preamble.content; + task.hoistableState = rowBoundary.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. + if (--suspenseListRow.pendingTasks === 0) { + finishSuspenseListRow(request, suspenseListRow); + } + } + } + // Unblock the initial task on the row itself. + if (--suspenseListRow.pendingTasks === 0) { + finishSuspenseListRow(request, suspenseListRow); + } + } 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); + } + 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]; @@ -2028,10 +2207,6 @@ function renderSuspenseListRows( // Reset lastPushedText for current Segment since the new Segments "consumed" it parentSegment.lastPushedText = false; } - - if (suspenseList !== null) { - pushEndSuspenseListBoundary(parentSegment.chunks, request.renderState); - } } if ( @@ -5499,6 +5674,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) { From fa54bb6b974d3864759e6e15903245a9bafc326b Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 19 Nov 2025 17:15:21 -0500 Subject: [PATCH 6/9] Ensure we add the right amount of text separators Assuming we didn't emit comments around every row. --- .../ReactDOMFizzSuspenseList-test.js | 77 +++++++++++++++++++ packages/react-server/src/ReactFizzServer.js | 22 ++++-- 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js index 91d8407e8a57f..1bc83e95c0c0e 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js @@ -1095,4 +1095,81 @@ describe('ReactDOMFizzSuspenseList', () => {
, ); }); + + 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-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index e04b4728cf147..7b8581346e7a3 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -2033,8 +2033,7 @@ function renderSuspenseListRows( insertionIndex, rowBoundary, prevContext, - // Assume we are text embedded at the trailing edges - i === 0 ? previousSegment.lastPushedText : true, + 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. @@ -2051,13 +2050,19 @@ function renderSuspenseListRows( queueCompletedSegment(previousRowBoundary, previousSegment); } + const isTopSegment = forwards ? n === 0 : n === totalChildren - 1; const rowSegment = createPendingSegment( request, 0, null, task.formatContext, - // Assume we are text embedded at the trailing edges - i === 0 ? previousSegment.lastPushedText : true, + // 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 @@ -2171,8 +2176,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. From bb43869e008c4562cb61487e2da8d1d57a468f9a Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 19 Nov 2025 17:35:45 -0500 Subject: [PATCH 7/9] Add backwards test --- .../ReactDOMFizzSuspenseList-test.js | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js index 1bc83e95c0c0e..150d94a4c6d99 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js @@ -1096,6 +1096,123 @@ describe('ReactDOMFizzSuspenseList', () => { ); }); + // @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 +
, + ); + }); + it('inserts text separators (comments) for text nodes (forwards)', async () => { function Foo() { return ( From e4b0b790a75c44b0a060e90c6201c324979e56c1 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 20 Nov 2025 18:12:31 -0500 Subject: [PATCH 8/9] Express together as enum so that we can express more modes --- packages/react-server/src/ReactFizzServer.js | 28 +++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 7b8581346e7a3..68228b17b2ec8 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -245,12 +245,17 @@ type SuspenseList = { 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. }; @@ -1502,7 +1507,7 @@ function renderSuspenseBoundary( } } else { const boundaryRow = prevRow; - if (boundaryRow !== null && boundaryRow.together) { + if (boundaryRow !== null && boundaryRow.mode === TOGETHER) { tryToResolveTogetherRow(request, boundaryRow); } } @@ -1769,7 +1774,7 @@ function unblockSuspenseListRow( const unblockedBoundaries = unblockedRow.boundaries; if ( unblockedBoundaries !== null && - (!unblockedRow.together || + (unblockedRow.mode !== TOGETHER || // Together rows are blocked on themselves. This is the last one, which will // unblock the row itself. unblockedRow.pendingTasks === 1) @@ -1780,7 +1785,10 @@ function unblockSuspenseListRow( if (inheritedHoistables !== null) { hoistHoistables(unblockedBoundary.contentState, inheritedHoistables); } - if (unblockedRow.together && unblockedBoundary.pendingTasks === 1) { + 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++; @@ -1865,7 +1873,7 @@ function createSuspenseListRow( boundaries: null, hoistables: createHoistableState(), inheritedHoistables: null, - together: false, + mode: INDEPENDENT, next: null, }; if (previousRow !== null && previousRow.pendingTasks > 0) { @@ -2003,7 +2011,7 @@ function renderSuspenseListRows( const suspenseListRow = createSuspenseListRow(previousSuspenseListRow); // This effectively acts as a together row because we'll block it on its own boundary. // TODO: For "collapsed" this should be different. - suspenseListRow.together = true; + 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. @@ -2367,7 +2375,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) { @@ -5059,7 +5067,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); } } @@ -5174,7 +5182,7 @@ function finishedTask( } } const boundaryRow = boundary.row; - if (boundaryRow !== null && boundaryRow.together) { + if (boundaryRow !== null && boundaryRow.mode === TOGETHER) { tryToResolveTogetherRow(request, boundaryRow); } } @@ -5929,7 +5937,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 From a811d2bb2a05b6fcb335c4fa90792ec5cb85209f Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 21 Nov 2025 18:25:07 -0500 Subject: [PATCH 9/9] Support collapsed mode The complex part about this mode is that we can't release the previous row until we have a loading state for the next row. So we make the implicit boundary of the next row block the preceeding row. But since that also blocks the next implicit boundary, there's a cycle that needs special handling. --- .../ReactDOMFizzSuspenseList-test.js | 295 ++++++++++++++++++ packages/react-server/src/ReactFizzServer.js | 98 ++++-- 2 files changed, 373 insertions(+), 20 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js index 150d94a4c6d99..15a2b5bacb414 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js @@ -1213,6 +1213,301 @@ describe('ReactDOMFizzSuspenseList', () => { ); }); + // @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 ( diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 68228b17b2ec8..6bf45d9c6dd01 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -268,6 +268,7 @@ type SuspenseBoundary = { 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 @@ -825,6 +826,7 @@ function createSuspenseBoundary( pendingTasks: 0, list: null, row: row, + preceedingRow: null, completedSegments: [], byteSize: 0, defer: defer, @@ -2002,6 +2004,7 @@ function renderSuspenseListRows( // 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' @@ -2009,14 +2012,20 @@ function renderSuspenseListRows( : n; const node = rows[i]; const suspenseListRow = createSuspenseListRow(previousSuspenseListRow); - // This effectively acts as a together row because we'll block it on its own boundary. - // TODO: For "collapsed" this should be different. - 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. - // TODO: For "collapsed" this should be different. - suspenseListRow.boundaries = []; + 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; @@ -2025,12 +2034,18 @@ function renderSuspenseListRows( const rowBoundary = createSuspenseBoundary( request, suspenseList, - suspenseListRow, // For "hidden" the boundary blocks itself from completing. TODO: For "collapsed" it's different. + tailMode === 'collapsed' && previousSuspenseListRow === null + ? null + : suspenseListRow, abortSet, canHavePreamble(prevContext) ? createPreamble() : null, defer, ); - rowBoundary.rootSegmentID = request.nextSegmentId++; + + 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. @@ -2057,6 +2072,13 @@ function renderSuspenseListRows( 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( @@ -2077,10 +2099,21 @@ function renderSuspenseListRows( // no parent segment so there's nothing to wait on. rowSegment.parentFlushed = true; - task.blockedBoundary = rowBoundary; - task.blockedPreamble = - rowBoundary.preamble === null ? null : rowBoundary.preamble.content; - task.hoistableState = rowBoundary.contentState; + 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 { @@ -2106,15 +2139,19 @@ function renderSuspenseListRows( // 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. - if (--suspenseListRow.pendingTasks === 0) { - finishSuspenseListRow(request, suspenseListRow); + 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); } } } - // Unblock the initial task on the row itself. - if (--suspenseListRow.pendingTasks === 0) { - finishSuspenseListRow(request, suspenseListRow); - } } catch (thrownValue: mixed) { task.blockedSegment = parentSegment; task.blockedPreamble = parentPreamble; @@ -2127,6 +2164,7 @@ function renderSuspenseListRows( } throw thrownValue; } + // We nest each row into the previous row boundary's content. previousSegment = rowSegment; previousRowBoundary = rowBoundary; @@ -2137,6 +2175,13 @@ function renderSuspenseListRows( 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; @@ -5185,6 +5230,19 @@ function finishedTask( 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, + ); + } + } } }
+ + }> + + + }> + + + }> + + + +