diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 2d3f1f0b8b621..c2126b12f7237 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,181 @@ body { ); }); + // @gate enableSuspenseList + it('delays "forwards" SuspenseList rows until the css of previous rows have completed', async () => { + await act(() => { + renderToPipeableStream( + + + + + + + + foo + + + bar + + + baz + + + + + + , + ).pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + loading... + , + ); + + // unblock css loading + await act(() => { + resolveText('foo'); + }); + + // bar is still blocking the whole list + expect(getMeaningfulChildren(document)).toEqual( + + + + + + {'loading...'} + + + , + ); + + // unblock inner loading states + await act(() => { + resolveText('bar'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + {'loading foo...'} + {'loading bar...'} + {'loading baz...'} + + + , + ); + + // resolve the last boundary + await act(() => { + resolveText('baz'); + }); + + // still blocked on the css of the first row + expect(getMeaningfulChildren(document)).toEqual( + + + + + + {'loading foo...'} + {'loading bar...'} + {'loading baz...'} + + + , + ); + + await act(() => { + loadStylesheets(); + }); + await assertLog(['load stylesheet: foo']); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + {'foo'} + {'bar'} + {'baz'} + + + , + ); + }); + + // @gate enableSuspenseList + it('delays "together" 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}) { diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index f28119dff3ecf..3f42b5d687e46 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -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, // 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