Repository: facebook/react
Pull request: [Fizz] Hoist hoistables to each row and transfer the dependencies to future rows

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.

Changed files:
filename: packages/react-dom/src/__tests__/ReactDOMFloat-test.js
patch: @@ -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(
+        <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}) {

filename: packages/react-server/src/ReactFizzServer.js
patch: @@ -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.
 };
@@ -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 <script> tag.
     if (row.pendingTasks === 1) {
-      unblockSuspenseListRow(request, row);
+      unblockSuspenseListRow(request, row, row.hoistables);
     } else {
       row.pendingTasks--;
     }