diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index b988bd72caff..cf3bf02bb5d4 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -10884,4 +10884,34 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue , ); }); + + // @gate enableCPUSuspense + it('outlines deferred Suspense boundaries', async () => { + function Log({text}) { + Scheduler.log(text); + return text; + } + + await act(async () => { + renderToPipeableStream( +
+ }> + {} + +
, + ).pipe(writable); + await jest.runAllTimers(); + const temp = document.createElement('body'); + temp.innerHTML = buffer; + expect(getVisibleChildren(temp)).toEqual(
Waiting
); + }); + + assertLog(['Waiting', 'hello']); + + expect(getVisibleChildren(container)).toEqual( +
+ hello +
, + ); + }); }); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 7251e52dd610..d9b64e93e767 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -326,6 +326,7 @@ export let didWarnAboutReassigningProps: boolean; let didWarnAboutRevealOrder; let didWarnAboutTailOptions; let didWarnAboutClassNameOnViewTransition; +let didWarnAboutExpectedLoadTime = false; if (__DEV__) { didWarnAboutBadClass = ({}: {[string]: boolean}); @@ -2459,8 +2460,20 @@ function updateSuspenseComponent( return bailoutOffscreenComponent(null, primaryChildFragment); } else if ( enableCPUSuspense && - typeof nextProps.unstable_expectedLoadTime === 'number' + (typeof nextProps.unstable_expectedLoadTime === 'number' || + nextProps.defer === true) ) { + if (__DEV__) { + if (typeof nextProps.unstable_expectedLoadTime === 'number') { + if (!didWarnAboutExpectedLoadTime) { + didWarnAboutExpectedLoadTime = true; + console.error( + ' is deprecated. ' + + 'Use instead.', + ); + } + } + } // This is a CPU-bound tree. Skip this tree and show a placeholder to // unblock the surrounding content. Then immediately retry after the // initial commit. diff --git a/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js b/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js index 1b1489de33a4..7d4ebadc62d3 100644 --- a/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js +++ b/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js @@ -1,3 +1,5 @@ +/* eslint-disable react/jsx-boolean-value */ + let React; let ReactNoop; let Scheduler; @@ -11,6 +13,7 @@ let resolveText; // let rejectText; let assertLog; +let assertConsoleErrorDev; let waitForPaint; describe('ReactSuspenseWithNoopRenderer', () => { @@ -26,6 +29,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { const InternalTestUtils = require('internal-test-utils'); assertLog = InternalTestUtils.assertLog; + assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev; waitForPaint = InternalTestUtils.waitForPaint; textCache = new Map(); @@ -116,14 +120,14 @@ describe('ReactSuspenseWithNoopRenderer', () => { } // @gate enableCPUSuspense - it('skips CPU-bound trees on initial mount', async () => { + it('warns for the old name is used', async () => { function App() { return ( <>
}> @@ -132,6 +136,49 @@ describe('ReactSuspenseWithNoopRenderer', () => { ); } + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + await waitForPaint(['Outer', 'Loading...']); + assertConsoleErrorDev([ + ' is deprecated. ' + + 'Use instead.' + + '\n in Suspense (at **)' + + '\n in App (at **)', + ]); + expect(root).toMatchRenderedOutput( + <> + Outer +
Loading...
+ , + ); + }); + + // Inner contents finish in separate commit from outer + assertLog(['Inner']); + expect(root).toMatchRenderedOutput( + <> + Outer +
Inner
+ , + ); + }); + + // @gate enableCPUSuspense + it('skips CPU-bound trees on initial mount', async () => { + function App() { + return ( + <> + +
+ }> + + +
+ + ); + } + const root = ReactNoop.createRoot(); await act(async () => { root.render(); @@ -164,9 +211,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { <>
- }> + }>
@@ -209,9 +254,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { <>
- }> + }>
@@ -263,14 +306,10 @@ describe('ReactSuspenseWithNoopRenderer', () => { <>
- }> + }>
- }> + }>
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 07408d64e881..aef1377cfd0f 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -183,6 +183,7 @@ import { enableViewTransition, enableFizzBlockingRender, enableAsyncDebugInfo, + enableCPUSuspense, } from 'shared/ReactFeatureFlags'; import assign from 'shared/assign'; @@ -253,6 +254,7 @@ type SuspenseBoundary = { row: null | SuspenseListRow, // the row that this boundary blocks from completing. completedSegments: Array, // completed but not yet flushed segments. byteSize: number, // used to determine whether to inline children boundaries. + defer: boolean, // never inline deferred boundaries fallbackAbortableTasks: Set, // used to cancel task on the fallback if the boundary completes or gets canceled. contentState: HoistableState, fallbackState: HoistableState, @@ -462,7 +464,9 @@ function isEligibleForOutlining( // The larger this limit is, the more we can save on preparing fallbacks in case we end up // outlining. return ( - (boundary.byteSize > 500 || hasSuspenseyContent(boundary.contentState)) && + (boundary.byteSize > 500 || + hasSuspenseyContent(boundary.contentState) || + boundary.defer) && // For boundaries that can possibly contribute to the preamble we don't want to outline // them regardless of their size since the fallbacks should only be emitted if we've // errored the boundary. @@ -798,6 +802,7 @@ function createSuspenseBoundary( fallbackAbortableTasks: Set, contentPreamble: null | Preamble, fallbackPreamble: null | Preamble, + defer: boolean, ): SuspenseBoundary { const boundary: SuspenseBoundary = { status: PENDING, @@ -807,6 +812,7 @@ function createSuspenseBoundary( row: row, completedSegments: [], byteSize: 0, + defer: defer, fallbackAbortableTasks, errorDigest: null, contentState: createHoistableState(), @@ -1315,6 +1321,7 @@ function renderSuspenseBoundary( // in case it ends up generating a large subtree of content. const fallback: ReactNodeList = props.fallback; const content: ReactNodeList = props.children; + const defer: boolean = enableCPUSuspense && props.defer === true; const fallbackAbortSet: Set = new Set(); let newBoundary: SuspenseBoundary; @@ -1325,6 +1332,7 @@ function renderSuspenseBoundary( fallbackAbortSet, createPreambleState(), createPreambleState(), + defer, ); } else { newBoundary = createSuspenseBoundary( @@ -1333,6 +1341,7 @@ function renderSuspenseBoundary( fallbackAbortSet, null, null, + defer, ); } if (request.trackedPostpones !== null) { @@ -1368,29 +1377,32 @@ function renderSuspenseBoundary( // no parent segment so there's nothing to wait on. contentRootSegment.parentFlushed = true; - if (request.trackedPostpones !== null) { + const trackedPostpones = request.trackedPostpones; + if (trackedPostpones !== null || defer) { + // This is a prerender or deferred boundary. In this mode we want to render the fallback synchronously + // and schedule the content to render later. This is the opposite of what we do during a normal render + // where we try to skip rendering the fallback if the content itself can render synchronously + // Stash the original stack frame. const suspenseComponentStack = task.componentStack; - // This is a prerender. In this mode we want to render the fallback synchronously and schedule - // the content to render later. This is the opposite of what we do during a normal render - // where we try to skip rendering the fallback if the content itself can render synchronously - const trackedPostpones = request.trackedPostpones; const fallbackKeyPath: KeyNode = [ keyPath[0], 'Suspense Fallback', keyPath[2], ]; - const fallbackReplayNode: ReplayNode = [ - fallbackKeyPath[1], - fallbackKeyPath[2], - ([]: Array), - null, - ]; - trackedPostpones.workingMap.set(fallbackKeyPath, fallbackReplayNode); - // We are rendering the fallback before the boundary content so we keep track of - // the fallback replay node until we determine if the primary content suspends - newBoundary.trackedFallbackNode = fallbackReplayNode; + if (trackedPostpones !== null) { + const fallbackReplayNode: ReplayNode = [ + fallbackKeyPath[1], + fallbackKeyPath[2], + ([]: Array), + null, + ]; + trackedPostpones.workingMap.set(fallbackKeyPath, fallbackReplayNode); + // We are rendering the fallback before the boundary content so we keep track of + // the fallback replay node until we determine if the primary content suspends + newBoundary.trackedFallbackNode = fallbackReplayNode; + } task.blockedSegment = boundarySegment; task.blockedPreamble = newBoundary.fallbackPreamble; @@ -1639,6 +1651,7 @@ function replaySuspenseBoundary( const content: ReactNodeList = props.children; const fallback: ReactNodeList = props.fallback; + const defer: boolean = enableCPUSuspense && props.defer === true; const fallbackAbortSet: Set = new Set(); let resumedBoundary: SuspenseBoundary; @@ -1649,6 +1662,7 @@ function replaySuspenseBoundary( fallbackAbortSet, createPreambleState(), createPreambleState(), + defer, ); } else { resumedBoundary = createSuspenseBoundary( @@ -1657,6 +1671,7 @@ function replaySuspenseBoundary( fallbackAbortSet, null, null, + defer, ); } resumedBoundary.parentFlushed = true; @@ -4552,6 +4567,7 @@ function abortRemainingSuspenseBoundary( new Set(), null, null, + false, ); resumedBoundary.parentFlushed = true; // We restore the same id of this boundary as was used during prerender. @@ -5759,7 +5775,8 @@ function flushSegment( !flushingPartialBoundaries && isEligibleForOutlining(request, boundary) && (flushedByteSize + boundary.byteSize > request.progressiveChunkSize || - hasSuspenseyContent(boundary.contentState)) + hasSuspenseyContent(boundary.contentState) || + boundary.defer) ) { // Inlining this boundary would make the current sequence being written too large // and block the parent for too long. Instead, it will be emitted separately so that we diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 0b8d222e5cda..fe36b77ee283 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -313,6 +313,7 @@ export type SuspenseProps = { unstable_avoidThisFallback?: boolean, unstable_expectedLoadTime?: number, + defer?: boolean, name?: string, };