From e32dda7f05c24e630d06724f7bbb57b0c81d5e1c Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 19 May 2025 21:49:05 -0400 Subject: [PATCH 1/4] Test --- .../src/__tests__/ReactDOMFloat-test.js | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 2d3f1f0b8b621..58319331b00b7 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,71 @@ body { ); }); + // @gate enableSuspenseList + it('delays "forwards" 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}) { From 964561bcb401c24ffb8819d8b36470730b78f773 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 19 May 2025 22:35:57 -0400 Subject: [PATCH 2/4] Hoist hoistables to each row and transfer the dependencies to future rows --- packages/react-server/src/ReactFizzServer.js | 28 ++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index f28119dff3ecf..119544fd641a1 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -236,6 +236,7 @@ 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. together: boolean, // All the boundaries within this row must be revealed together. next: null | SuspenseListRow, // The next row blocked by this one. }; @@ -1676,22 +1677,32 @@ 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); + } // 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 +1711,7 @@ function unblockSuspenseListRow( // Still blocked. break; } + inheritedHoistables = unblockedRow.hoistables; unblockedRow = unblockedRow.next; } } @@ -1728,7 +1740,7 @@ function tryToResolveTogetherRow( } } if (allCompleteAndInlinable) { - unblockSuspenseListRow(request, togetherRow); + unblockSuspenseListRow(request, togetherRow, null); } } @@ -1738,6 +1750,7 @@ function createSuspenseListRow( const newRow: SuspenseListRow = { pendingTasks: 1, // At first the row is blocked on attempting rendering itself. boundaries: null, + hoistables: createHoistableState(), together: false, next: null, }; @@ -4869,10 +4882,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 +5697,7 @@ function flushPartialBoundary( // unblock the boundary itself which can issue its complete instruction. // TODO: Ideally the complete instruction would be in a single