From e32dda7f05c24e630d06724f7bbb57b0c81d5e1c Mon Sep 17 00:00:00 2001
From: Sebastian Markbage
Date: Mon, 19 May 2025 21:49:05 -0400
Subject: [PATCH 1/4] Test
---
.../src/__tests__/ReactDOMFloat-test.js | 67 +++++++++++++++++++
1 file changed, 67 insertions(+)
diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
index 2d3f1f0b8b621..58319331b00b7 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
@@ -21,6 +21,7 @@ let ReactDOM;
let ReactDOMClient;
let ReactDOMFizzServer;
let Suspense;
+let SuspenseList;
let textCache;
let loadCache;
let writable;
@@ -74,6 +75,7 @@ describe('ReactDOMFloat', () => {
ReactDOMFizzServer = require('react-dom/server');
Stream = require('stream');
Suspense = React.Suspense;
+ SuspenseList = React.unstable_SuspenseList;
Scheduler = require('scheduler/unstable_mock');
const InternalTestUtils = require('internal-test-utils');
@@ -5746,6 +5748,71 @@ body {
);
});
+ // @gate enableSuspenseList
+ it('delays "forwards" SuspenseList rows until the css of previous rows have completed', async () => {
+ await act(() => {
+ renderToPipeableStream(
+
+
+
+
+
+
+ foo
+
+
+ bar
+
+
+ ,
+ ).pipe(writable);
+ });
+
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+ {'loading foo...'}
+ {'loading bar...'}
+
+ ,
+ );
+
+ await act(() => {
+ resolveText('foo');
+ });
+
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+ {'loading foo...'}
+ {'loading bar...'}
+
+
+ ,
+ );
+
+ await act(() => {
+ loadStylesheets();
+ });
+ await assertLog(['load stylesheet: foo']);
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+ {'foo'}
+ {'bar'}
+
+
+ ,
+ );
+ });
+
describe('ReactDOM.preconnect(href, { crossOrigin })', () => {
it('creates a preconnect resource when called', async () => {
function App({url}) {
From 964561bcb401c24ffb8819d8b36470730b78f773 Mon Sep 17 00:00:00 2001
From: Sebastian Markbage
Date: Mon, 19 May 2025 22:35:57 -0400
Subject: [PATCH 2/4] Hoist hoistables to each row and transfer the
dependencies to future rows
---
packages/react-server/src/ReactFizzServer.js | 28 ++++++++++++++++----
1 file changed, 23 insertions(+), 5 deletions(-)
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index f28119dff3ecf..119544fd641a1 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -236,6 +236,7 @@ type LegacyContext = {
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.
together: boolean, // All the boundaries within this row must be revealed together.
next: null | SuspenseListRow, // The next row blocked by this one.
};
@@ -1676,22 +1677,32 @@ function replaySuspenseBoundary(
function finishSuspenseListRow(request: Request, row: SuspenseListRow): void {
// This row finished. Now we have to unblock all the next rows that were blocked on this.
- unblockSuspenseListRow(request, row.next);
+ unblockSuspenseListRow(request, row.next, row.hoistables);
}
function unblockSuspenseListRow(
request: Request,
unblockedRow: null | SuspenseListRow,
+ inheritedHoistables: null | HoistableState,
): void {
// We do this in a loop to avoid stack overflow for very long lists that get unblocked.
while (unblockedRow !== null) {
+ 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.
+ hoistHoistables(unblockedRow.hoistables, inheritedHoistables);
+ }
// Unblocking the boundaries will decrement the count of this row but we keep it above
// zero so they never finish this row recursively.
const unblockedBoundaries = unblockedRow.boundaries;
if (unblockedBoundaries !== null) {
unblockedRow.boundaries = null;
for (let i = 0; i < unblockedBoundaries.length; i++) {
- finishedTask(request, unblockedBoundaries[i], null, null);
+ const unblockedBoundary = unblockedBoundaries[i];
+ if (inheritedHoistables !== null) {
+ hoistHoistables(unblockedBoundary.contentState, inheritedHoistables);
+ }
+ finishedTask(request, unblockedBoundary, null, null);
}
}
// Instead we decrement at the end to keep it all in this loop.
@@ -1700,6 +1711,7 @@ function unblockSuspenseListRow(
// Still blocked.
break;
}
+ inheritedHoistables = unblockedRow.hoistables;
unblockedRow = unblockedRow.next;
}
}
@@ -1728,7 +1740,7 @@ function tryToResolveTogetherRow(
}
}
if (allCompleteAndInlinable) {
- unblockSuspenseListRow(request, togetherRow);
+ unblockSuspenseListRow(request, togetherRow, null);
}
}
@@ -1738,6 +1750,7 @@ function createSuspenseListRow(
const newRow: SuspenseListRow = {
pendingTasks: 1, // At first the row is blocked on attempting rendering itself.
boundaries: null,
+ hoistables: createHoistableState(),
together: false,
next: null,
};
@@ -4869,10 +4882,15 @@ function finishedTask(
// If the boundary is eligible to be outlined during flushing we can't cancel the fallback
// since we might need it when it's being outlined.
if (boundary.status === COMPLETED) {
+ const boundaryRow = boundary.row;
+ if (boundaryRow !== null) {
+ // Hoist the HoistableState from the boundary to the row so that the next rows
+ // can depend on the same dependencies.
+ hoistHoistables(boundaryRow.hoistables, boundary.contentState);
+ }
if (!isEligibleForOutlining(request, boundary)) {
boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request);
boundary.fallbackAbortableTasks.clear();
- const boundaryRow = boundary.row;
if (boundaryRow !== null) {
// If we aren't eligible for outlining, we don't have to wait until we flush it.
if (--boundaryRow.pendingTasks === 0) {
@@ -5679,7 +5697,7 @@ function flushPartialBoundary(
// unblock the boundary itself which can issue its complete instruction.
// TODO: Ideally the complete instruction would be in a single