diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
index 2d3f1f0b8b621..c2126b12f7237 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,181 @@ body {
);
});
+ // @gate enableSuspenseList
+ it('delays "forwards" SuspenseList rows until the css of previous rows have completed', async () => {
+ await act(() => {
+ renderToPipeableStream(
+
+
+
+
+
+
+
+ foo
+
+
+ bar
+
+
+ baz
+
+
+
+
+
+ ,
+ ).pipe(writable);
+ });
+
+ expect(getMeaningfulChildren(document)).toEqual(
+
+ loading...
+ ,
+ );
+
+ // unblock css loading
+ await act(() => {
+ resolveText('foo');
+ });
+
+ // bar is still blocking the whole list
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+ {'loading...'}
+
+
+ ,
+ );
+
+ // unblock inner loading states
+ await act(() => {
+ resolveText('bar');
+ });
+
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+ {'loading foo...'}
+ {'loading bar...'}
+ {'loading baz...'}
+
+
+ ,
+ );
+
+ // resolve the last boundary
+ await act(() => {
+ resolveText('baz');
+ });
+
+ // still blocked on the css of the first row
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+ {'loading foo...'}
+ {'loading bar...'}
+ {'loading baz...'}
+
+
+ ,
+ );
+
+ await act(() => {
+ loadStylesheets();
+ });
+ await assertLog(['load stylesheet: foo']);
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+ {'foo'}
+ {'bar'}
+ {'baz'}
+
+
+ ,
+ );
+ });
+
+ // @gate enableSuspenseList
+ it('delays "together" 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}) {
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index f28119dff3ecf..3f42b5d687e46 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -236,6 +236,8 @@ 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.
+ 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.
next: null | SuspenseListRow, // The next row blocked by this one.
};
@@ -790,6 +792,10 @@ function createSuspenseBoundary(
boundary.pendingTasks++;
blockedBoundaries.push(boundary);
}
+ const inheritedHoistables = row.inheritedHoistables;
+ if (inheritedHoistables !== null) {
+ hoistHoistables(boundary.contentState, inheritedHoistables);
+ }
}
return boundary;
}
@@ -1676,22 +1682,36 @@ 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);
+ // Mark the row itself for any newly discovered Suspense boundaries to inherit.
+ // This is different from hoistables because that also includes hoistables from
+ // all the boundaries below this row and not just previous rows.
+ unblockedRow.inheritedHoistables = 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 +1720,7 @@ function unblockSuspenseListRow(
// Still blocked.
break;
}
+ inheritedHoistables = unblockedRow.hoistables;
unblockedRow = unblockedRow.next;
}
}
@@ -1728,7 +1749,7 @@ function tryToResolveTogetherRow(
}
}
if (allCompleteAndInlinable) {
- unblockSuspenseListRow(request, togetherRow);
+ unblockSuspenseListRow(request, togetherRow, togetherRow.hoistables);
}
}
@@ -1738,6 +1759,8 @@ function createSuspenseListRow(
const newRow: SuspenseListRow = {
pendingTasks: 1, // At first the row is blocked on attempting rendering itself.
boundaries: null,
+ hoistables: createHoistableState(),
+ inheritedHoistables: null,
together: false,
next: null,
};
@@ -4869,10 +4892,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 +5707,7 @@ function flushPartialBoundary(
// unblock the boundary itself which can issue its complete instruction.
// TODO: Ideally the complete instruction would be in a single