diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js index fd1791ff2c8..4e40bc4f207 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js @@ -172,7 +172,6 @@ export { completeResumableState, emitEarlyPreloads, supportsClientAPIs, - canHavePreamble, hoistPreambleState, isPreambleReady, isPreambleContext, @@ -194,6 +193,10 @@ export function getViewTransitionFormatContext( return parentContext; } +export function canHavePreamble(formatContext: FormatContext): boolean { + return false; +} + export function pushTextInstance( target: Array, text: string, diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js index 3a2d854d332..977d2dbf154 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js @@ -27,9 +27,10 @@ let writable; let container; let buffer = ''; let hasErrored = false; +let hasCompleted = false; let fatalError = undefined; -describe('ReactDOMFizSuspenseList', () => { +describe('ReactDOMFizzSuspenseList', () => { beforeEach(() => { jest.resetModules(); JSDOM = require('jsdom').JSDOM; @@ -59,6 +60,7 @@ describe('ReactDOMFizSuspenseList', () => { buffer = ''; hasErrored = false; + hasCompleted = false; writable = new Stream.PassThrough(); writable.setEncoding('utf8'); @@ -69,6 +71,9 @@ describe('ReactDOMFizSuspenseList', () => { hasErrored = true; fatalError = error; }); + writable.on('finish', () => { + hasCompleted = true; + }); }); afterEach(() => { @@ -103,7 +108,12 @@ describe('ReactDOMFizSuspenseList', () => { function createAsyncText(text) { let resolved = false; + let error = undefined; const Component = function () { + if (error !== undefined) { + Scheduler.log('Error! [' + error.message + ']'); + throw error; + } if (!resolved) { Scheduler.log('Suspend! [' + text + ']'); throw promise; @@ -115,6 +125,10 @@ describe('ReactDOMFizSuspenseList', () => { resolved = true; return resolve(); }; + Component.reject = function (e) { + error = e; + return resolve(); + }; }); return Component; } @@ -183,6 +197,323 @@ describe('ReactDOMFizSuspenseList', () => { ); }); + // @gate enableSuspenseList + it('displays all "together"', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + + function Foo() { + return ( +
+ + }> + + + }> + + + }> + + + +
+ ); + } + + await A.resolve(); + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + assertLog([ + 'A', + 'Suspend! [B]', + 'Suspend! [C]', + 'Loading A', + 'Loading B', + 'Loading C', + ]); + + expect(getVisibleChildren(container)).toEqual( +
+ Loading A + Loading B + Loading C +
, + ); + + await serverAct(() => B.resolve()); + assertLog(['B']); + + expect(getVisibleChildren(container)).toEqual( +
+ Loading A + Loading B + Loading C +
, + ); + + await serverAct(() => C.resolve()); + assertLog(['C']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + B + C +
, + ); + }); + + // @gate enableSuspenseList + it('displays all "together" in a single pass', async () => { + function Foo() { + return ( +
+ + }> + + + }> + + + }> + + + +
+ ); + } + + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + await 0; + const bufferedContent = buffer; + buffer = ''; + + assertLog(['A', 'B', 'C', 'Loading A', 'Loading B', 'Loading C']); + + expect(bufferedContent).toMatchInlineSnapshot( + `"
ABC
"`, + ); + }); + + // @gate enableSuspenseList + it('displays all "together" even when nested as siblings', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + + function Foo() { + return ( +
+ +
+ }> + + + }> + + +
+
+ }> + + +
+ +
+ ); + } + + await A.resolve(); + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + assertLog([ + 'A', + 'Suspend! [B]', + 'Suspend! [C]', + 'Loading A', + 'Loading B', + 'Loading C', + ]); + + expect(getVisibleChildren(container)).toEqual( +
+
+ Loading A + Loading B +
+
+ Loading C +
+
, + ); + + await serverAct(() => B.resolve()); + assertLog(['B']); + + expect(getVisibleChildren(container)).toEqual( +
+
+ Loading A + Loading B +
+
+ Loading C +
+
, + ); + + await serverAct(() => C.resolve()); + assertLog(['C']); + + expect(getVisibleChildren(container)).toEqual( +
+
+ A + B +
+
+ C +
+
, + ); + }); + + // @gate enableSuspenseList + it('displays all "together" in nested SuspenseLists', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + + function Foo() { + return ( +
+ + }> + + + + }> + + + }> + + + + +
+ ); + } + + await A.resolve(); + await B.resolve(); + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + assertLog([ + 'A', + 'B', + 'Suspend! [C]', + 'Loading A', + 'Loading B', + 'Loading C', + ]); + + expect(getVisibleChildren(container)).toEqual( +
+ Loading A + Loading B + Loading C +
, + ); + + await serverAct(() => C.resolve()); + assertLog(['C']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + B + C +
, + ); + }); + + // @gate enableSuspenseList + it('displays all "together" in nested SuspenseLists where the inner is default', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + + function Foo() { + return ( +
+ + }> + + + + }> + + + }> + + + + +
+ ); + } + + await A.resolve(); + await B.resolve(); + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + assertLog([ + 'A', + 'B', + 'Suspend! [C]', + 'Loading A', + 'Loading B', + 'Loading C', + ]); + + expect(getVisibleChildren(container)).toEqual( +
+ Loading A + Loading B + Loading C +
, + ); + + await serverAct(() => C.resolve()); + assertLog(['C']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + B + C +
, + ); + }); + // @gate enableSuspenseList it('displays each items in "forwards" order', async () => { const A = createAsyncText('A'); @@ -397,4 +728,120 @@ describe('ReactDOMFizSuspenseList', () => { , ); }); + + // @gate enableSuspenseList + it('can abort a pending SuspenseList', async () => { + const A = createAsyncText('A'); + + function Foo() { + return ( +
+ ); + } + + const errors = []; + let abortStream; + await serverAct(async () => { + const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(, { + onError(error) { + errors.push(error.message); + }, + }); + pipe(writable); + abortStream = abort; + }); + + assertLog([ + 'Suspend! [A]', + 'B', // TODO: Defer rendering the content after fallback if previous suspended, + 'Loading A', + 'Loading B', + ]); + + expect(getVisibleChildren(container)).toEqual( +
+ Loading A + Loading B +
, + ); + + await serverAct(() => { + abortStream(); + }); + + expect(hasCompleted).toBe(true); + expect(errors).toEqual([ + 'The render was aborted by the server without a reason.', + ]); + }); + + // @gate enableSuspenseList + it('can error a pending SuspenseList', async () => { + const A = createAsyncText('A'); + + function Foo() { + return ( +
+ ); + } + + const errors = []; + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(, { + onError(error) { + errors.push(error.message); + }, + }); + pipe(writable); + }); + + assertLog([ + 'Suspend! [A]', + 'B', // TODO: Defer rendering the content after fallback if previous suspended, + 'Loading A', + 'Loading B', + ]); + + expect(getVisibleChildren(container)).toEqual( +
+ Loading A + Loading B +
, + ); + + await serverAct(async () => { + A.reject(new Error('hi')); + }); + + assertLog(['Error! [hi]']); + + expect(getVisibleChildren(container)).toEqual( +
+ Loading A + B +
, + ); + + expect(errors).toEqual(['hi']); + expect(hasErrored).toBe(false); + expect(hasCompleted).toBe(true); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 2d3f1f0b8b6..c2126b12f72 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 70e61f42c6e..7f47e8e4d5c 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -236,6 +236,9 @@ 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. }; @@ -789,6 +792,10 @@ function createSuspenseBoundary( boundary.pendingTasks++; blockedBoundaries.push(boundary); } + const inheritedHoistables = row.inheritedHoistables; + if (inheritedHoistables !== null) { + hoistHoistables(boundary.contentState, inheritedHoistables); + } } return boundary; } @@ -1404,6 +1411,11 @@ function renderSuspenseBoundary( } return; } + } else { + const boundaryRow = prevRow; + if (boundaryRow !== null && boundaryRow.together) { + tryToResolveTogetherRow(request, boundaryRow); + } } } catch (thrownValue: mixed) { newBoundary.status = CLIENT_RENDERED; @@ -1670,16 +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, 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. - let unblockedRow = row.next; 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. @@ -1688,16 +1720,48 @@ function finishSuspenseListRow(request: Request, row: SuspenseListRow): void { // Still blocked. break; } + inheritedHoistables = unblockedRow.hoistables; unblockedRow = unblockedRow.next; } } +function tryToResolveTogetherRow( + request: Request, + togetherRow: SuspenseListRow, +): void { + // If we have a "together" row and all the pendingTasks are really the boundaries themselves, + // and we won't outline any of them then we can unblock this row early so that we can inline + // all the boundaries at once. + const boundaries = togetherRow.boundaries; + if (boundaries === null || togetherRow.pendingTasks !== boundaries.length) { + return; + } + let allCompleteAndInlinable = true; + for (let i = 0; i < boundaries.length; i++) { + const rowBoundary = boundaries[i]; + if ( + rowBoundary.pendingTasks !== 1 || + rowBoundary.parentFlushed || + isEligibleForOutlining(request, rowBoundary) + ) { + allCompleteAndInlinable = false; + break; + } + } + if (allCompleteAndInlinable) { + unblockSuspenseListRow(request, togetherRow, togetherRow.hoistables); + } +} + function createSuspenseListRow( previousRow: null | SuspenseListRow, ): SuspenseListRow { 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, }; if (previousRow !== null && previousRow.pendingTasks > 0) { @@ -1978,7 +2042,27 @@ function renderSuspenseList( } if (revealOrder === 'together') { - // TODO + const prevKeyPath = task.keyPath; + const prevRow = task.row; + const newRow = (task.row = createSuspenseListRow(null)); + // This will cause boundaries to block on this row, but there's nothing to + // unblock them. We'll use the partial flushing pass to unblock them. + newRow.boundaries = []; + newRow.together = true; + task.keyPath = keyPath; + renderNodeDestructive(request, task, children, -1); + if (--newRow.pendingTasks === 0) { + finishSuspenseListRow(request, newRow); + } + task.keyPath = prevKeyPath; + task.row = prevRow; + if (prevRow !== null && newRow.pendingTasks > 0) { + // If we are part of an outer SuspenseList and our row is still pending, then that blocks + // the parent row from completing. We can continue the chain. + prevRow.pendingTasks++; + newRow.next = prevRow; + } + return; } // For other reveal order modes, we just render it as a fragment. const prevKeyPath = task.keyPath; @@ -4308,6 +4392,14 @@ function erroredTask( encodeErrorForBoundary(boundary, errorDigest, error, errorInfo, false); untrackBoundary(request, boundary); + const boundaryRow = boundary.row; + if (boundaryRow !== null) { + // Unblock the SuspenseListRow that was blocked by this boundary. + if (--boundaryRow.pendingTasks === 0) { + finishSuspenseListRow(request, boundaryRow); + } + } + // Regardless of what happens next, this boundary won't be displayed, // so we can flush it, if the parent already flushed. if (boundary.parentFlushed) { @@ -4460,13 +4552,6 @@ function abortTask(task: Task, request: Request, error: mixed): void { segment.status = ABORTED; } - const row = task.row; - if (row !== null) { - if (--row.pendingTasks === 0) { - finishSuspenseListRow(request, row); - } - } - const errorInfo = getThrownInfo(task.componentStack); if (boundary === null) { @@ -4489,7 +4574,7 @@ function abortTask(task: Task, request: Request, error: mixed): void { // we just need to mark it as postponed. logPostpone(request, postponeInstance.message, errorInfo, null); trackPostpone(request, trackedPostpones, task, segment); - finishedTask(request, null, row, segment); + finishedTask(request, null, task.row, segment); } else { const fatal = new Error( 'The render was aborted with postpone when the shell is incomplete. Reason: ' + @@ -4508,7 +4593,7 @@ function abortTask(task: Task, request: Request, error: mixed): void { // We log the error but we still resolve the prerender logRecoverableError(request, error, errorInfo, null); trackPostpone(request, trackedPostpones, task, segment); - finishedTask(request, null, row, segment); + finishedTask(request, null, task.row, segment); } else { logRecoverableError(request, error, errorInfo, null); fatalError(request, error, errorInfo, null); @@ -4552,7 +4637,6 @@ function abortTask(task: Task, request: Request, error: mixed): void { } } } else { - boundary.pendingTasks--; // We construct an errorInfo from the boundary's componentStack so the error in dev will indicate which // boundary the message is referring to const trackedPostpones = request.trackedPostpones; @@ -4580,7 +4664,7 @@ function abortTask(task: Task, request: Request, error: mixed): void { abortTask(fallbackTask, request, error), ); boundary.fallbackAbortableTasks.clear(); - return finishedTask(request, boundary, row, segment); + return finishedTask(request, boundary, task.row, segment); } } boundary.status = CLIENT_RENDERED; @@ -4597,7 +4681,7 @@ function abortTask(task: Task, request: Request, error: mixed): void { logPostpone(request, postponeInstance.message, errorInfo, null); if (request.trackedPostpones !== null && segment !== null) { trackPostpone(request, request.trackedPostpones, task, segment); - finishedTask(request, task.blockedBoundary, row, segment); + finishedTask(request, task.blockedBoundary, task.row, segment); // If this boundary was still pending then we haven't already cancelled its fallbacks. // We'll need to abort the fallbacks, which will also error that parent boundary. @@ -4622,6 +4706,16 @@ function abortTask(task: Task, request: Request, error: mixed): void { } } + boundary.pendingTasks--; + + const boundaryRow = boundary.row; + if (boundaryRow !== null) { + // Unblock the SuspenseListRow that was blocked by this boundary. + if (--boundaryRow.pendingTasks === 0) { + finishSuspenseListRow(request, boundaryRow); + } + } + // If this boundary was still pending then we haven't already cancelled its fallbacks. // We'll need to abort the fallbacks, which will also error that parent boundary. boundary.fallbackAbortableTasks.forEach(fallbackTask => @@ -4630,6 +4724,13 @@ function abortTask(task: Task, request: Request, error: mixed): void { boundary.fallbackAbortableTasks.clear(); } + const row = task.row; + if (row !== null) { + if (--row.pendingTasks === 0) { + finishSuspenseListRow(request, row); + } + } + request.allPendingTasks--; if (request.allPendingTasks === 0) { completeAll(request); @@ -4759,6 +4860,8 @@ function finishedTask( if (row !== null) { if (--row.pendingTasks === 0) { finishSuspenseListRow(request, row); + } else if (row.together) { + tryToResolveTogetherRow(request, row); } } request.allPendingTasks--; @@ -4806,10 +4909,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) { @@ -4848,6 +4956,10 @@ function finishedTask( } } } + const boundaryRow = boundary.row; + if (boundaryRow !== null && boundaryRow.together) { + tryToResolveTogetherRow(request, boundaryRow); + } } } @@ -5604,6 +5716,20 @@ function flushPartialBoundary( } completedSegments.splice(0, i); + const row = boundary.row; + if (row !== null && row.together && boundary.pendingTasks === 1) { + // "together" rows are blocked on their own boundaries. + // We have now flushed all the boundary's segments as partials. + // We can now unblock it from blocking the row that will eventually + // unblock the boundary itself which can issue its complete instruction. + // TODO: Ideally the complete instruction would be in a single