From e43c353c84254e9d0e5f9aa9a175fc03f7cc348a Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 11 Sep 2024 10:43:09 -0400 Subject: [PATCH 1/4] Add resumeAndPrerender exports --- packages/react-dom/npm/static.browser.js | 1 + packages/react-dom/npm/static.edge.js | 1 + packages/react-dom/npm/static.node.js | 1 + .../src/server/ReactDOMFizzStaticBrowser.js | 18 +++++++++++++- .../src/server/ReactDOMFizzStaticEdge.js | 18 +++++++++++++- .../src/server/ReactDOMFizzStaticNode.js | 24 ++++++++++++++++++- .../src/server/react-dom-server.browser.js | 2 +- .../src/server/react-dom-server.edge.js | 2 +- .../src/server/react-dom-server.node.js | 5 +++- packages/react-dom/static.browser.js | 6 ++++- packages/react-dom/static.edge.js | 6 ++++- packages/react-dom/static.node.js | 1 + 12 files changed, 77 insertions(+), 8 deletions(-) diff --git a/packages/react-dom/npm/static.browser.js b/packages/react-dom/npm/static.browser.js index 6d3f52b0e6c1e..ddfe2b20896dd 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 de57d7a4c022e..ff770374b314a 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 0a7cc8dfd7855..5dc47d472ba4b 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/server/ReactDOMFizzStaticBrowser.js b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js index f5d6a45a18c8c..dc2350d2ead90 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js @@ -141,4 +141,20 @@ 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 (null: any); +} + +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 1a2eb1e599afe..fef7512e0d695 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js @@ -140,4 +140,20 @@ 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 (null: any); +} + +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 fc25aa75c190a..18e7182f6f8eb 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js @@ -141,4 +141,26 @@ function prerenderToNodeStream( }); } -export {prerenderToNodeStream, ReactVersion as version}; +type ResumeOptions = { + nonce?: string, + signal?: AbortSignal, + onShellReady?: () => void, + onShellError?: (error: mixed) => void, + onAllReady?: () => void, + onError?: (error: mixed, errorInfo: ErrorInfo) => ?string, + onPostpone?: (reason: string, postponeInfo: PostponeInfo) => void, +}; + +function resumeAndPrerenderToNodeStream( + children: ReactNodeList, + postponedState: PostponedState, + options?: ResumeOptions, +): Promise { + return (null: any); +} + +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 c12bb28c19dc4..5ab1f0e14256e 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 c3882bed01dc4..e70e8fd4cbefe 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 9ca72308b08e5..17c2d755b4873 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 f5148e087fa85..23e07d7dd29fd 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 40bdffd4e4b52..e2cbc692869a0 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 9036370546960..a25c88af4d9f4 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'; From 852d978344cbda7906e998281e18832436a84e21 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 11 Sep 2024 11:33:10 -0400 Subject: [PATCH 2/4] Create a resume request that also tracks postpones --- .../src/server/ReactDOMFizzStaticBrowser.js | 57 ++++++++++++++++++- .../src/server/ReactDOMFizzStaticEdge.js | 57 ++++++++++++++++++- .../src/server/ReactDOMFizzStaticNode.js | 51 +++++++++++++++-- packages/react-server/src/ReactFizzServer.js | 31 ++++++++++ 4 files changed, 190 insertions(+), 6 deletions(-) diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js index dc2350d2ead90..6785515bbebe7 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'; @@ -154,7 +156,60 @@ function resumeAndPrerender( postponedState: PostponedState, options?: ResumeOptions, ): Promise { - return (null: any); + 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 fef7512e0d695..5a7467002cb5c 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'; @@ -153,7 +155,60 @@ function resumeAndPrerender( postponedState: PostponedState, options?: ResumeOptions, ): Promise { - return (null: any); + 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 18e7182f6f8eb..9b9cd680c16b9 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'; @@ -144,9 +146,6 @@ function prerenderToNodeStream( type ResumeOptions = { nonce?: string, signal?: AbortSignal, - onShellReady?: () => void, - onShellError?: (error: mixed) => void, - onAllReady?: () => void, onError?: (error: mixed, errorInfo: ErrorInfo) => ?string, onPostpone?: (reason: string, postponeInfo: PostponeInfo) => void, }; @@ -156,7 +155,51 @@ function resumeAndPrerenderToNodeStream( postponedState: PostponedState, options?: ResumeOptions, ): Promise { - return (null: any); + 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 { diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 633aed053bb3b..b05f63508e684 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 { From 27fbed5cfc2dd3718b3d3d8a9ad6acd8c025dc12 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 11 Sep 2024 20:58:18 -0400 Subject: [PATCH 3/4] Missing keyPath This would've shown up for FormState eventually. --- packages/react-server/src/ReactFizzServer.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index b05f63508e684..d0e6eb852c08f 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -1380,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 { @@ -5097,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) { From cd20701c9336c8927034fd5b1f1aa7965fdc9e1a Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 11 Sep 2024 21:58:35 -0400 Subject: [PATCH 4/4] Test --- .../ReactDOMFizzStaticBrowser-test.js | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index 357ef1dcb478e..1512e1d4c7e99 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'); + }); });