diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 0e2dfdf8b90f3..d3bac386bd186 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -576,6 +576,29 @@ export function createResumableState( }; } +export function resetResumableState( + resumableState: ResumableState, + renderState: RenderState, +): void { + // Resets the resumable state based on what didn't manage to fully flush in the render state. + // This currently assumes nothing was flushed. + resumableState.nextFormID = 0; + resumableState.hasBody = false; + resumableState.hasHtml = false; + resumableState.unknownResources = {}; + resumableState.dnsResources = {}; + resumableState.connectResources = { + default: {}, + anonymous: {}, + credentials: {}, + }; + resumableState.imageResources = {}; + resumableState.styleResources = {}; + resumableState.scriptResources = {}; + resumableState.moduleUnknownResources = {}; + resumableState.moduleScriptResources = {}; +} + // Constants for the insertion mode we're currently writing in. We don't encode all HTML5 insertion // modes. We only include the variants as they matter for the sake of our purposes. // We don't actually provide the namespace therefore we use constants instead of the string. diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index 608d668eb1463..aab794723945e 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -1187,4 +1187,133 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(getVisibleChildren(container)).toEqual(
Hello
); }); + + // @gate enablePostpone + it('emits an empty prelude and resumes at the root if we postpone in the shell', async () => { + let prerendering = true; + function Postpone() { + if (prerendering) { + React.unstable_postpone(); + } + return 'Hello'; + } + + function App() { + return ( + + + + + + + ); + } + + const prerendered = await ReactDOMFizzStatic.prerender(); + expect(prerendered.postponed).not.toBe(null); + + prerendering = false; + + expect(await readContent(prerendered.prelude)).toBe(''); + + const content = await ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ); + + expect(await readContent(content)).toBe( + '' + + '' + + 'Hello', + ); + }); + + // @gate enablePostpone + it('emits an empty prelude if we have not rendered html or head tags yet', async () => { + let prerendering = true; + function Postpone() { + if (prerendering) { + React.unstable_postpone(); + } + return ( + + Hello + + ); + } + + function App() { + return ( + <> + + + + ); + } + + const prerendered = await ReactDOMFizzStatic.prerender(); + expect(prerendered.postponed).not.toBe(null); + + prerendering = false; + + expect(await readContent(prerendered.prelude)).toBe(''); + + const content = await ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ); + + expect(await readContent(content)).toBe( + '' + + '' + + 'Hello', + ); + }); + + // @gate enablePostpone + it('emits an empty prelude if a postpone in a promise in the shell', async () => { + let prerendering = true; + function Postpone() { + if (prerendering) { + React.unstable_postpone(); + } + return 'Hello'; + } + + const Lazy = React.lazy(async () => { + await 0; + return {default: Postpone}; + }); + + function App() { + return ( + + + +
+ +
+ + + ); + } + + const prerendered = await ReactDOMFizzStatic.prerender(); + expect(prerendered.postponed).not.toBe(null); + + prerendering = false; + + expect(await readContent(prerendered.prelude)).toBe(''); + + const content = await ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ); + + expect(await readContent(content)).toBe( + '' + + '' + + '
Hello
', + ); + }); }); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 2251908286cf3..80e0c6db18f7a 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -76,6 +76,7 @@ import { requestStorage, pushFormStateMarkerIsMatching, pushFormStateMarkerIsNotMatching, + resetResumableState, } from './ReactFizzConfig'; import { constructClassInstance, @@ -505,6 +506,39 @@ export function resumeRequest( onFatalError: onFatalError === undefined ? noop : onFatalError, formState: null, }; + if (typeof postponedState.replaySlots === 'number') { + const resumedId = postponedState.replaySlots; + // We have a resume slot at the very root. This is effectively just a full rerender. + const rootSegment = createPendingSegment( + request, + 0, + null, + postponedState.rootFormatContext, + // Root segments are never embedded in Text on either edge + false, + false, + ); + rootSegment.id = resumedId; + // There is no parent so conceptually, we're unblocked to flush this segment. + rootSegment.parentFlushed = true; + const rootTask = createRenderTask( + request, + null, + children, + -1, + null, + rootSegment, + abortSet, + null, + postponedState.rootFormatContext, + emptyContextObject, + rootContextSnapshot, + emptyTreeContext, + ); + pingedTasks.push(rootTask); + return request; + } + const replay: ReplaySet = { nodes: postponedState.replayNodes, slots: postponedState.replaySlots, @@ -2477,6 +2511,17 @@ function trackPostpone( const keyPath = task.keyPath; const boundary = task.blockedBoundary; + + if (boundary === null) { + segment.id = request.nextSegmentId++; + trackedPostpones.rootSlots = segment.id; + if (request.completedRootSegment !== null) { + // Postpone the root if this was a deeper segment. + request.completedRootSegment.status = POSTPONED; + } + return; + } + if (boundary !== null && boundary.status === PENDING) { boundary.status = POSTPONED; // We need to eagerly assign it an ID because we'll need to refer to @@ -2835,7 +2880,7 @@ function renderNode( enablePostpone && request.trackedPostpones !== null && x.$$typeof === REACT_POSTPONE_TYPE && - task.blockedBoundary !== null // TODO: Support holes in the shell + task.blockedBoundary !== null // bubble if we're postponing in the shell ) { // If we're tracking postpones, we inject a hole here and continue rendering // sibling. Similar to suspending. If we're not tracking, we treat it more like @@ -3376,8 +3421,7 @@ function retryRenderTask( } else if ( enablePostpone && request.trackedPostpones !== null && - x.$$typeof === REACT_POSTPONE_TYPE && - task.blockedBoundary !== null // TODO: Support holes in the shell + x.$$typeof === REACT_POSTPONE_TYPE ) { // If we're tracking postpones, we mark this segment as postponed and finish // the task without filling it in. If we're not tracking, we treat it more like @@ -3870,7 +3914,10 @@ function flushCompletedQueues( let i; const completedRootSegment = request.completedRootSegment; if (completedRootSegment !== null) { - if (request.pendingRootTasks === 0) { + if (completedRootSegment.status === POSTPONED) { + // We postponed the root, so we write nothing. + return; + } else if (request.pendingRootTasks === 0) { if (enableFloat) { writePreamble( destination, @@ -4138,6 +4185,13 @@ export function getPostponedState(request: Request): null | PostponedState { request.trackedPostpones = null; return null; } + if ( + request.completedRootSegment !== null && + request.completedRootSegment.status === POSTPONED + ) { + // We postponed the root so we didn't flush anything. + resetResumableState(request.resumableState, request.renderState); + } return { nextSegmentId: request.nextSegmentId, rootFormatContext: request.rootFormatContext,