Skip to content

[Fizz] Hoist hoistables to each row and transfer the dependencies to future rows #33312

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFloat-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ let ReactDOM;
let ReactDOMClient;
let ReactDOMFizzServer;
let Suspense;
let SuspenseList;
let textCache;
let loadCache;
let writable;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -5746,6 +5748,181 @@ body {
);
});

// @gate enableSuspenseList
it('delays "forwards" SuspenseList rows until the css of previous rows have completed', async () => {
await act(() => {
renderToPipeableStream(
<html>
<body>
<Suspense fallback="loading...">
<SuspenseList revealOrder="forwards">
<Suspense fallback="loading foo...">
<BlockedOn value="foo">
<link rel="stylesheet" href="foo" precedence="foo" />
foo
</BlockedOn>
</Suspense>
<Suspense fallback="loading bar...">bar</Suspense>
<BlockedOn value="bar">
<Suspense fallback="loading baz...">
<BlockedOn value="baz">baz</BlockedOn>
</Suspense>
</BlockedOn>
</SuspenseList>
</Suspense>
</body>
</html>,
).pipe(writable);
});

expect(getMeaningfulChildren(document)).toEqual(
<html>
<head />
<body>loading...</body>
</html>,
);

// unblock css loading
await act(() => {
resolveText('foo');
});

// bar is still blocking the whole list
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="foo" data-precedence="foo" />
</head>
<body>
{'loading...'}
<link as="style" href="foo" rel="preload" />
</body>
</html>,
);

// unblock inner loading states
await act(() => {
resolveText('bar');
});

expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="foo" data-precedence="foo" />
</head>
<body>
{'loading foo...'}
{'loading bar...'}
{'loading baz...'}
<link as="style" href="foo" rel="preload" />
</body>
</html>,
);

// resolve the last boundary
await act(() => {
resolveText('baz');
});

// still blocked on the css of the first row
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="foo" data-precedence="foo" />
</head>
<body>
{'loading foo...'}
{'loading bar...'}
{'loading baz...'}
<link as="style" href="foo" rel="preload" />
</body>
</html>,
);

await act(() => {
loadStylesheets();
});
await assertLog(['load stylesheet: foo']);
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="foo" data-precedence="foo" />
</head>
<body>
{'foo'}
{'bar'}
{'baz'}
<link as="style" href="foo" rel="preload" />
</body>
</html>,
);
});

// @gate enableSuspenseList
it('delays "together" SuspenseList rows until the css of previous rows have completed', async () => {
await act(() => {
renderToPipeableStream(
<html>
<body>
<SuspenseList revealOrder="together">
<Suspense fallback="loading foo...">
<BlockedOn value="foo">
<link rel="stylesheet" href="foo" precedence="foo" />
foo
</BlockedOn>
</Suspense>
<Suspense fallback="loading bar...">bar</Suspense>
</SuspenseList>
</body>
</html>,
).pipe(writable);
});

expect(getMeaningfulChildren(document)).toEqual(
<html>
<head />
<body>
{'loading foo...'}
{'loading bar...'}
</body>
</html>,
);

await act(() => {
resolveText('foo');
});

expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="foo" data-precedence="foo" />
</head>
<body>
{'loading foo...'}
{'loading bar...'}
<link as="style" href="foo" rel="preload" />
</body>
</html>,
);

await act(() => {
loadStylesheets();
});
await assertLog(['load stylesheet: foo']);
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="foo" data-precedence="foo" />
</head>
<body>
{'foo'}
{'bar'}
<link as="style" href="foo" rel="preload" />
</body>
</html>,
);
});

describe('ReactDOM.preconnect(href, { crossOrigin })', () => {
it('creates a preconnect resource when called', async () => {
function App({url}) {
Expand Down
38 changes: 33 additions & 5 deletions packages/react-server/src/ReactFizzServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<SuspenseBoundary>, // 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.
};
Expand Down Expand Up @@ -790,6 +792,10 @@ function createSuspenseBoundary(
boundary.pendingTasks++;
blockedBoundaries.push(boundary);
}
const inheritedHoistables = row.inheritedHoistables;
if (inheritedHoistables !== null) {
hoistHoistables(boundary.contentState, inheritedHoistables);
}
}
return boundary;
}
Expand Down Expand Up @@ -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.
Expand All @@ -1700,6 +1720,7 @@ function unblockSuspenseListRow(
// Still blocked.
break;
}
inheritedHoistables = unblockedRow.hoistables;
unblockedRow = unblockedRow.next;
}
}
Expand Down Expand Up @@ -1728,7 +1749,7 @@ function tryToResolveTogetherRow(
}
}
if (allCompleteAndInlinable) {
unblockSuspenseListRow(request, togetherRow);
unblockSuspenseListRow(request, togetherRow, togetherRow.hoistables);
}
}

Expand All @@ -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,
};
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 <script> tag.
if (row.pendingTasks === 1) {
unblockSuspenseListRow(request, row);
unblockSuspenseListRow(request, row, row.hoistables);
} else {
row.pendingTasks--;
}
Expand Down
Loading