@samitouri / QOS-React-1 / commits / 50389e1792

[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 }