diff --git a/packages/react-dom/npm/static.browser.js b/packages/react-dom/npm/static.browser.js index 6d3f52b0e6..ddfe2b2089 100644 --- a/packages/react-dom/npm/static.browser.js +++ b/packages/react-dom/npm/static.browser.js @@ -9,3 +9,4 @@ if (process.env.NODE_ENV === 'production') { exports.version = s.version; exports.prerender = s.prerender; +exports.resumeAndPrerender = s.resumeAndPrerender; diff --git a/packages/react-dom/npm/static.edge.js b/packages/react-dom/npm/static.edge.js index de57d7a4c0..ff770374b3 100644 --- a/packages/react-dom/npm/static.edge.js +++ b/packages/react-dom/npm/static.edge.js @@ -9,3 +9,4 @@ if (process.env.NODE_ENV === 'production') { exports.version = s.version; exports.prerender = s.prerender; +exports.resumeAndPrerender = s.resumeAndPrerender; diff --git a/packages/react-dom/npm/static.node.js b/packages/react-dom/npm/static.node.js index 0a7cc8dfd7..5dc47d472b 100644 --- a/packages/react-dom/npm/static.node.js +++ b/packages/react-dom/npm/static.node.js @@ -9,3 +9,4 @@ if (process.env.NODE_ENV === 'production') { exports.version = s.version; exports.prerenderToNodeStream = s.prerenderToNodeStream; +exports.resumeAndPrerenderToNodeStream = s.resumeAndPrerenderToNodeStream; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index 357ef1dcb4..1512e1d4c7 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -1758,4 +1758,90 @@ describe('ReactDOMFizzStaticBrowser', () => { await readIntoContainer(dynamic); expect(getVisibleChildren(container)).toEqual('hello'); }); + + // @gate enableHalt + it('can resume render of a prerender', async () => { + const errors = []; + + let resolveA; + const promiseA = new Promise(r => (resolveA = r)); + let resolveB; + const promiseB = new Promise(r => (resolveB = r)); + + async function ComponentA() { + await promiseA; + return ( + + + + ); + } + + async function ComponentB() { + await promiseB; + return 'Hello'; + } + + function App() { + return ( + + + + ); + } + + const controller = new AbortController(); + let pendingResult; + await serverAct(async () => { + pendingResult = ReactDOMFizzStatic.prerender(, { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, + }); + }); + + controller.abort(); + + const prerendered = await pendingResult; + const postponedState = JSON.stringify(prerendered.postponed); + + await readIntoContainer(prerendered.prelude); + expect(getVisibleChildren(container)).toEqual('Loading A'); + + await resolveA(); + + expect(prerendered.postponed).not.toBe(null); + + const controller2 = new AbortController(); + await serverAct(async () => { + pendingResult = ReactDOMFizzStatic.resumeAndPrerender( + , + JSON.parse(postponedState), + { + signal: controller2.signal, + onError(x) { + errors.push(x.message); + }, + }, + ); + }); + + controller2.abort(); + + const prerendered2 = await pendingResult; + const postponedState2 = JSON.stringify(prerendered2.postponed); + + await readIntoContainer(prerendered2.prelude); + expect(getVisibleChildren(container)).toEqual('Loading B'); + + await resolveB(); + + const dynamic = await serverAct(() => + ReactDOMFizzServer.resume(, JSON.parse(postponedState2)), + ); + + await readIntoContainer(dynamic); + expect(getVisibleChildren(container)).toEqual('Hello'); + }); }); diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js index f5d6a45a18..6785515bbe 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js @@ -23,6 +23,7 @@ import ReactVersion from 'shared/ReactVersion'; import { createPrerenderRequest, + resumeAndPrerenderRequest, startWork, startFlowing, stopFlowing, @@ -33,6 +34,7 @@ import { import { createResumableState, createRenderState, + resumeRenderState, createRootFormatContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; @@ -141,4 +143,73 @@ function prerender( }); } -export {prerender, ReactVersion as version}; +type ResumeOptions = { + nonce?: string, + signal?: AbortSignal, + onError?: (error: mixed) => ?string, + onPostpone?: (reason: string) => void, + unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, +}; + +function resumeAndPrerender( + children: ReactNodeList, + postponedState: PostponedState, + options?: ResumeOptions, +): Promise { + return new Promise((resolve, reject) => { + const onFatalError = reject; + + function onAllReady() { + const stream = new ReadableStream( + { + type: 'bytes', + pull: (controller): ?Promise => { + startFlowing(request, controller); + }, + cancel: (reason): ?Promise => { + stopFlowing(request); + abort(request, reason); + }, + }, + // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ); + + const result = { + postponed: getPostponedState(request), + prelude: stream, + }; + resolve(result); + } + + const request = resumeAndPrerenderRequest( + children, + postponedState, + resumeRenderState( + postponedState.resumableState, + options ? options.nonce : undefined, + ), + options ? options.onError : undefined, + onAllReady, + undefined, + undefined, + onFatalError, + options ? options.onPostpone : undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startWork(request); + }); +} + +export {prerender, resumeAndPrerender, ReactVersion as version}; diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js index 1a2eb1e599..5a7467002c 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js @@ -23,6 +23,7 @@ import ReactVersion from 'shared/ReactVersion'; import { createPrerenderRequest, + resumeAndPrerenderRequest, startWork, startFlowing, stopFlowing, @@ -33,6 +34,7 @@ import { import { createResumableState, createRenderState, + resumeRenderState, createRootFormatContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; @@ -140,4 +142,73 @@ function prerender( }); } -export {prerender, ReactVersion as version}; +type ResumeOptions = { + nonce?: string, + signal?: AbortSignal, + onError?: (error: mixed) => ?string, + onPostpone?: (reason: string) => void, + unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, +}; + +function resumeAndPrerender( + children: ReactNodeList, + postponedState: PostponedState, + options?: ResumeOptions, +): Promise { + return new Promise((resolve, reject) => { + const onFatalError = reject; + + function onAllReady() { + const stream = new ReadableStream( + { + type: 'bytes', + pull: (controller): ?Promise => { + startFlowing(request, controller); + }, + cancel: (reason): ?Promise => { + stopFlowing(request); + abort(request, reason); + }, + }, + // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ); + + const result = { + postponed: getPostponedState(request), + prelude: stream, + }; + resolve(result); + } + + const request = resumeAndPrerenderRequest( + children, + postponedState, + resumeRenderState( + postponedState.resumableState, + options ? options.nonce : undefined, + ), + options ? options.onError : undefined, + onAllReady, + undefined, + undefined, + onFatalError, + options ? options.onPostpone : undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startWork(request); + }); +} + +export {prerender, resumeAndPrerender, ReactVersion as version}; diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js index fc25aa75c1..9b9cd680c1 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js @@ -25,6 +25,7 @@ import ReactVersion from 'shared/ReactVersion'; import { createPrerenderRequest, + resumeAndPrerenderRequest, startWork, startFlowing, abort, @@ -34,6 +35,7 @@ import { import { createResumableState, createRenderState, + resumeRenderState, createRootFormatContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; @@ -141,4 +143,67 @@ function prerenderToNodeStream( }); } -export {prerenderToNodeStream, ReactVersion as version}; +type ResumeOptions = { + nonce?: string, + signal?: AbortSignal, + onError?: (error: mixed, errorInfo: ErrorInfo) => ?string, + onPostpone?: (reason: string, postponeInfo: PostponeInfo) => void, +}; + +function resumeAndPrerenderToNodeStream( + children: ReactNodeList, + postponedState: PostponedState, + options?: ResumeOptions, +): Promise { + return new Promise((resolve, reject) => { + const onFatalError = reject; + + function onAllReady() { + const readable: Readable = new Readable({ + read() { + startFlowing(request, writable); + }, + }); + const writable = createFakeWritable(readable); + + const result = { + postponed: getPostponedState(request), + prelude: readable, + }; + resolve(result); + } + const request = resumeAndPrerenderRequest( + children, + postponedState, + resumeRenderState( + postponedState.resumableState, + options ? options.nonce : undefined, + ), + options ? options.onError : undefined, + onAllReady, + undefined, + undefined, + onFatalError, + options ? options.onPostpone : undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startWork(request); + }); +} + +export { + prerenderToNodeStream, + resumeAndPrerenderToNodeStream, + ReactVersion as version, +}; diff --git a/packages/react-dom/src/server/react-dom-server.browser.js b/packages/react-dom/src/server/react-dom-server.browser.js index c12bb28c19..5ab1f0e142 100644 --- a/packages/react-dom/src/server/react-dom-server.browser.js +++ b/packages/react-dom/src/server/react-dom-server.browser.js @@ -8,4 +8,4 @@ */ export * from './ReactDOMFizzServerBrowser.js'; -export {prerender} from './ReactDOMFizzStaticBrowser.js'; +export {prerender, resumeAndPrerender} from './ReactDOMFizzStaticBrowser.js'; diff --git a/packages/react-dom/src/server/react-dom-server.edge.js b/packages/react-dom/src/server/react-dom-server.edge.js index c3882bed01..e70e8fd4cb 100644 --- a/packages/react-dom/src/server/react-dom-server.edge.js +++ b/packages/react-dom/src/server/react-dom-server.edge.js @@ -8,4 +8,4 @@ */ export * from './ReactDOMFizzServerEdge.js'; -export {prerender} from './ReactDOMFizzStaticEdge.js'; +export {prerender, resumeAndPrerender} from './ReactDOMFizzStaticEdge.js'; diff --git a/packages/react-dom/src/server/react-dom-server.node.js b/packages/react-dom/src/server/react-dom-server.node.js index 9ca72308b0..17c2d755b4 100644 --- a/packages/react-dom/src/server/react-dom-server.node.js +++ b/packages/react-dom/src/server/react-dom-server.node.js @@ -8,4 +8,7 @@ */ export * from './ReactDOMFizzServerNode.js'; -export {prerenderToNodeStream} from './ReactDOMFizzStaticNode.js'; +export { + prerenderToNodeStream, + resumeAndPrerenderToNodeStream, +} from './ReactDOMFizzStaticNode.js'; diff --git a/packages/react-dom/static.browser.js b/packages/react-dom/static.browser.js index f5148e087f..23e07d7dd2 100644 --- a/packages/react-dom/static.browser.js +++ b/packages/react-dom/static.browser.js @@ -7,4 +7,8 @@ * @flow */ -export {prerender, version} from './src/server/react-dom-server.browser'; +export { + prerender, + resumeAndPrerender, + version, +} from './src/server/react-dom-server.browser'; diff --git a/packages/react-dom/static.edge.js b/packages/react-dom/static.edge.js index 40bdffd4e4..e2cbc69286 100644 --- a/packages/react-dom/static.edge.js +++ b/packages/react-dom/static.edge.js @@ -7,4 +7,8 @@ * @flow */ -export {prerender, version} from './src/server/react-dom-server.edge'; +export { + prerender, + resumeAndPrerender, + version, +} from './src/server/react-dom-server.edge'; diff --git a/packages/react-dom/static.node.js b/packages/react-dom/static.node.js index 9036370546..a25c88af4d 100644 --- a/packages/react-dom/static.node.js +++ b/packages/react-dom/static.node.js @@ -9,5 +9,6 @@ export { prerenderToNodeStream, + resumeAndPrerenderToNodeStream, version, } from './src/server/react-dom-server.node'; diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 633aed053b..d0e6eb852c 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -641,6 +641,37 @@ export function resumeRequest( return request; } +export function resumeAndPrerenderRequest( + children: ReactNodeList, + postponedState: PostponedState, + renderState: RenderState, + onError: void | ((error: mixed, errorInfo: ErrorInfo) => ?string), + onAllReady: void | (() => void), + onShellReady: void | (() => void), + onShellError: void | ((error: mixed) => void), + onFatalError: void | ((error: mixed) => void), + onPostpone: void | ((reason: string, postponeInfo: PostponeInfo) => void), +): Request { + const request = resumeRequest( + children, + postponedState, + renderState, + onError, + onAllReady, + onShellReady, + onShellError, + onFatalError, + onPostpone, + ); + // Start tracking postponed holes during this render. + request.trackedPostpones = { + workingMap: new Map(), + rootNodes: [], + rootSlots: null, + }; + return request; +} + let currentRequest: null | Request = null; export function resolveRequest(): null | Request { @@ -1349,6 +1380,7 @@ function replaySuspenseBoundary( // we're writing to. If something suspends, it'll spawn new suspended task with that context. task.blockedBoundary = resumedBoundary; task.hoistableState = resumedBoundary.contentState; + task.keyPath = keyPath; task.replay = {nodes: childNodes, slots: childSlots, pendingTasks: 1}; try { @@ -5066,6 +5098,7 @@ export function abort(request: Request, reason: mixed): void { if (request.status === OPEN) { request.status = ABORTING; } + try { const abortableTasks = request.abortableTasks; if (abortableTasks.size > 0) {