[Fizz] Hoist hoistables to each row and transfer the dependencies to future rows (#33312)
Stacked on #33311. When a row contains Suspense boundaries that themselves depend on CSS, they will not resolve until the CSS has loaded on the client. We need future rows in a list to be blocked until this happens. We could do something in the runtime but a simpler approach is to just add those CSS dependencies to all those boundaries as well. To do this, we first hoist the HoistableState from a completed boundary onto its parent row. Then when the row finishes do we hoist it onto the next row and onto any boundaries within that row.
Sebastian Markbåge committed
May 20, 2025 at 14:48 UTC
50389e179273558d0919d45bc5db99a21b258b03
2 files changed
+210
-5
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+177
@@ -21,6 +21,7 @@ let ReactDOM;
21
let ReactDOMClient;
22
let ReactDOMFizzServer;
23
let Suspense;
24
+let SuspenseList;
25
let textCache;
26
let loadCache;
27
let writable;
@@ -74,6 +75,7 @@ describe('ReactDOMFloat', () => {
75
ReactDOMFizzServer = require('react-dom/server');
76
Stream = require('stream');
77
Suspense = React.Suspense;
78
+ SuspenseList = React.unstable_SuspenseList;
79
Scheduler = require('scheduler/unstable_mock');
80
81
const InternalTestUtils = require('internal-test-utils');
@@ -5746,6 +5748,181 @@ body {
5748
);
5749
});
5750
5751
+ // @gate enableSuspenseList
5752
+ it('delays "forwards" SuspenseList rows until the css of previous rows have completed', async () => {
5753
+ await act(() => {
5754
+ renderToPipeableStream(
5755
+ <html>
5756
+ <body>
5757
+ <Suspense fallback="loading...">
5758
+ <SuspenseList revealOrder="forwards">
5759
+ <Suspense fallback="loading foo...">
5760
+ <BlockedOn value="foo">
5761
+ <link rel="stylesheet" href="foo" precedence="foo" />
5762
+ foo
5763
+ </BlockedOn>
5764
+ </Suspense>
5765
+ <Suspense fallback="loading bar...">bar</Suspense>
5766
+ <BlockedOn value="bar">
5767
+ <Suspense fallback="loading baz...">
5768
+ <BlockedOn value="baz">baz</BlockedOn>
5769
+ </Suspense>
5770
+ </BlockedOn>
5771
+ </SuspenseList>
5772
+ </Suspense>
5773
+ </body>
5774
+ </html>,
5775
+ ).pipe(writable);
5776
+ });
5777
+
5778
+ expect(getMeaningfulChildren(document)).toEqual(
5779
+ <html>
5780
+ <head />
5781
+ <body>loading...</body>
5782
+ </html>,
5783
+ );
5784
+
5785
+ // unblock css loading
5786
+ await act(() => {
5787
+ resolveText('foo');
5788
+ });
5789
+
5790
+ // bar is still blocking the whole list
5791
+ expect(getMeaningfulChildren(document)).toEqual(
5792
+ <html>
5793
+ <head>
5794
+ <link rel="stylesheet" href="foo" data-precedence="foo" />
5795
+ </head>
5796
+ <body>
5797
+ {'loading...'}
5798
+ <link as="style" href="foo" rel="preload" />
5799
+ </body>
5800
+ </html>,
5801
+ );
5802
+
5803
+ // unblock inner loading states
5804
+ await act(() => {
5805
+ resolveText('bar');
5806
+ });
5807
+
5808
+ expect(getMeaningfulChildren(document)).toEqual(
5809
+ <html>
5810
+ <head>
5811
+ <link rel="stylesheet" href="foo" data-precedence="foo" />
5812
+ </head>
5813
+ <body>
5814
+ {'loading foo...'}
5815
+ {'loading bar...'}
5816
+ {'loading baz...'}
5817
+ <link as="style" href="foo" rel="preload" />
5818
+ </body>
5819
+ </html>,
5820
+ );
5821
+
5822
+ // resolve the last boundary
5823
+ await act(() => {
5824
+ resolveText('baz');
5825
+ });
5826
+
5827
+ // still blocked on the css of the first row
5828
+ expect(getMeaningfulChildren(document)).toEqual(
5829
+ <html>
5830
+ <head>
5831
+ <link rel="stylesheet" href="foo" data-precedence="foo" />
5832
+ </head>
5833
+ <body>
5834
+ {'loading foo...'}
5835
+ {'loading bar...'}
5836
+ {'loading baz...'}
5837
+ <link as="style" href="foo" rel="preload" />
5838
+ </body>
5839
+ </html>,
5840
+ );
5841
+
5842
+ await act(() => {
5843
+ loadStylesheets();
5844
+ });
5845
+ await assertLog(['load stylesheet: foo']);
5846
+ expect(getMeaningfulChildren(document)).toEqual(
5847
+ <html>
5848
+ <head>
5849
+ <link rel="stylesheet" href="foo" data-precedence="foo" />
5850
+ </head>
5851
+ <body>
5852
+ {'foo'}
5853
+ {'bar'}
5854
+ {'baz'}
5855
+ <link as="style" href="foo" rel="preload" />
5856
+ </body>
5857
+ </html>,
5858
+ );
5859
+ });
5860
+
5861
+ // @gate enableSuspenseList
5862
+ it('delays "together" SuspenseList rows until the css of previous rows have completed', async () => {
5863
+ await act(() => {
5864
+ renderToPipeableStream(
5865
+ <html>
5866
+ <body>
5867
+ <SuspenseList revealOrder="together">
5868
+ <Suspense fallback="loading foo...">
5869
+ <BlockedOn value="foo">
5870
+ <link rel="stylesheet" href="foo" precedence="foo" />
5871
+ foo
5872
+ </BlockedOn>
5873
+ </Suspense>
5874
+ <Suspense fallback="loading bar...">bar</Suspense>
5875
+ </SuspenseList>
5876
+ </body>
5877
+ </html>,
5878
+ ).pipe(writable);
5879
+ });
5880
+
5881
+ expect(getMeaningfulChildren(document)).toEqual(
5882
+ <html>
5883
+ <head />
5884
+ <body>
5885
+ {'loading foo...'}
5886
+ {'loading bar...'}
5887
+ </body>
5888
+ </html>,
5889
+ );
5890
+
5891
+ await act(() => {
5892
+ resolveText('foo');
5893
+ });
5894
+
5895
+ expect(getMeaningfulChildren(document)).toEqual(
5896
+ <html>
5897
+ <head>
5898
+ <link rel="stylesheet" href="foo" data-precedence="foo" />
5899
+ </head>
5900
+ <body>
5901
+ {'loading foo...'}
5902
+ {'loading bar...'}
5903
+ <link as="style" href="foo" rel="preload" />
5904
+ </body>
5905
+ </html>,
5906
+ );
5907
+
5908
+ await act(() => {
5909
+ loadStylesheets();
5910
+ });
5911
+ await assertLog(['load stylesheet: foo']);
5912
+ expect(getMeaningfulChildren(document)).toEqual(
5913
+ <html>
5914
+ <head>
5915
+ <link rel="stylesheet" href="foo" data-precedence="foo" />
5916
+ </head>
5917
+ <body>
5918
+ {'foo'}
5919
+ {'bar'}
5920
+ <link as="style" href="foo" rel="preload" />
5921
+ </body>
5922
+ </html>,
5923
+ );
5924
+ });
5925
+
5926
describe('ReactDOM.preconnect(href, { crossOrigin })', () => {
5927
it('creates a preconnect resource when called', async () => {
5928
function App({url}) {
packages/react-server/src/ReactFizzServer.js
+33
-5
@@ -236,6 +236,8 @@ type LegacyContext = {
236
type SuspenseListRow = {
237
pendingTasks: number, // The number of tasks, previous rows and inner suspense boundaries blocking this row.
238
boundaries: null | Array<SuspenseBoundary>, // The boundaries in this row waiting to be unblocked by the previous row. (null means this row is not blocked)
239
+ hoistables: HoistableState, // Any dependencies that this row depends on. Future rows need to also depend on it.
240
+ inheritedHoistables: null | HoistableState, // Any dependencies that previous row depend on, that new boundaries of this row needs.
241
together: boolean, // All the boundaries within this row must be revealed together.
242
next: null | SuspenseListRow, // The next row blocked by this one.
243
};
@@ -790,6 +792,10 @@ function createSuspenseBoundary(
792
boundary.pendingTasks++;
793
blockedBoundaries.push(boundary);
794
}
795
+ const inheritedHoistables = row.inheritedHoistables;
796
+ if (inheritedHoistables !== null) {
797
+ hoistHoistables(boundary.contentState, inheritedHoistables);
798
+ }
799
}
800
return boundary;
801
}
@@ -1676,22 +1682,36 @@ function replaySuspenseBoundary(
1682
1683
function finishSuspenseListRow(request: Request, row: SuspenseListRow): void {
1684
// This row finished. Now we have to unblock all the next rows that were blocked on this.
1679
- unblockSuspenseListRow(request, row.next);
1685
+ unblockSuspenseListRow(request, row.next, row.hoistables);
1686
}
1687
1688
function unblockSuspenseListRow(
1689
request: Request,
1690
unblockedRow: null | SuspenseListRow,
1691
+ inheritedHoistables: null | HoistableState,
1692
): void {
1693
// We do this in a loop to avoid stack overflow for very long lists that get unblocked.
1694
while (unblockedRow !== null) {
1695
+ if (inheritedHoistables !== null) {
1696
+ // Hoist any hoistables from the previous row into the next row so that it can be
1697
+ // later transferred to all the rows.
1698
+ hoistHoistables(unblockedRow.hoistables, inheritedHoistables);
1699
+ // Mark the row itself for any newly discovered Suspense boundaries to inherit.
1700
+ // This is different from hoistables because that also includes hoistables from
1701
+ // all the boundaries below this row and not just previous rows.
1702
+ unblockedRow.inheritedHoistables = inheritedHoistables;
1703
+ }
1704
// Unblocking the boundaries will decrement the count of this row but we keep it above
1705
// zero so they never finish this row recursively.
1706
const unblockedBoundaries = unblockedRow.boundaries;
1707
if (unblockedBoundaries !== null) {
1708
unblockedRow.boundaries = null;
1709
for (let i = 0; i < unblockedBoundaries.length; i++) {
1694
- finishedTask(request, unblockedBoundaries[i], null, null);
1710
+ const unblockedBoundary = unblockedBoundaries[i];
1711
+ if (inheritedHoistables !== null) {
1712
+ hoistHoistables(unblockedBoundary.contentState, inheritedHoistables);
1713
+ }
1714
+ finishedTask(request, unblockedBoundary, null, null);
1715
}
1716
}
1717
// Instead we decrement at the end to keep it all in this loop.
@@ -1700,6 +1720,7 @@ function unblockSuspenseListRow(
1720
// Still blocked.
1721
break;
1722
}
1723
+ inheritedHoistables = unblockedRow.hoistables;
1724
unblockedRow = unblockedRow.next;
1725
}
1726
}
@@ -1728,7 +1749,7 @@ function tryToResolveTogetherRow(
1749
}
1750
}
1751
if (allCompleteAndInlinable) {
1731
- unblockSuspenseListRow(request, togetherRow);
1752
+ unblockSuspenseListRow(request, togetherRow, togetherRow.hoistables);
1753
}
1754
}
1755
@@ -1738,6 +1759,8 @@ function createSuspenseListRow(
1759
const newRow: SuspenseListRow = {
1760
pendingTasks: 1, // At first the row is blocked on attempting rendering itself.
1761
boundaries: null,
1762
+ hoistables: createHoistableState(),
1763
+ inheritedHoistables: null,
1764
together: false,
1765
next: null,
1766
};
@@ -4869,10 +4892,15 @@ function finishedTask(
4892
// If the boundary is eligible to be outlined during flushing we can't cancel the fallback
4893
// since we might need it when it's being outlined.
4894
if (boundary.status === COMPLETED) {
4895
+ const boundaryRow = boundary.row;
4896
+ if (boundaryRow !== null) {
4897
+ // Hoist the HoistableState from the boundary to the row so that the next rows
4898
+ // can depend on the same dependencies.
4899
+ hoistHoistables(boundaryRow.hoistables, boundary.contentState);
4900
+ }
4901
if (!isEligibleForOutlining(request, boundary)) {
4902
boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request);
4903
boundary.fallbackAbortableTasks.clear();
4875
- const boundaryRow = boundary.row;
4904
if (boundaryRow !== null) {
4905
// If we aren't eligible for outlining, we don't have to wait until we flush it.
4906
if (--boundaryRow.pendingTasks === 0) {
@@ -5679,7 +5707,7 @@ function flushPartialBoundary(
5707
// unblock the boundary itself which can issue its complete instruction.
5708
// TODO: Ideally the complete instruction would be in a single <script> tag.
5709
if (row.pendingTasks === 1) {
5682
- unblockSuspenseListRow(request, row);
5710
+ unblockSuspenseListRow(request, row, row.hoistables);
5711
} else {
5712
row.pendingTasks--;
5713
}