From 5be73c32a11f05a084035eee5854f3eac26b58e2 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 3 Nov 2025 09:14:35 -0500 Subject: [PATCH 01/18] 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 0e2fff5cd45..91d8407e8a5 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 e585362a647fe0373a85ba2c1b0855a9aa06baf4 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 6 Nov 2025 23:41:25 -0500 Subject: [PATCH 02/18] 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 4ad48d79fba..8904dfe4f8e 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 c62f0dfb41905bf5893020fc7db733b7b0f1bf4b Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 7 Nov 2025 13:57:56 -0500 Subject: [PATCH 03/18] 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 a93c32a947f..c02c00a13c6 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 d48e9a8dd93..376c5c07bc8 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 7dbe5592f33..6fc10b27a20 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 1793180cc76..e492e9c2164 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 8904dfe4f8e..f14c8c3fed0 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 aa8ea94b579..83c42d801e6 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 84b43ccb7c2276cb81d4f8db79ede0f926476918 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 7 Nov 2025 15:11:30 -0500 Subject: [PATCH 04/18] 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 f14c8c3fed0..908f1c4be1f 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 ff10be64c0cea4cbde6e672db63de8e2181c07b5 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 9 Nov 2025 14:36:06 -0500 Subject: [PATCH 05/18] 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 908f1c4be1f..e04b4728cf1 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 96617e585c0620ab1ca6a95d42692bc910afb845 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 19 Nov 2025 17:15:21 -0500 Subject: [PATCH 06/18] 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 91d8407e8a5..1bc83e95c0c 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 e04b4728cf1..7b8581346e7 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 80d4e323bb010d5b55dba07a9e097e3c52003835 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 19 Nov 2025 17:35:45 -0500 Subject: [PATCH 07/18] 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 1bc83e95c0c..150d94a4c6d 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 c93e25a38052af57d16d156ae06925b79988dfea Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 20 Nov 2025 18:12:31 -0500 Subject: [PATCH 08/18] 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 7b8581346e7..68228b17b2e 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 317389d230d186a8414bf38096d45d20a49e2f8d Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 21 Nov 2025 18:25:07 -0500 Subject: [PATCH 09/18] 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 150d94a4c6d..15a2b5bacb4 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 68228b17b2e..6bf45d9c6dd 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, + ); + } + } } } From f518688dc5f6a7d19c96d4c91db0ca2b093dc742 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 15 Jan 2026 13:30:20 -0500 Subject: [PATCH 10/18] Gate the implementation since it's still not complete --- .../ReactDOMFizzSuspenseList-test.js | 44 ++++++++++++------- packages/react-server/src/ReactFizzServer.js | 3 +- packages/shared/ReactFeatureFlags.js | 2 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + ...actFeatureFlags.test-renderer.native-fb.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 9 files changed, 39 insertions(+), 16 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js index 15a2b5bacb4..7613ee90763 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js @@ -133,7 +133,7 @@ describe('ReactDOMFizzSuspenseList', () => { return Component; } - // @gate enableSuspenseList + // @gate enableSuspenseList && enableFizzSuspenseListTail it('shows content forwards but hidden tail by default', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -979,7 +979,7 @@ describe('ReactDOMFizzSuspenseList', () => { expect(hasCompleted).toBe(true); }); - // @gate enableSuspenseList + // @gate enableSuspenseList && enableFizzSuspenseListTail it('can stream in "forwards" with tail "hidden" with boundaries', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -1042,7 +1042,7 @@ describe('ReactDOMFizzSuspenseList', () => { ); }); - // @gate enableSuspenseList + // @gate enableSuspenseList && enableFizzSuspenseListTail it('can stream in "forwards" with tail "hidden" without boundaries', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -1096,7 +1096,7 @@ describe('ReactDOMFizzSuspenseList', () => { ); }); - // @gate enableSuspenseList + // @gate enableSuspenseList && enableFizzSuspenseListTail it('can stream in "backwards" with tail "hidden" with boundaries', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -1159,7 +1159,7 @@ describe('ReactDOMFizzSuspenseList', () => { ); }); - // @gate enableSuspenseList + // @gate enableSuspenseList && enableFizzSuspenseListTail it('can stream in "backwards" with tail "hidden" without boundaries', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -1213,7 +1213,7 @@ describe('ReactDOMFizzSuspenseList', () => { ); }); - // @gate enableSuspenseList + // @gate enableSuspenseList && enableFizzSuspenseListTail it('can stream in "forwards" with tail "collapsed"', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -1301,7 +1301,7 @@ describe('ReactDOMFizzSuspenseList', () => { ); }); - // @gate enableSuspenseList + // @gate enableSuspenseList && enableFizzSuspenseListTail it('can stream in "backwards" with tail "collapsed"', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -1439,7 +1439,7 @@ describe('ReactDOMFizzSuspenseList', () => { ); }); - // @gate enableSuspenseList + // @gate enableSuspenseList && enableFizzSuspenseListTail 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 @@ -1525,9 +1525,15 @@ describe('ReactDOMFizzSuspenseList', () => { 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; + const boundaryComments = gate(flags => flags.enableFizzSuspenseListTail) + ? 2 * textNodes // TODO: One we remove the comments around boundaries this should be zero. + : 0; + const textSeparators = gate(flags => flags.enableFizzSuspenseListTail) + ? textNodes // One after each node. + : textNodes - 1; // One between each node + const suspenseListComments = gate(flags => flags.enableFizzSuspenseListTail) + ? 2 + : 0; expect(container.firstChild.childNodes.length).toBe( textNodes + textSeparators + boundaryComments + suspenseListComments, ); @@ -1550,9 +1556,13 @@ describe('ReactDOMFizzSuspenseList', () => { 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 boundaryComments = gate(flags => flags.enableFizzSuspenseListTail) + ? 2 * textNodes // TODO: One we remove the comments around boundaries this should be zero. + : 0; const textSeparators = textNodes; // One after each node. - const suspenseListComments = 2; + const suspenseListComments = gate(flags => flags.enableFizzSuspenseListTail) + ? 2 + : 0; expect(container.firstChild.childNodes.length).toBe( textNodes + textSeparators + boundaryComments + suspenseListComments, ); @@ -1577,9 +1587,13 @@ describe('ReactDOMFizzSuspenseList', () => { 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 boundaryComments = gate(flags => flags.enableFizzSuspenseListTail) + ? 2 * textNodes // TODO: One we remove the comments around boundaries this should be zero. + : 0; const textSeparators = textNodes; // One after each node. - const suspenseListComments = 2; + const suspenseListComments = gate(flags => flags.enableFizzSuspenseListTail) + ? 2 + : 0; 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 6bf45d9c6dd..4a2aef49129 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -185,6 +185,7 @@ import { enableAsyncIterableChildren, enableViewTransition, enableFizzBlockingRender, + enableFizzSuspenseListTail, enableAsyncDebugInfo, enableCPUSuspense, } from 'shared/ReactFeatureFlags'; @@ -1979,7 +1980,7 @@ function renderSuspenseListRows( revealOrder !== 'backwards' && revealOrder !== 'unstable_legacy-backwards'; - if (tailMode !== 'visible') { + if (enableFizzSuspenseListTail && 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); diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index ebb287568af..ee144ca36df 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -92,6 +92,8 @@ export const enableSuspenseyImages: boolean = false; export const enableFizzBlockingRender = __EXPERIMENTAL__; // rel="expect" +export const enableFizzSuspenseListTail: boolean = false; + export const enableSrcObject = __EXPERIMENTAL__; export const enableHydrationChangeEvent = __EXPERIMENTAL__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index d9a91f8a808..3eab844f48a 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -76,6 +76,7 @@ export const enableGestureTransition: boolean = false; export const enableScrollEndPolyfill: boolean = true; export const enableSuspenseyImages: boolean = false; export const enableFizzBlockingRender: boolean = true; +export const enableFizzSuspenseListTail: boolean = false; export const enableSrcObject: boolean = false; export const enableHydrationChangeEvent: boolean = true; export const enableDefaultTransitionIndicator: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index fa8f336c03f..64ae1eb75ec 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -67,6 +67,7 @@ export const enableGestureTransition: boolean = false; export const enableScrollEndPolyfill: boolean = true; export const enableSuspenseyImages: boolean = false; export const enableFizzBlockingRender: boolean = true; +export const enableFizzSuspenseListTail: boolean = false; export const enableSrcObject: boolean = false; export const enableHydrationChangeEvent: boolean = false; export const enableDefaultTransitionIndicator: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index acf3847bd06..b78c9fdebf3 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -68,6 +68,7 @@ export const enableGestureTransition: boolean = false; export const enableScrollEndPolyfill: boolean = true; export const enableSuspenseyImages: boolean = false; export const enableFizzBlockingRender: boolean = true; +export const enableFizzSuspenseListTail: boolean = false; export const enableSrcObject: boolean = false; export const enableHydrationChangeEvent: boolean = false; export const enableDefaultTransitionIndicator: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 5d3a5513018..1920d8c521f 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -63,6 +63,7 @@ export const enableGestureTransition = false; export const enableScrollEndPolyfill = true; export const enableSuspenseyImages = false; export const enableFizzBlockingRender = true; +export const enableFizzSuspenseListTail = false; export const enableSrcObject = false; export const enableHydrationChangeEvent = false; export const enableDefaultTransitionIndicator = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 553be202c45..32700e4b59d 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -76,6 +76,7 @@ export const enableGestureTransition: boolean = false; export const enableScrollEndPolyfill: boolean = true; export const enableSuspenseyImages: boolean = false; export const enableFizzBlockingRender: boolean = true; +export const enableFizzSuspenseListTail: boolean = false; export const enableSrcObject: boolean = false; export const enableHydrationChangeEvent: boolean = false; export const enableDefaultTransitionIndicator: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 87801a9658f..9d529bc1c48 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -106,6 +106,7 @@ export const enableGestureTransition: boolean = false; export const enableSuspenseyImages: boolean = false; export const enableFizzBlockingRender: boolean = true; +export const enableFizzSuspenseListTail: boolean = false; export const enableSrcObject: boolean = false; export const enableHydrationChangeEvent: boolean = false; export const enableDefaultTransitionIndicator: boolean = true; From 1715b70ee1e1c305c48cd3c0e5a788e02595f37f Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 15 Jan 2026 17:05:26 -0500 Subject: [PATCH 11/18] Text comment nodes are now omitted for the last row --- packages/react-dom/src/__tests__/ReactDOMUseId-test.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMUseId-test.js b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js index 08c48d5f0d7..e5ac22dabb5 100644 --- a/packages/react-dom/src/__tests__/ReactDOMUseId-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js @@ -601,7 +601,6 @@ describe('useId', () => { > A -
`); @@ -618,7 +617,6 @@ describe('useId', () => { > A -
`); }); From 19f96f50f8c41a5f5c35fff8c02d6fbe3203d654 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 15 Jan 2026 20:50:57 -0500 Subject: [PATCH 12/18] Add gates to tests --- .../react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js index 7613ee90763..7349e8861eb 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js @@ -1508,6 +1508,7 @@ describe('ReactDOMFizzSuspenseList', () => { ); }); + // @gate enableSuspenseList it('inserts text separators (comments) for text nodes (forwards)', async () => { function Foo() { return ( @@ -1539,6 +1540,7 @@ describe('ReactDOMFizzSuspenseList', () => { ); }); + // @gate enableSuspenseList it('inserts text separators (comments) for text nodes (backwards)', async () => { function Foo() { return ( @@ -1568,6 +1570,7 @@ describe('ReactDOMFizzSuspenseList', () => { ); }); + // @gate enableSuspenseList it('inserts text separators (comments) for text nodes (legacy)', async () => { function Foo() { return ( From 38732e17a7835f5f860c1f49edffcc7704773887 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 24 Nov 2025 10:02:38 -0500 Subject: [PATCH 13/18] Extract flushSuspenseBoundarySegment helper --- packages/react-server/src/ReactFizzServer.js | 22 +++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 4a2aef49129..7a2231dd7b4 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -5752,6 +5752,22 @@ function flushSegment( segment.boundary = null; boundary.parentFlushed = true; + return flushSuspenseBoundarySegment( + request, + destination, + boundary, + segment, + hoistableState, + ); +} + +function flushSuspenseBoundarySegment( + request: Request, + destination: Destination, + boundary: SuspenseBoundary, + fallbackSegment: Segment, + hoistableState: null | HoistableState, +): boolean { // This segment is a Suspense boundary. We need to decide whether to // emit the content or the fallback now. if (boundary.status === CLIENT_RENDERED) { @@ -5788,7 +5804,7 @@ function flushSegment( ); } // Flush the fallback. - flushSubtree(request, destination, segment, hoistableState); + flushSubtree(request, destination, fallbackSegment, hoistableState); return writeEndClientRenderedSuspenseBoundary( destination, @@ -5815,7 +5831,7 @@ function flushSegment( hoistHoistables(hoistableState, boundary.fallbackState); } // Flush the fallback. - flushSubtree(request, destination, segment, hoistableState); + flushSubtree(request, destination, fallbackSegment, hoistableState); return writeEndPendingSuspenseBoundary(destination, request.renderState); } else if ( @@ -5853,7 +5869,7 @@ function flushSegment( // flushes later in this pass or in a future flush // Flush the fallback. - flushSubtree(request, destination, segment, hoistableState); + flushSubtree(request, destination, fallbackSegment, hoistableState); return writeEndPendingSuspenseBoundary(destination, request.renderState); } else { From 348de86028357ae5c8aaf5802840abb021f654e3 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 24 Nov 2025 11:28:36 -0500 Subject: [PATCH 14/18] Special case flushing list row boundaries This avoids the wrapper around the implicit suspense boundaries and instead is meant to emit a single marker at the end of the list where new items will be inserted. --- packages/react-server/src/ReactFizzServer.js | 120 ++++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 7a2231dd7b4..b099150185c 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -825,7 +825,7 @@ function createSuspenseBoundary( rootSegmentID: -1, parentFlushed: false, pendingTasks: 0, - list: null, + list: list, row: row, preceedingRow: null, completedSegments: [], @@ -5752,6 +5752,17 @@ function flushSegment( segment.boundary = null; boundary.parentFlushed = true; + const suspenseList = boundary.list; + if (suspenseList !== null) { + return flushSuspenseListSegment( + request, + destination, + suspenseList, + boundary, + hoistableState, + ); + } + return flushSuspenseBoundarySegment( request, destination, @@ -5761,6 +5772,113 @@ function flushSegment( ); } +function flushSuspenseListSegment( + request: Request, + destination: Destination, + suspenseList: SuspenseList, + boundary: SuspenseBoundary, + hoistableState: null | HoistableState, +): boolean { + // This segment is a Suspense boundary. We need to decide whether to + // emit the content or the fallback now. + if (boundary.status === CLIENT_RENDERED) { + // The suspense list didn't complete all the way. Emit a marker to continue on the client. + /* + if (__DEV__) { + writeClientRenderedSuspenseList( + destination, + request.renderState, + boundary.errorDigest, + boundary.errorMessage, + boundary.errorStack, + boundary.errorComponentStack, + ); + } else { + writeClientRenderedSuspenseList( + destination, + request.renderState, + boundary.errorDigest, + null, + null, + null, + ); + } + */ + return true; + } else if (boundary.status !== COMPLETED) { + if (boundary.status === PENDING) { + boundary.rootSegmentID = request.nextSegmentId++; + } + + if (boundary.completedSegments.length > 0) { + // If this is at least partially complete, we can queue it to be partially emitted early. + request.partialBoundaries.push(boundary); + } + + if (suspenseList.id === -1) { + // We lazily assign an ID to the list marker. + suspenseList.id = boundary.rootSegmentID; + // This is the first time we're emitting this list so we need to emit the marker. + // const id = suspenseList.id; + // return writePendingSuspenseList(destination, request.renderState, id); + return true; + } else { + // This is just a partial continuation of a previously emitted list. We already have + // a marker for the list so there's nothing more to emit. + return true; + } + } else if ( + // We don't outline when we're emitting partially completed boundaries optimistically + // because it doesn't make sense to outline something if its parent is going to be + // blocked on something later in the stream anyway. + !flushingPartialBoundaries && + isEligibleForOutlining(request, boundary) && + (flushedByteSize + boundary.byteSize > request.progressiveChunkSize || + hasSuspenseyContent(boundary.contentState) || + boundary.defer) + ) { + boundary.rootSegmentID = request.nextSegmentId++; + if (suspenseList.id === -1) { + // We lazily assign an ID to the list marker. + suspenseList.id = boundary.rootSegmentID; + } + + request.completedBoundaries.push(boundary); + + // const id = suspenseList.id; + // return writePendingSuspenseList(destination, request.renderState, id); + return true; + } else { + // We're inlining this boundary so its bytes get counted to the current running count. + flushedByteSize += boundary.byteSize; + if (hoistableState) { + hoistHoistables(hoistableState, boundary.contentState); + } + + const row = boundary.row; + if (row !== null && isEligibleForOutlining(request, boundary)) { + // Once we have written the boundary, we can unblock the row and let future + // rows be written. This may schedule new completed boundaries. + if (--row.pendingTasks === 0) { + finishSuspenseListRow(request, row); + } + } + + // We can inline this row's content without any additional markers. + const completedSegments = boundary.completedSegments; + + if (completedSegments.length !== 1) { + throw new Error( + 'A previously unvisited boundary must have exactly one root segment. This is a bug in React.', + ); + } + + const contentSegment = completedSegments[0]; + // TODO: Apply a tail call optimization to avoid recursing into the list. + return flushSegment(request, destination, contentSegment, hoistableState); + } +} + function flushSuspenseBoundarySegment( request: Request, destination: Destination, From 57a5577237cde959a9595199f266f55d5f9ccbc9 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 24 Nov 2025 11:43:54 -0500 Subject: [PATCH 15/18] Add template placeholder for future row items or errored row item --- .../src/server/ReactFizzConfigDOM.js | 76 +++++++++++++++++++ .../src/server/ReactFizzConfigDOMLegacy.js | 4 + .../react-markup/src/ReactFizzConfigMarkup.js | 2 + .../src/ReactNoopServer.js | 13 ++++ packages/react-server/src/ReactFizzServer.js | 69 +++++++++-------- .../src/forks/ReactFizzConfig.custom.js | 4 + 6 files changed, 137 insertions(+), 31 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index c02c00a13c6..b429bcf0ce6 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -150,6 +150,7 @@ export type RenderState = { placeholderPrefix: PrecomputedChunk, segmentPrefix: PrecomputedChunk, boundaryPrefix: PrecomputedChunk, + listPrefix: PrecomputedChunk, // inline script streaming format, unused if using external runtime / data startInlineScript: PrecomputedChunk, @@ -491,6 +492,7 @@ export function createRenderState( placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'), segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'), boundaryPrefix: stringToPrecomputedChunk(idPrefix + 'B:'), + listPrefix: stringToPrecomputedChunk(idPrefix + 'L:'), startInlineScript: inlineScriptWithNonce, startInlineStyle: inlineStyleWithNonce, preamble: createPreambleState(), @@ -4591,6 +4593,80 @@ export function pushEndSuspenseListBoundary( target.push(endSuspenseListBoundary); } +const pendingSuspenseList1 = stringToPrecomputedChunk(''); + +const clientRenderedSuspenseListError1 = stringToPrecomputedChunk(''); + +export function writePendingSuspenseListMarker( + destination: Destination, + renderState: RenderState, + id: number, +): boolean { + writeChunk(destination, pendingSuspenseList1); + + if (id === null) { + throw new Error( + 'An ID must have been assigned before we can complete the boundary.', + ); + } + + writeChunk(destination, renderState.listPrefix); + writeChunk(destination, stringToChunk(id.toString(16))); + return writeChunkAndReturn(destination, pendingSuspenseList2); +} +export function writeClientRenderedSuspenseListMarker( + destination: Destination, + renderState: RenderState, + errorDigest: ?string, + errorMessage: ?string, + errorStack: ?string, + errorComponentStack: ?string, +): boolean { + writeChunk(destination, clientRenderedSuspenseListError1); + if (errorDigest) { + writeChunk(destination, clientRenderedSuspenseListError1A); + writeChunk(destination, stringToChunk(escapeTextForBrowser(errorDigest))); + writeChunk(destination, clientRenderedSuspenseListErrorAttrInterstitial); + } + if (__DEV__) { + if (errorMessage) { + writeChunk(destination, clientRenderedSuspenseListError1B); + writeChunk( + destination, + stringToChunk(escapeTextForBrowser(errorMessage)), + ); + writeChunk(destination, clientRenderedSuspenseListErrorAttrInterstitial); + } + if (errorStack) { + writeChunk(destination, clientRenderedSuspenseListError1C); + writeChunk(destination, stringToChunk(escapeTextForBrowser(errorStack))); + writeChunk(destination, clientRenderedSuspenseListErrorAttrInterstitial); + } + if (errorComponentStack) { + writeChunk(destination, clientRenderedSuspenseListError1D); + writeChunk( + destination, + stringToChunk(escapeTextForBrowser(errorComponentStack)), + ); + writeChunk(destination, clientRenderedSuspenseListErrorAttrInterstitial); + } + } + return writeChunkAndReturn(destination, clientRenderedSuspenseListError2); +} + // 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 376c5c07bc8..c22a58f0a96 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js @@ -50,6 +50,7 @@ export type RenderState = { placeholderPrefix: PrecomputedChunk, segmentPrefix: PrecomputedChunk, boundaryPrefix: PrecomputedChunk, + listPrefix: PrecomputedChunk, startInlineScript: PrecomputedChunk, startInlineStyle: PrecomputedChunk, preamble: PreambleState, @@ -107,6 +108,7 @@ export function createRenderState( placeholderPrefix: renderState.placeholderPrefix, segmentPrefix: renderState.segmentPrefix, boundaryPrefix: renderState.boundaryPrefix, + listPrefix: renderState.listPrefix, startInlineScript: renderState.startInlineScript, startInlineStyle: renderState.startInlineStyle, preamble: renderState.preamble, @@ -167,6 +169,8 @@ export { writeClientRenderBoundaryInstruction, writeStartPendingSuspenseBoundary, writeEndPendingSuspenseBoundary, + writePendingSuspenseListMarker, + writeClientRenderedSuspenseListMarker, writeHoistablesForBoundary, writePlaceholder, writeCompletedRoot, diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js index 6fc10b27a20..ccb7562e317 100644 --- a/packages/react-markup/src/ReactFizzConfigMarkup.js +++ b/packages/react-markup/src/ReactFizzConfigMarkup.js @@ -65,6 +65,8 @@ export { writeClientRenderBoundaryInstruction, writeStartPendingSuspenseBoundary, writeEndPendingSuspenseBoundary, + writePendingSuspenseListMarker, + writeClientRenderedSuspenseListMarker, writeHoistablesForBoundary, writePlaceholder, createRootFormatContext, diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index e492e9c2164..582086c5400 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -221,6 +221,19 @@ const ReactNoopServer = ReactFizzServer({ target.push(POP); }, + writePendingSuspenseList( + destination: Destination, + renderState: RenderState, + ): boolean { + return true; + }, + writeClientRenderedSuspenseList( + destination: Destination, + renderState: RenderState, + ): boolean { + return true; + }, + writeStartCompletedSuspenseBoundary( destination: Destination, renderState: RenderState, diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index b099150185c..16e148caf40 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -64,6 +64,8 @@ import { pushEndActivityBoundary, pushStartSuspenseListBoundary, pushEndSuspenseListBoundary, + writePendingSuspenseListMarker, + writeClientRenderedSuspenseListMarker, writeStartCompletedSuspenseBoundary, writeStartPendingSuspenseBoundary, writeStartClientRenderedSuspenseBoundary, @@ -5783,28 +5785,25 @@ function flushSuspenseListSegment( // emit the content or the fallback now. if (boundary.status === CLIENT_RENDERED) { // The suspense list didn't complete all the way. Emit a marker to continue on the client. - /* - if (__DEV__) { - writeClientRenderedSuspenseList( - destination, - request.renderState, - boundary.errorDigest, - boundary.errorMessage, - boundary.errorStack, - boundary.errorComponentStack, - ); - } else { - writeClientRenderedSuspenseList( - destination, - request.renderState, - boundary.errorDigest, - null, - null, - null, - ); - } - */ - return true; + if (__DEV__) { + return writeClientRenderedSuspenseListMarker( + destination, + request.renderState, + boundary.errorDigest, + boundary.errorMessage, + boundary.errorStack, + boundary.errorComponentStack, + ); + } else { + return writeClientRenderedSuspenseListMarker( + destination, + request.renderState, + boundary.errorDigest, + null, + null, + null, + ); + } } else if (boundary.status !== COMPLETED) { if (boundary.status === PENDING) { boundary.rootSegmentID = request.nextSegmentId++; @@ -5819,9 +5818,11 @@ function flushSuspenseListSegment( // We lazily assign an ID to the list marker. suspenseList.id = boundary.rootSegmentID; // This is the first time we're emitting this list so we need to emit the marker. - // const id = suspenseList.id; - // return writePendingSuspenseList(destination, request.renderState, id); - return true; + return writePendingSuspenseListMarker( + destination, + request.renderState, + suspenseList.id, + ); } else { // This is just a partial continuation of a previously emitted list. We already have // a marker for the list so there's nothing more to emit. @@ -5838,16 +5839,22 @@ function flushSuspenseListSegment( boundary.defer) ) { boundary.rootSegmentID = request.nextSegmentId++; + request.completedBoundaries.push(boundary); + if (suspenseList.id === -1) { // We lazily assign an ID to the list marker. suspenseList.id = boundary.rootSegmentID; + // This is the first time we're emitting this list so we need to emit the marker. + return writePendingSuspenseListMarker( + destination, + request.renderState, + suspenseList.id, + ); + } else { + // This is just a partial continuation of a previously emitted list. We already have + // a marker for the list so there's nothing more to emit. + return true; } - - request.completedBoundaries.push(boundary); - - // const id = suspenseList.id; - // return writePendingSuspenseList(destination, request.renderState, id); - return true; } else { // We're inlining this boundary so its bytes get counted to the current running count. flushedByteSize += boundary.byteSize; diff --git a/packages/react-server/src/forks/ReactFizzConfig.custom.js b/packages/react-server/src/forks/ReactFizzConfig.custom.js index 83c42d801e6..8adccae0dbe 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.custom.js +++ b/packages/react-server/src/forks/ReactFizzConfig.custom.js @@ -71,6 +71,10 @@ export const pushStartSuspenseListBoundary = $$$config.pushStartSuspenseListBoundary; export const pushEndSuspenseListBoundary = $$$config.pushEndSuspenseListBoundary; +export const writePendingSuspenseListMarker = + $$$config.writePendingSuspenseListMarker; +export const writeClientRenderedSuspenseListMarker = + $$$config.writeClientRenderedSuspenseListMarker; export const writeStartCompletedSuspenseBoundary = $$$config.writeStartCompletedSuspenseBoundary; export const writeStartPendingSuspenseBoundary = From 1c7ac778827a56ce48cb4242f8fde1350bf12611 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 24 Nov 2025 13:21:15 -0500 Subject: [PATCH 16/18] Add instruction configs for appending, completing or client-rendering list items --- .../src/server/ReactFizzConfigDOM.js | 123 +++++++++++++++-- .../src/server/ReactFizzConfigDOMLegacy.js | 3 + .../react-markup/src/ReactFizzConfigMarkup.js | 3 + .../src/ReactNoopServer.js | 39 ++++++ packages/react-server/src/ReactFizzServer.js | 124 +++++++++++++----- .../src/forks/ReactFizzConfig.custom.js | 6 + 6 files changed, 258 insertions(+), 40 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index b429bcf0ce6..c7b6da886d3 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -5014,11 +5014,13 @@ const completeBoundaryData2 = stringToPrecomputedChunk('" data-sid="'); const completeBoundaryData3a = stringToPrecomputedChunk('" data-sty="'); const completeBoundaryDataEnd = dataElementQuotedEnd; -export function writeCompletedBoundaryInstruction( +function writeCompletedInstruction( destination: Destination, resumableState: ResumableState, renderState: RenderState, - id: number, + prefix: PrecomputedChunk, + boundaryId: number, + segmentId: number, hoistableState: HoistableState, ): boolean { const requiresStyleInsertion = renderState.stylesToHoist; @@ -5103,10 +5105,8 @@ export function writeCompletedBoundaryInstruction( } } - const idChunk = stringToChunk(id.toString(16)); - - writeChunk(destination, renderState.boundaryPrefix); - writeChunk(destination, idChunk); + writeChunk(destination, prefix); + writeChunk(destination, stringToChunk(boundaryId.toString(16))); // Write function arguments, which are string and array literals if (scriptFormat) { @@ -5115,7 +5115,7 @@ export function writeCompletedBoundaryInstruction( writeChunk(destination, completeBoundaryData2); } writeChunk(destination, renderState.segmentPrefix); - writeChunk(destination, idChunk); + writeChunk(destination, stringToChunk(segmentId.toString(16))); if (requiresStyleInsertion) { // Script and data writers must format this differently: // - script writer emits an array literal, whose string elements are @@ -5144,6 +5144,64 @@ export function writeCompletedBoundaryInstruction( return writeBootstrap(destination, renderState) && writeMore; } +export function writeCompletedBoundaryInstruction( + destination: Destination, + resumableState: ResumableState, + renderState: RenderState, + id: number, + hoistableState: HoistableState, +): boolean { + return writeCompletedInstruction( + destination, + resumableState, + renderState, + renderState.boundaryPrefix, + id, + id, + hoistableState, + ); +} + +export function writeAppendListInstruction( + destination: Destination, + resumableState: ResumableState, + renderState: RenderState, + suspenseListId: number, + segmentId: number, + hoistableState: HoistableState, + forwards: boolean, +): boolean { + return writeCompletedInstruction( + destination, + resumableState, + renderState, + renderState.listPrefix, + suspenseListId, + segmentId, + hoistableState, + ); +} + +export function writeCompletedListInstruction( + destination: Destination, + resumableState: ResumableState, + renderState: RenderState, + suspenseListId: number, + segmentId: number, + hoistableState: HoistableState, + forwards: boolean, +): boolean { + return writeCompletedInstruction( + destination, + resumableState, + renderState, + renderState.listPrefix, + suspenseListId, + segmentId, + hoistableState, + ); +} + const clientRenderScriptFunctionOnly = stringToPrecomputedChunk(clientRenderFunction); @@ -5164,10 +5222,11 @@ const clientRenderData4 = stringToPrecomputedChunk('" data-stck="'); const clientRenderData5 = stringToPrecomputedChunk('" data-cstck="'); const clientRenderDataEnd = dataElementQuotedEnd; -export function writeClientRenderBoundaryInstruction( +function writeClientRenderInstruction( destination: Destination, resumableState: ResumableState, renderState: RenderState, + prefix: PrecomputedChunk, id: number, errorDigest: ?string, errorMessage: ?string, @@ -5196,7 +5255,7 @@ export function writeClientRenderBoundaryInstruction( writeChunk(destination, clientRenderData1); } - writeChunk(destination, renderState.boundaryPrefix); + writeChunk(destination, prefix); writeChunk(destination, stringToChunk(id.toString(16))); if (scriptFormat) { // " needs to be inserted for scripts, since ArgInterstitual does not contain @@ -5284,6 +5343,52 @@ export function writeClientRenderBoundaryInstruction( } } +export function writeClientRenderBoundaryInstruction( + destination: Destination, + resumableState: ResumableState, + renderState: RenderState, + id: number, + errorDigest: ?string, + errorMessage: ?string, + errorStack: ?string, + errorComponentStack: ?string, +): boolean { + return writeClientRenderInstruction( + destination, + resumableState, + renderState, + renderState.boundaryPrefix, + id, + errorDigest, + errorMessage, + errorStack, + errorComponentStack, + ); +} + +export function writeClientRenderListInstruction( + destination: Destination, + resumableState: ResumableState, + renderState: RenderState, + id: number, + errorDigest: ?string, + errorMessage: ?string, + errorStack: ?string, + errorComponentStack: ?string, +): boolean { + return writeClientRenderInstruction( + destination, + resumableState, + renderState, + renderState.listPrefix, + id, + errorDigest, + errorMessage, + errorStack, + errorComponentStack, + ); +} + const regexForJSStringsInInstructionScripts = /[<\u2028\u2029]/g; function escapeJSStringsForInstructionScripts(input: string): string { const escaped = JSON.stringify(input); diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js index c22a58f0a96..a2cd74c20da 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js @@ -171,6 +171,9 @@ export { writeEndPendingSuspenseBoundary, writePendingSuspenseListMarker, writeClientRenderedSuspenseListMarker, + writeAppendListInstruction, + writeCompletedListInstruction, + writeClientRenderListInstruction, writeHoistablesForBoundary, writePlaceholder, writeCompletedRoot, diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js index ccb7562e317..fd706c75437 100644 --- a/packages/react-markup/src/ReactFizzConfigMarkup.js +++ b/packages/react-markup/src/ReactFizzConfigMarkup.js @@ -67,6 +67,9 @@ export { writeEndPendingSuspenseBoundary, writePendingSuspenseListMarker, writeClientRenderedSuspenseListMarker, + writeAppendListInstruction, + writeCompletedListInstruction, + writeClientRenderListInstruction, writeHoistablesForBoundary, writePlaceholder, createRootFormatContext, diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 582086c5400..1e4c71ccaa0 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -352,6 +352,45 @@ const ReactNoopServer = ReactFizzServer({ return true; }, + writeAppendListInstruction( + destination: Destination, + renderState: RenderState, + boundary: SuspenseInstance, + contentSegmentID: number, + ): boolean { + const segment = destination.segments.get(contentSegmentID); + if (!segment) { + throw new Error('Missing segment.'); + } + boundary.children = segment.children; + boundary.state = 'appended'; + return true; + }, + + writeCompletedListInstruction( + destination: Destination, + renderState: RenderState, + boundary: SuspenseInstance, + contentSegmentID: number, + ): boolean { + const segment = destination.segments.get(contentSegmentID); + if (!segment) { + throw new Error('Missing segment.'); + } + boundary.children = segment.children; + boundary.state = 'complete'; + return true; + }, + + writeClientRenderedSuspenseListMarker( + destination: Destination, + renderState: RenderState, + boundary: SuspenseInstance, + ): boolean { + boundary.status = 'client-render'; + return true; + }, + writePreambleStart() {}, writePreambleEnd() {}, writeHoistables() {}, diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 16e148caf40..5580fb173f9 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -77,6 +77,9 @@ import { writeClientRenderBoundaryInstruction, writeCompletedBoundaryInstruction, writeCompletedSegmentInstruction, + writeClientRenderListInstruction, + writeAppendListInstruction, + writeCompletedListInstruction, writeHoistablesForBoundary, pushTextInstance, pushStartInstance, @@ -245,6 +248,7 @@ type LegacyContext = { type SuspenseList = { id: number, forwards: boolean, + totalRows: number, completedRows: number, }; @@ -1866,6 +1870,7 @@ function createSuspenseList(forwards: boolean): SuspenseList { return { id: -1, forwards: forwards, + totalRows: 0, completedRows: 0, }; } @@ -2034,6 +2039,8 @@ function renderSuspenseListRows( task.row = suspenseListRow; task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i); + suspenseList.totalRows++; + const rowBoundary = createSuspenseBoundary( request, suspenseList, @@ -5781,9 +5788,9 @@ function flushSuspenseListSegment( boundary: SuspenseBoundary, hoistableState: null | HoistableState, ): boolean { - // This segment is a Suspense boundary. We need to decide whether to - // emit the content or the fallback now. + // This segment is a SuspenseList boundary row. if (boundary.status === CLIENT_RENDERED) { + suspenseList.completedRows++; // The suspense list didn't complete all the way. Emit a marker to continue on the client. if (__DEV__) { return writeClientRenderedSuspenseListMarker( @@ -5871,6 +5878,8 @@ function flushSuspenseListSegment( } } + suspenseList.completedRows++; + // We can inline this row's content without any additional markers. const completedSegments = boundary.completedSegments; @@ -6036,28 +6045,55 @@ function flushClientRenderedBoundary( destination: Destination, boundary: SuspenseBoundary, ): boolean { - if (__DEV__) { - return writeClientRenderBoundaryInstruction( - destination, - request.resumableState, - request.renderState, - boundary.rootSegmentID, - boundary.errorDigest, - boundary.errorMessage, - boundary.errorStack, - boundary.errorComponentStack, - ); + const suspenseList = boundary.list; + if (suspenseList !== null) { + if (__DEV__) { + return writeClientRenderListInstruction( + destination, + request.resumableState, + request.renderState, + suspenseList.id, + boundary.errorDigest, + boundary.errorMessage, + boundary.errorStack, + boundary.errorComponentStack, + ); + } else { + return writeClientRenderListInstruction( + destination, + request.resumableState, + request.renderState, + suspenseList.id, + boundary.errorDigest, + null, + null, + null, + ); + } } else { - return writeClientRenderBoundaryInstruction( - destination, - request.resumableState, - request.renderState, - boundary.rootSegmentID, - boundary.errorDigest, - null, - null, - null, - ); + if (__DEV__) { + return writeClientRenderBoundaryInstruction( + destination, + request.resumableState, + request.renderState, + boundary.rootSegmentID, + boundary.errorDigest, + boundary.errorMessage, + boundary.errorStack, + boundary.errorComponentStack, + ); + } else { + return writeClientRenderBoundaryInstruction( + destination, + request.resumableState, + request.renderState, + boundary.rootSegmentID, + boundary.errorDigest, + null, + null, + null, + ); + } } } @@ -6105,13 +6141,40 @@ function flushCompletedBoundary( boundary.contentState, request.renderState, ); - return writeCompletedBoundaryInstruction( - destination, - request.resumableState, - request.renderState, - boundary.rootSegmentID, - boundary.contentState, - ); + + const suspenseList = boundary.list; + if (suspenseList !== null) { + suspenseList.completedRows++; + if (suspenseList.completedRows < suspenseList.totalRows) { + return writeAppendListInstruction( + destination, + request.resumableState, + request.renderState, + suspenseList.id, + boundary.rootSegmentID, + boundary.contentState, + suspenseList.forwards, + ); + } else { + return writeCompletedListInstruction( + destination, + request.resumableState, + request.renderState, + suspenseList.id, + boundary.rootSegmentID, + boundary.contentState, + suspenseList.forwards, + ); + } + } else { + return writeCompletedBoundaryInstruction( + destination, + request.resumableState, + request.renderState, + boundary.rootSegmentID, + boundary.contentState, + ); + } } function flushPartialBoundary( @@ -6181,7 +6244,6 @@ function flushPartiallyCompletedSegment( 'A root segment ID must have been assigned by now. This is a bug in React.', ); } - return flushSegmentContainer(request, destination, segment, hoistableState); } else if (segmentID === boundary.rootSegmentID) { // When we emit postponed boundaries, we might have assigned the ID already diff --git a/packages/react-server/src/forks/ReactFizzConfig.custom.js b/packages/react-server/src/forks/ReactFizzConfig.custom.js index 8adccae0dbe..9c9a4589aaa 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.custom.js +++ b/packages/react-server/src/forks/ReactFizzConfig.custom.js @@ -95,6 +95,12 @@ export const writeCompletedBoundaryInstruction = $$$config.writeCompletedBoundaryInstruction; export const writeClientRenderBoundaryInstruction = $$$config.writeClientRenderBoundaryInstruction; +export const writeAppendListInstruction = $$$config.writeAppendListInstruction; +export const writeCompletedListInstruction = + $$$config.writeCompletedListInstruction; +export const writeClientRenderListInstruction = + $$$config.writeClientRenderListInstruction; + export const NotPendingTransition = $$$config.NotPendingTransition; export const createPreambleState = $$$config.createPreambleState; export const canHavePreamble = $$$config.canHavePreamble; From 5e72105d4eb0bf9e9ae28bdccc0deeeceea751ca Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 24 Nov 2025 14:48:21 -0500 Subject: [PATCH 17/18] Ensure we can resolve together rows eagerly --- .../src/__tests__/ReactDOMFizzSuspenseList-test.js | 12 +++--------- packages/react-server/src/ReactFizzServer.js | 4 ++++ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js index 7349e8861eb..fc209298d65 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js @@ -1526,9 +1526,7 @@ describe('ReactDOMFizzSuspenseList', () => { expect(getVisibleChildren(container)).toEqual(
{['A', 'B', 'C']}
); const textNodes = 3; - const boundaryComments = gate(flags => flags.enableFizzSuspenseListTail) - ? 2 * textNodes // TODO: One we remove the comments around boundaries this should be zero. - : 0; + const boundaryComments = 0; const textSeparators = gate(flags => flags.enableFizzSuspenseListTail) ? textNodes // One after each node. : textNodes - 1; // One between each node @@ -1558,9 +1556,7 @@ describe('ReactDOMFizzSuspenseList', () => { expect(getVisibleChildren(container)).toEqual(
{['A', 'B', 'C']}
); const textNodes = 3; - const boundaryComments = gate(flags => flags.enableFizzSuspenseListTail) - ? 2 * textNodes // TODO: One we remove the comments around boundaries this should be zero. - : 0; + const boundaryComments = 0; const textSeparators = textNodes; // One after each node. const suspenseListComments = gate(flags => flags.enableFizzSuspenseListTail) ? 2 @@ -1590,9 +1586,7 @@ describe('ReactDOMFizzSuspenseList', () => { expect(getVisibleChildren(container)).toEqual(
{['A', 'B', 'C']}
); const textNodes = 3; - const boundaryComments = gate(flags => flags.enableFizzSuspenseListTail) - ? 2 * textNodes // TODO: One we remove the comments around boundaries this should be zero. - : 0; + const boundaryComments = 0; const textSeparators = textNodes; // One after each node. const suspenseListComments = gate(flags => flags.enableFizzSuspenseListTail) ? 2 diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 5580fb173f9..3730d36b31d 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -2087,6 +2087,8 @@ function renderSuspenseListRows( // chance to add the next row as a blocker for the previous row. if (--previousSuspenseListRow.pendingTasks === 0) { finishSuspenseListRow(request, previousSuspenseListRow); + } else if (previousSuspenseListRow.mode === TOGETHER) { + tryToResolveTogetherRow(request, previousSuspenseListRow); } } @@ -2190,6 +2192,8 @@ function renderSuspenseListRows( // chance to add the next row as a blocker for the previous row. if (--previousSuspenseListRow.pendingTasks === 0) { finishSuspenseListRow(request, previousSuspenseListRow); + } else if (previousSuspenseListRow.mode === TOGETHER) { + tryToResolveTogetherRow(request, previousSuspenseListRow); } } task.blockedBoundary = parentBoundary; From d5ffec169f90097f8451754e9359537c77a94919 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 30 Nov 2025 16:40:24 -0500 Subject: [PATCH 18/18] stash --- .../src/server/ReactFizzConfigDOM.js | 4 +++- .../ReactDOMFizzInstructionSetShared.js | 14 ++++++++++---- .../src/__tests__/ReactDOMFizzSuspenseList-test.js | 3 ++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index c7b6da886d3..f55e536c0c1 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -4593,7 +4593,9 @@ export function pushEndSuspenseListBoundary( target.push(endSuspenseListBoundary); } -const pendingSuspenseList1 = stringToPrecomputedChunk(''); const clientRenderedSuspenseListError1 = stringToPrecomputedChunk(' { // We also want to execute any scripts that are embedded. // We assume that we have now received a proper fragment of HTML. const bufferedContent = buffer; + console.log(bufferedContent); buffer = ''; const temp = document.createElement('body'); temp.innerHTML = bufferedContent; @@ -134,7 +135,7 @@ describe('ReactDOMFizzSuspenseList', () => { } // @gate enableSuspenseList && enableFizzSuspenseListTail - it('shows content forwards but hidden tail by default', async () => { + fit('shows content forwards but hidden tail by default', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); const C = createAsyncText('C');
+ + }> + + + }> + + + }> + + + +