From fa6eab58541330349480690ef4e211520cc08d94 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 15 Aug 2024 14:28:28 -0700 Subject: [PATCH] [Flight] Implement prerender (#30686) Prerendering in flight is similar to prerendering in Fizz. Instead of receiving a result (the stream) immediately a promise is returned which resolves to the stream when the prerender is complete. The promise will reject if the flight render fatally errors otherwise it will resolve when the render is completed or is aborted. --- .../flight/__tests__/__e2e__/smoke.test.js | 7 ++ fixtures/flight/server/global.js | 11 ++- fixtures/flight/server/region.js | 61 ++++++++++++++- fixtures/flight/src/App.js | 7 +- packages/react-server-dom-esm/npm/static.js | 6 ++ .../react-server-dom-esm/npm/static.node.js | 12 +++ packages/react-server-dom-esm/package.json | 7 ++ .../src/server/ReactFlightDOMServerNode.js | 77 ++++++++++++++++++ .../server/react-flight-dom-server.node.js | 1 + .../react-flight-dom-server.node.stable.js | 19 +++++ packages/react-server-dom-esm/static.js | 13 ++++ packages/react-server-dom-esm/static.node.js | 10 +++ .../npm/static.browser.js | 12 +++ .../npm/static.edge.js | 12 +++ .../react-server-dom-turbopack/npm/static.js | 6 ++ .../npm/static.node.js | 12 +++ .../npm/static.node.unbundled.js | 12 +++ .../react-server-dom-turbopack/package.json | 23 ++++++ .../src/server/ReactFlightDOMServerBrowser.js | 67 +++++++++++++++- .../src/server/ReactFlightDOMServerEdge.js | 67 +++++++++++++++- .../src/server/ReactFlightDOMServerNode.js | 78 +++++++++++++++++++ .../server/react-flight-dom-server.browser.js | 1 + .../react-flight-dom-server.browser.stable.js | 19 +++++ .../server/react-flight-dom-server.edge.js | 1 + .../react-flight-dom-server.edge.stable.js | 19 +++++ .../server/react-flight-dom-server.node.js | 1 + .../react-flight-dom-server.node.stable.js | 20 +++++ .../react-flight-dom-server.node.unbundled.js | 1 + ...flight-dom-server.node.unbundled.stable.js | 20 +++++ .../static.browser.js | 10 +++ .../react-server-dom-turbopack/static.edge.js | 10 +++ packages/react-server-dom-turbopack/static.js | 13 ++++ .../react-server-dom-turbopack/static.node.js | 10 +++ .../static.node.unbundled.js | 10 +++ .../npm/static.browser.js | 12 +++ .../npm/static.edge.js | 12 +++ .../react-server-dom-webpack/npm/static.js | 6 ++ .../npm/static.node.js | 12 +++ .../npm/static.node.unbundled.js | 12 +++ .../react-server-dom-webpack/package.json | 22 ++++++ .../src/__tests__/ReactFlightDOM-test.js | 72 +++++++++++++++++ .../src/server/ReactFlightDOMServerBrowser.js | 67 +++++++++++++++- .../src/server/ReactFlightDOMServerEdge.js | 67 +++++++++++++++- .../src/server/ReactFlightDOMServerNode.js | 78 +++++++++++++++++++ .../server/react-flight-dom-server.browser.js | 1 + .../react-flight-dom-server.browser.stable.js | 19 +++++ .../server/react-flight-dom-server.edge.js | 1 + .../react-flight-dom-server.edge.stable.js | 19 +++++ .../server/react-flight-dom-server.node.js | 1 + .../react-flight-dom-server.node.stable.js | 20 +++++ .../react-flight-dom-server.node.unbundled.js | 1 + ...flight-dom-server.node.unbundled.stable.js | 20 +++++ .../static.browser.js | 10 +++ .../react-server-dom-webpack/static.edge.js | 10 +++ packages/react-server-dom-webpack/static.js | 13 ++++ .../react-server-dom-webpack/static.node.js | 10 +++ .../static.node.unbundled.js | 10 +++ .../react-server/src/ReactFlightServer.js | 19 +++++ scripts/shared/inlinedHostConfigs.js | 14 ++++ 59 files changed, 1174 insertions(+), 9 deletions(-) create mode 100644 packages/react-server-dom-esm/npm/static.js create mode 100644 packages/react-server-dom-esm/npm/static.node.js create mode 100644 packages/react-server-dom-esm/src/server/react-flight-dom-server.node.stable.js create mode 100644 packages/react-server-dom-esm/static.js create mode 100644 packages/react-server-dom-esm/static.node.js create mode 100644 packages/react-server-dom-turbopack/npm/static.browser.js create mode 100644 packages/react-server-dom-turbopack/npm/static.edge.js create mode 100644 packages/react-server-dom-turbopack/npm/static.js create mode 100644 packages/react-server-dom-turbopack/npm/static.node.js create mode 100644 packages/react-server-dom-turbopack/npm/static.node.unbundled.js create mode 100644 packages/react-server-dom-turbopack/src/server/react-flight-dom-server.browser.stable.js create mode 100644 packages/react-server-dom-turbopack/src/server/react-flight-dom-server.edge.stable.js create mode 100644 packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.stable.js create mode 100644 packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.unbundled.stable.js create mode 100644 packages/react-server-dom-turbopack/static.browser.js create mode 100644 packages/react-server-dom-turbopack/static.edge.js create mode 100644 packages/react-server-dom-turbopack/static.js create mode 100644 packages/react-server-dom-turbopack/static.node.js create mode 100644 packages/react-server-dom-turbopack/static.node.unbundled.js create mode 100644 packages/react-server-dom-webpack/npm/static.browser.js create mode 100644 packages/react-server-dom-webpack/npm/static.edge.js create mode 100644 packages/react-server-dom-webpack/npm/static.js create mode 100644 packages/react-server-dom-webpack/npm/static.node.js create mode 100644 packages/react-server-dom-webpack/npm/static.node.unbundled.js create mode 100644 packages/react-server-dom-webpack/src/server/react-flight-dom-server.browser.stable.js create mode 100644 packages/react-server-dom-webpack/src/server/react-flight-dom-server.edge.stable.js create mode 100644 packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.stable.js create mode 100644 packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.unbundled.stable.js create mode 100644 packages/react-server-dom-webpack/static.browser.js create mode 100644 packages/react-server-dom-webpack/static.edge.js create mode 100644 packages/react-server-dom-webpack/static.js create mode 100644 packages/react-server-dom-webpack/static.node.js create mode 100644 packages/react-server-dom-webpack/static.node.unbundled.js diff --git a/fixtures/flight/__tests__/__e2e__/smoke.test.js b/fixtures/flight/__tests__/__e2e__/smoke.test.js index 267bd10908..f2bcbbd111 100644 --- a/fixtures/flight/__tests__/__e2e__/smoke.test.js +++ b/fixtures/flight/__tests__/__e2e__/smoke.test.js @@ -16,6 +16,13 @@ test('smoke test', async ({page}) => { await expect(page.getByTestId('promise-as-a-child-test')).toHaveText( 'Promise as a child hydrates without errors: deferred text' ); + await expect(page.getByTestId('prerendered')).not.toBeAttached(); + + await expect(consoleErrors).toEqual([]); + await expect(pageErrors).toEqual([]); + + await page.goto('/prerender'); + await expect(page.getByTestId('prerendered')).toBeAttached(); await expect(consoleErrors).toEqual([]); await expect(pageErrors).toEqual([]); diff --git a/fixtures/flight/server/global.js b/fixtures/flight/server/global.js index e4ae3a6291..8133bf7912 100644 --- a/fixtures/flight/server/global.js +++ b/fixtures/flight/server/global.js @@ -86,7 +86,7 @@ function request(options, body) { }); } -app.all('/', async function (req, res, next) { +async function renderApp(req, res, next) { // Proxy the request to the regional server. const proxiedHeaders = { 'X-Forwarded-Host': req.hostname, @@ -102,12 +102,14 @@ app.all('/', async function (req, res, next) { proxiedHeaders['Content-type'] = req.get('Content-type'); } + const requestsPrerender = req.path === '/prerender'; + const promiseForData = request( { host: '127.0.0.1', port: 3001, method: req.method, - path: '/', + path: requestsPrerender ? '/?prerender=1' : '/', headers: proxiedHeaders, }, req @@ -210,7 +212,10 @@ app.all('/', async function (req, res, next) { res.end(); } } -}); +} + +app.all('/', renderApp); +app.all('/prerender', renderApp); if (process.env.NODE_ENV === 'development') { app.use(express.static('public')); diff --git a/fixtures/flight/server/region.js b/fixtures/flight/server/region.js index bc4ba05ddf..daf619741a 100644 --- a/fixtures/flight/server/region.js +++ b/fixtures/flight/server/region.js @@ -105,8 +105,67 @@ async function renderApp(res, returnValue, formState) { pipe(res); } +async function prerenderApp(res, returnValue, formState) { + const {prerenderToNodeStream} = await import( + 'react-server-dom-webpack/static' + ); + // const m = require('../src/App.js'); + const m = await import('../src/App.js'); + + let moduleMap; + let mainCSSChunks; + if (process.env.NODE_ENV === 'development') { + // Read the module map from the HMR server in development. + moduleMap = await ( + await fetch('http://localhost:3000/react-client-manifest.json') + ).json(); + mainCSSChunks = ( + await ( + await fetch('http://localhost:3000/entrypoint-manifest.json') + ).json() + ).main.css; + } else { + // Read the module map from the static build in production. + moduleMap = JSON.parse( + await readFile( + path.resolve(__dirname, `../build/react-client-manifest.json`), + 'utf8' + ) + ); + mainCSSChunks = JSON.parse( + await readFile( + path.resolve(__dirname, `../build/entrypoint-manifest.json`), + 'utf8' + ) + ).main.css; + } + const App = m.default.default || m.default; + const root = React.createElement( + React.Fragment, + null, + // Prepend the App's tree with stylesheets required for this entrypoint. + mainCSSChunks.map(filename => + React.createElement('link', { + rel: 'stylesheet', + href: filename, + precedence: 'default', + key: filename, + }) + ), + React.createElement(App, {prerender: true}) + ); + // For client-invoked server actions we refresh the tree and return a return value. + const payload = {root, returnValue, formState}; + const {prelude} = await prerenderToNodeStream(payload, moduleMap); + prelude.pipe(res); +} + app.get('/', async function (req, res) { - await renderApp(res, null, null); + if ('prerender' in req.query) { + await prerenderApp(res, null, null); + } else { + await renderApp(res, null, null); + } }); app.post('/', bodyParser.text(), async function (req, res) { diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js index 027056c515..08987750eb 100644 --- a/fixtures/flight/src/App.js +++ b/fixtures/flight/src/App.js @@ -23,7 +23,7 @@ const promisedText = new Promise(resolve => setTimeout(() => resolve('deferred text'), 100) ); -export default async function App() { +export default async function App({prerender}) { const res = await fetch('http://localhost:3001/todos'); const todos = await res.json(); return ( @@ -35,6 +35,11 @@ export default async function App() { + {prerender ? ( + + ) : ( + + )}

{getServerState()}

diff --git a/packages/react-server-dom-esm/npm/static.js b/packages/react-server-dom-esm/npm/static.js new file mode 100644 index 0000000000..13a632e641 --- /dev/null +++ b/packages/react-server-dom-esm/npm/static.js @@ -0,0 +1,6 @@ +'use strict'; + +throw new Error( + 'The React Server Writer cannot be used outside a react-server environment. ' + + 'You must configure Node.js using the `--conditions react-server` flag.' +); diff --git a/packages/react-server-dom-esm/npm/static.node.js b/packages/react-server-dom-esm/npm/static.node.js new file mode 100644 index 0000000000..ff0b9b2a42 --- /dev/null +++ b/packages/react-server-dom-esm/npm/static.node.js @@ -0,0 +1,12 @@ +'use strict'; + +var s; +if (process.env.NODE_ENV === 'production') { + s = require('./cjs/react-server-dom-esm-server.node.production.js'); +} else { + s = require('./cjs/react-server-dom-esm-server.node.development.js'); +} + +if (s.prerenderToNodeStream) { + exports.prerenderToNodeStream = s.prerenderToNodeStream; +} diff --git a/packages/react-server-dom-esm/package.json b/packages/react-server-dom-esm/package.json index bd9e9c3949..a1f8f17f45 100644 --- a/packages/react-server-dom-esm/package.json +++ b/packages/react-server-dom-esm/package.json @@ -17,6 +17,8 @@ "client.node.js", "server.js", "server.node.js", + "static.js", + "static.node.js", "cjs/", "esm/" ], @@ -33,6 +35,11 @@ "default": "./server.js" }, "./server.node": "./server.node.js", + "./static": { + "react-server": "./static.node.js", + "default": "./static.js" + }, + "./static.node": "./static.node.js", "./node-loader": "./esm/react-server-dom-esm-node-loader.production.js", "./src/*": "./src/*.js", "./package.json": "./package.json" diff --git a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js index c724d89c2b..bb65ef4b65 100644 --- a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js @@ -18,6 +18,8 @@ import type {Busboy} from 'busboy'; import type {Writable} from 'stream'; import type {Thenable} from 'shared/ReactTypes'; +import {Readable} from 'stream'; + import { createRequest, startWork, @@ -123,6 +125,80 @@ function renderToPipeableStream( }, }; } +function createFakeWritable(readable: any): Writable { + // The current host config expects a Writable so we create + // a fake writable for now to push into the Readable. + return ({ + write(chunk) { + return readable.push(chunk); + }, + end() { + readable.push(null); + }, + destroy(error) { + readable.destroy(error); + }, + }: any); +} + +type PrerenderOptions = { + environmentName?: string | (() => string), + filterStackFrame?: (url: string, functionName: string) => boolean, + onError?: (error: mixed) => void, + onPostpone?: (reason: string) => void, + identifierPrefix?: string, + temporaryReferences?: TemporaryReferenceSet, + signal?: AbortSignal, +}; + +type StaticResult = { + prelude: Readable, +}; + +function prerenderToNodeStream( + model: ReactClientValue, + moduleBasePath: ClientManifest, + options?: PrerenderOptions, +): Promise { + return new Promise((resolve, reject) => { + const onFatalError = reject; + function onAllReady() { + const readable: Readable = new Readable({ + read() { + startFlowing(request, writable); + }, + }); + const writable = createFakeWritable(readable); + resolve({prelude: readable}); + } + + const request = createRequest( + model, + moduleBasePath, + options ? options.onError : undefined, + options ? options.identifierPrefix : undefined, + options ? options.onPostpone : undefined, + options ? options.temporaryReferences : undefined, + __DEV__ && options ? options.environmentName : undefined, + __DEV__ && options ? options.filterStackFrame : undefined, + onAllReady, + onFatalError, + ); + 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); + }); +} function decodeReplyFromBusboy( busboyStream: Busboy, @@ -207,6 +283,7 @@ function decodeReply( export { renderToPipeableStream, + prerenderToNodeStream, decodeReplyFromBusboy, decodeReply, decodeAction, diff --git a/packages/react-server-dom-esm/src/server/react-flight-dom-server.node.js b/packages/react-server-dom-esm/src/server/react-flight-dom-server.node.js index d14d2b8ed3..f24946fcae 100644 --- a/packages/react-server-dom-esm/src/server/react-flight-dom-server.node.js +++ b/packages/react-server-dom-esm/src/server/react-flight-dom-server.node.js @@ -9,6 +9,7 @@ export { renderToPipeableStream, + prerenderToNodeStream, decodeReplyFromBusboy, decodeReply, decodeAction, diff --git a/packages/react-server-dom-esm/src/server/react-flight-dom-server.node.stable.js b/packages/react-server-dom-esm/src/server/react-flight-dom-server.node.stable.js new file mode 100644 index 0000000000..d14d2b8ed3 --- /dev/null +++ b/packages/react-server-dom-esm/src/server/react-flight-dom-server.node.stable.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export { + renderToPipeableStream, + decodeReplyFromBusboy, + decodeReply, + decodeAction, + decodeFormState, + registerServerReference, + registerClientReference, + createTemporaryReferenceSet, +} from './ReactFlightDOMServerNode'; diff --git a/packages/react-server-dom-esm/static.js b/packages/react-server-dom-esm/static.js new file mode 100644 index 0000000000..83d8b8a017 --- /dev/null +++ b/packages/react-server-dom-esm/static.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +throw new Error( + 'The React Server cannot be used outside a react-server environment. ' + + 'You must configure Node.js using the `--conditions react-server` flag.', +); diff --git a/packages/react-server-dom-esm/static.node.js b/packages/react-server-dom-esm/static.node.js new file mode 100644 index 0000000000..d15eddc6f9 --- /dev/null +++ b/packages/react-server-dom-esm/static.node.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export {prerenderToNodeStream} from './src/server/react-flight-dom-server.node'; diff --git a/packages/react-server-dom-turbopack/npm/static.browser.js b/packages/react-server-dom-turbopack/npm/static.browser.js new file mode 100644 index 0000000000..edc104a459 --- /dev/null +++ b/packages/react-server-dom-turbopack/npm/static.browser.js @@ -0,0 +1,12 @@ +'use strict'; + +var s; +if (process.env.NODE_ENV === 'production') { + s = require('./cjs/react-server-dom-turbopack-server.browser.production.js'); +} else { + s = require('./cjs/react-server-dom-turbopack-server.browser.development.js'); +} + +if (s.prerender) { + exports.prerender = s.prerender; +} diff --git a/packages/react-server-dom-turbopack/npm/static.edge.js b/packages/react-server-dom-turbopack/npm/static.edge.js new file mode 100644 index 0000000000..c074f8ffe7 --- /dev/null +++ b/packages/react-server-dom-turbopack/npm/static.edge.js @@ -0,0 +1,12 @@ +'use strict'; + +var s; +if (process.env.NODE_ENV === 'production') { + s = require('./cjs/react-server-dom-turbopack-server.edge.production.js'); +} else { + s = require('./cjs/react-server-dom-turbopack-server.edge.development.js'); +} + +if (s.prerender) { + exports.prerender = s.prerender; +} diff --git a/packages/react-server-dom-turbopack/npm/static.js b/packages/react-server-dom-turbopack/npm/static.js new file mode 100644 index 0000000000..13a632e641 --- /dev/null +++ b/packages/react-server-dom-turbopack/npm/static.js @@ -0,0 +1,6 @@ +'use strict'; + +throw new Error( + 'The React Server Writer cannot be used outside a react-server environment. ' + + 'You must configure Node.js using the `--conditions react-server` flag.' +); diff --git a/packages/react-server-dom-turbopack/npm/static.node.js b/packages/react-server-dom-turbopack/npm/static.node.js new file mode 100644 index 0000000000..84083a9651 --- /dev/null +++ b/packages/react-server-dom-turbopack/npm/static.node.js @@ -0,0 +1,12 @@ +'use strict'; + +var s; +if (process.env.NODE_ENV === 'production') { + s = require('./cjs/react-server-dom-turbopack-server.node.production.js'); +} else { + s = require('./cjs/react-server-dom-turbopack-server.node.development.js'); +} + +if (s.prerenderToNodeStream) { + exports.prerenderToNodeStream = s.prerenderToNodeStream; +} diff --git a/packages/react-server-dom-turbopack/npm/static.node.unbundled.js b/packages/react-server-dom-turbopack/npm/static.node.unbundled.js new file mode 100644 index 0000000000..e77863bf36 --- /dev/null +++ b/packages/react-server-dom-turbopack/npm/static.node.unbundled.js @@ -0,0 +1,12 @@ +'use strict'; + +var s; +if (process.env.NODE_ENV === 'production') { + s = require('./cjs/react-server-dom-turbopack-server.node.unbundled.production.js'); +} else { + s = require('./cjs/react-server-dom-turbopack-server.node.unbundled.development.js'); +} + +if (s.prerenderToNodeStream) { + exports.prerenderToNodeStream = s.prerenderToNodeStream; +} diff --git a/packages/react-server-dom-turbopack/package.json b/packages/react-server-dom-turbopack/package.json index 93e694b3a3..93cd7d37a0 100644 --- a/packages/react-server-dom-turbopack/package.json +++ b/packages/react-server-dom-turbopack/package.json @@ -22,6 +22,11 @@ "server.edge.js", "server.node.js", "server.node.unbundled.js", + "static.js", + "static.browser.js", + "static.edge.js", + "static.node.js", + "static.node.unbundled.js", "node-register.js", "cjs/", "esm/" @@ -63,6 +68,24 @@ "./server.edge": "./server.edge.js", "./server.node": "./server.node.js", "./server.node.unbundled": "./server.node.unbundled.js", + "./static": { + "react-server": { + "workerd": "./static.edge.js", + "deno": "./static.browser.js", + "node": { + "turbopack": "./static.node.js", + "webpack": "./static.node.js", + "default": "./static.node.unbundled.js" + }, + "edge-light": "./static.edge.js", + "browser": "./static.browser.js" + }, + "default": "./static.js" + }, + "./static.browser": "./static.browser.js", + "./static.edge": "./static.edge.js", + "./static.node": "./static.node.js", + "./static.node.unbundled": "./static.node.unbundled.js", "./node-loader": "./esm/react-server-dom-turbopack-node-loader.production.js", "./node-register": "./node-register.js", "./src/*": "./src/*.js", diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js index 58a87992d6..ef98076494 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js @@ -100,6 +100,65 @@ function renderToReadableStream( return stream; } +type StaticResult = { + prelude: ReadableStream, +}; + +function prerender( + model: ReactClientValue, + turbopackMap: ClientManifest, + options?: Options, +): Promise { + return new Promise((resolve, reject) => { + const onFatalError = reject; + function onAllReady() { + const stream = new ReadableStream( + { + type: 'bytes', + start: (controller): ?Promise => { + startWork(request); + }, + 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}, + ); + resolve({prelude: stream}); + } + const request = createRequest( + model, + turbopackMap, + options ? options.onError : undefined, + options ? options.identifierPrefix : undefined, + options ? options.onPostpone : undefined, + options ? options.temporaryReferences : undefined, + __DEV__ && options ? options.environmentName : undefined, + __DEV__ && options ? options.filterStackFrame : undefined, + onAllReady, + onFatalError, + ); + 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); + }); +} + function decodeReply( body: string | FormData, turbopackMap: ServerManifest, @@ -121,4 +180,10 @@ function decodeReply( return root; } -export {renderToReadableStream, decodeReply, decodeAction, decodeFormState}; +export { + renderToReadableStream, + prerender, + decodeReply, + decodeAction, + decodeFormState, +}; diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js index 58a87992d6..ef98076494 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js @@ -100,6 +100,65 @@ function renderToReadableStream( return stream; } +type StaticResult = { + prelude: ReadableStream, +}; + +function prerender( + model: ReactClientValue, + turbopackMap: ClientManifest, + options?: Options, +): Promise { + return new Promise((resolve, reject) => { + const onFatalError = reject; + function onAllReady() { + const stream = new ReadableStream( + { + type: 'bytes', + start: (controller): ?Promise => { + startWork(request); + }, + 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}, + ); + resolve({prelude: stream}); + } + const request = createRequest( + model, + turbopackMap, + options ? options.onError : undefined, + options ? options.identifierPrefix : undefined, + options ? options.onPostpone : undefined, + options ? options.temporaryReferences : undefined, + __DEV__ && options ? options.environmentName : undefined, + __DEV__ && options ? options.filterStackFrame : undefined, + onAllReady, + onFatalError, + ); + 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); + }); +} + function decodeReply( body: string | FormData, turbopackMap: ServerManifest, @@ -121,4 +180,10 @@ function decodeReply( return root; } -export {renderToReadableStream, decodeReply, decodeAction, decodeFormState}; +export { + renderToReadableStream, + prerender, + decodeReply, + decodeAction, + decodeFormState, +}; diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js index d76bcb5759..e484d4b7e7 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js @@ -18,6 +18,8 @@ import type {Busboy} from 'busboy'; import type {Writable} from 'stream'; import type {Thenable} from 'shared/ReactTypes'; +import {Readable} from 'stream'; + import { createRequest, startWork, @@ -125,6 +127,81 @@ function renderToPipeableStream( }; } +function createFakeWritable(readable: any): Writable { + // The current host config expects a Writable so we create + // a fake writable for now to push into the Readable. + return ({ + write(chunk) { + return readable.push(chunk); + }, + end() { + readable.push(null); + }, + destroy(error) { + readable.destroy(error); + }, + }: any); +} + +type PrerenderOptions = { + environmentName?: string | (() => string), + filterStackFrame?: (url: string, functionName: string) => boolean, + onError?: (error: mixed) => void, + onPostpone?: (reason: string) => void, + identifierPrefix?: string, + temporaryReferences?: TemporaryReferenceSet, + signal?: AbortSignal, +}; + +type StaticResult = { + prelude: Readable, +}; + +function prerenderToNodeStream( + model: ReactClientValue, + turbopackMap: ClientManifest, + options?: PrerenderOptions, +): Promise { + return new Promise((resolve, reject) => { + const onFatalError = reject; + function onAllReady() { + const readable: Readable = new Readable({ + read() { + startFlowing(request, writable); + }, + }); + const writable = createFakeWritable(readable); + resolve({prelude: readable}); + } + + const request = createRequest( + model, + turbopackMap, + options ? options.onError : undefined, + options ? options.identifierPrefix : undefined, + options ? options.onPostpone : undefined, + options ? options.temporaryReferences : undefined, + __DEV__ && options ? options.environmentName : undefined, + __DEV__ && options ? options.filterStackFrame : undefined, + onAllReady, + onFatalError, + ); + 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); + }); +} + function decodeReplyFromBusboy( busboyStream: Busboy, turbopackMap: ServerManifest, @@ -208,6 +285,7 @@ function decodeReply( export { renderToPipeableStream, + prerenderToNodeStream, decodeReplyFromBusboy, decodeReply, decodeAction, diff --git a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.browser.js b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.browser.js index 0100b65554..d8373ec551 100644 --- a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.browser.js +++ b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.browser.js @@ -9,6 +9,7 @@ export { renderToReadableStream, + prerender, decodeReply, decodeAction, decodeFormState, diff --git a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.browser.stable.js b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.browser.stable.js new file mode 100644 index 0000000000..0100b65554 --- /dev/null +++ b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.browser.stable.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export { + renderToReadableStream, + decodeReply, + decodeAction, + decodeFormState, + registerServerReference, + registerClientReference, + createClientModuleProxy, + createTemporaryReferenceSet, +} from './ReactFlightDOMServerBrowser'; diff --git a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.edge.js b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.edge.js index eb887b73a8..9521ba6b68 100644 --- a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.edge.js +++ b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.edge.js @@ -9,6 +9,7 @@ export { renderToReadableStream, + prerender, decodeReply, decodeAction, decodeFormState, diff --git a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.edge.stable.js b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.edge.stable.js new file mode 100644 index 0000000000..eb887b73a8 --- /dev/null +++ b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.edge.stable.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export { + renderToReadableStream, + decodeReply, + decodeAction, + decodeFormState, + registerServerReference, + registerClientReference, + createClientModuleProxy, + createTemporaryReferenceSet, +} from './ReactFlightDOMServerEdge'; diff --git a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.js b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.js index 0d15970406..badc2ed50b 100644 --- a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.js +++ b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.js @@ -9,6 +9,7 @@ export { renderToPipeableStream, + prerenderToNodeStream, decodeReplyFromBusboy, decodeReply, decodeAction, diff --git a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.stable.js b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.stable.js new file mode 100644 index 0000000000..0d15970406 --- /dev/null +++ b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.stable.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export { + renderToPipeableStream, + decodeReplyFromBusboy, + decodeReply, + decodeAction, + decodeFormState, + registerServerReference, + registerClientReference, + createClientModuleProxy, + createTemporaryReferenceSet, +} from './ReactFlightDOMServerNode'; diff --git a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.unbundled.js b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.unbundled.js index 0d15970406..badc2ed50b 100644 --- a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.unbundled.js +++ b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.unbundled.js @@ -9,6 +9,7 @@ export { renderToPipeableStream, + prerenderToNodeStream, decodeReplyFromBusboy, decodeReply, decodeAction, diff --git a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.unbundled.stable.js b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.unbundled.stable.js new file mode 100644 index 0000000000..0d15970406 --- /dev/null +++ b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.unbundled.stable.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export { + renderToPipeableStream, + decodeReplyFromBusboy, + decodeReply, + decodeAction, + decodeFormState, + registerServerReference, + registerClientReference, + createClientModuleProxy, + createTemporaryReferenceSet, +} from './ReactFlightDOMServerNode'; diff --git a/packages/react-server-dom-turbopack/static.browser.js b/packages/react-server-dom-turbopack/static.browser.js new file mode 100644 index 0000000000..2589789163 --- /dev/null +++ b/packages/react-server-dom-turbopack/static.browser.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export {prerender} from './src/server/react-flight-dom-server.browser'; diff --git a/packages/react-server-dom-turbopack/static.edge.js b/packages/react-server-dom-turbopack/static.edge.js new file mode 100644 index 0000000000..a39d54c73f --- /dev/null +++ b/packages/react-server-dom-turbopack/static.edge.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export {prerender} from './src/server/react-flight-dom-server.edge'; diff --git a/packages/react-server-dom-turbopack/static.js b/packages/react-server-dom-turbopack/static.js new file mode 100644 index 0000000000..83d8b8a017 --- /dev/null +++ b/packages/react-server-dom-turbopack/static.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +throw new Error( + 'The React Server cannot be used outside a react-server environment. ' + + 'You must configure Node.js using the `--conditions react-server` flag.', +); diff --git a/packages/react-server-dom-turbopack/static.node.js b/packages/react-server-dom-turbopack/static.node.js new file mode 100644 index 0000000000..d15eddc6f9 --- /dev/null +++ b/packages/react-server-dom-turbopack/static.node.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export {prerenderToNodeStream} from './src/server/react-flight-dom-server.node'; diff --git a/packages/react-server-dom-turbopack/static.node.unbundled.js b/packages/react-server-dom-turbopack/static.node.unbundled.js new file mode 100644 index 0000000000..b2134459af --- /dev/null +++ b/packages/react-server-dom-turbopack/static.node.unbundled.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export {prerenderToNodeStream} from './src/server/react-flight-dom-server.node.unbundled'; diff --git a/packages/react-server-dom-webpack/npm/static.browser.js b/packages/react-server-dom-webpack/npm/static.browser.js new file mode 100644 index 0000000000..7d514abd6b --- /dev/null +++ b/packages/react-server-dom-webpack/npm/static.browser.js @@ -0,0 +1,12 @@ +'use strict'; + +var s; +if (process.env.NODE_ENV === 'production') { + s = require('./cjs/react-server-dom-webpack-server.browser.production.js'); +} else { + s = require('./cjs/react-server-dom-webpack-server.browser.development.js'); +} + +if (s.prerender) { + exports.prerender = s.prerender; +} diff --git a/packages/react-server-dom-webpack/npm/static.edge.js b/packages/react-server-dom-webpack/npm/static.edge.js new file mode 100644 index 0000000000..a4ae48f55e --- /dev/null +++ b/packages/react-server-dom-webpack/npm/static.edge.js @@ -0,0 +1,12 @@ +'use strict'; + +var s; +if (process.env.NODE_ENV === 'production') { + s = require('./cjs/react-server-dom-webpack-server.edge.production.js'); +} else { + s = require('./cjs/react-server-dom-webpack-server.edge.development.js'); +} + +if (s.prerender) { + exports.prerender = s.prerender; +} diff --git a/packages/react-server-dom-webpack/npm/static.js b/packages/react-server-dom-webpack/npm/static.js new file mode 100644 index 0000000000..13a632e641 --- /dev/null +++ b/packages/react-server-dom-webpack/npm/static.js @@ -0,0 +1,6 @@ +'use strict'; + +throw new Error( + 'The React Server Writer cannot be used outside a react-server environment. ' + + 'You must configure Node.js using the `--conditions react-server` flag.' +); diff --git a/packages/react-server-dom-webpack/npm/static.node.js b/packages/react-server-dom-webpack/npm/static.node.js new file mode 100644 index 0000000000..dbc4179d3e --- /dev/null +++ b/packages/react-server-dom-webpack/npm/static.node.js @@ -0,0 +1,12 @@ +'use strict'; + +var s; +if (process.env.NODE_ENV === 'production') { + s = require('./cjs/react-server-dom-webpack-server.node.production.js'); +} else { + s = require('./cjs/react-server-dom-webpack-server.node.development.js'); +} + +if (s.prerenderToNodeStream) { + exports.prerenderToNodeStream = s.prerenderToNodeStream; +} diff --git a/packages/react-server-dom-webpack/npm/static.node.unbundled.js b/packages/react-server-dom-webpack/npm/static.node.unbundled.js new file mode 100644 index 0000000000..73c8a3b86e --- /dev/null +++ b/packages/react-server-dom-webpack/npm/static.node.unbundled.js @@ -0,0 +1,12 @@ +'use strict'; + +var s; +if (process.env.NODE_ENV === 'production') { + s = require('./cjs/react-server-dom-webpack-server.node.unbundled.production.js'); +} else { + s = require('./cjs/react-server-dom-webpack-server.node.unbundled.development.js'); +} + +if (s.prerenderToNodeStream) { + exports.prerenderToNodeStream = s.prerenderToNodeStream; +} diff --git a/packages/react-server-dom-webpack/package.json b/packages/react-server-dom-webpack/package.json index b8e2ccf92e..7a1fe29d4d 100644 --- a/packages/react-server-dom-webpack/package.json +++ b/packages/react-server-dom-webpack/package.json @@ -23,6 +23,11 @@ "server.edge.js", "server.node.js", "server.node.unbundled.js", + "static.js", + "static.browser.js", + "static.edge.js", + "static.node.js", + "static.node.unbundled.js", "node-register.js", "cjs/", "esm/" @@ -63,6 +68,23 @@ "./server.edge": "./server.edge.js", "./server.node": "./server.node.js", "./server.node.unbundled": "./server.node.unbundled.js", + "./static": { + "react-server": { + "workerd": "./static.edge.js", + "deno": "./static.browser.js", + "node": { + "webpack": "./static.node.js", + "default": "./static.node.unbundled.js" + }, + "edge-light": "./static.edge.js", + "browser": "./static.browser.js" + }, + "default": "./static.js" + }, + "./static.browser": "./static.browser.js", + "./static.edge": "./static.edge.js", + "./static.node": "./static.node.js", + "./static.node.unbundled": "./static.node.unbundled.js", "./node-loader": "./esm/react-server-dom-webpack-node-loader.production.js", "./node-register": "./node-register.js", "./src/*": "./src/*.js", diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js index 718ddf6c57..faaf8aef01 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -10,6 +10,7 @@ 'use strict'; import {patchSetImmediate} from '../../../../scripts/jest/patchSetImmediate'; +import {Readable} from 'stream'; // Polyfills for test environment global.ReadableStream = @@ -28,6 +29,7 @@ let React; let FlightReactDOM; let ReactDOMClient; let ReactServerDOMServer; +let ReactServerDOMStaticServer; let ReactServerDOMClient; let ReactDOMFizzServer; let ReactDOMStaticServer; @@ -59,12 +61,20 @@ describe('ReactFlightDOM', () => { jest.mock('react-server-dom-webpack/server', () => require('react-server-dom-webpack/server.node.unbundled'), ); + if (__EXPERIMENTAL__) { + jest.mock('react-server-dom-webpack/static', () => + require('react-server-dom-webpack/static.node.unbundled'), + ); + } const WebpackMock = require('./utils/WebpackMock'); clientExports = WebpackMock.clientExports; clientModuleError = WebpackMock.clientModuleError; webpackMap = WebpackMock.webpackMap; ReactServerDOMServer = require('react-server-dom-webpack/server'); + if (__EXPERIMENTAL__) { + ReactServerDOMStaticServer = require('react-server-dom-webpack/static'); + } // This reset is to load modules for the SSR/Browser scope. jest.unmock('react-server-dom-webpack/server'); @@ -2650,4 +2660,66 @@ describe('ReactFlightDOM', () => {
, ); }); + + // @gate experimental + it('can prerender', async () => { + let resolveGreeting; + const greetingPromise = new Promise(resolve => { + resolveGreeting = resolve; + }); + + function App() { + return ( +
+ +
+ ); + } + + async function Greeting() { + await greetingPromise; + return 'hello world'; + } + + const {pendingResult} = await serverAct(async () => { + // destructure trick to avoid the act scope from awaiting the returned value + return { + pendingResult: ReactServerDOMStaticServer.prerenderToNodeStream( + , + webpackMap, + ), + }; + }); + + resolveGreeting(); + const {prelude} = await pendingResult; + + const response = ReactServerDOMClient.createFromReadableStream( + Readable.toWeb(prelude), + ); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const shellErrors = []; + await serverAct(async () => { + ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onShellError(error) { + shellErrors.push(error.message); + }, + }, + ).pipe(fizzWritable); + }); + + expect(shellErrors).toEqual([]); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual(
hello world
); + }); }); diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js index 6a6f2936f8..a4e0c3bef6 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js @@ -100,6 +100,65 @@ function renderToReadableStream( return stream; } +type StaticResult = { + prelude: ReadableStream, +}; + +function prerender( + model: ReactClientValue, + webpackMap: ClientManifest, + options?: Options, +): Promise { + return new Promise((resolve, reject) => { + const onFatalError = reject; + function onAllReady() { + const stream = new ReadableStream( + { + type: 'bytes', + start: (controller): ?Promise => { + startWork(request); + }, + 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}, + ); + resolve({prelude: stream}); + } + const request = createRequest( + model, + webpackMap, + options ? options.onError : undefined, + options ? options.identifierPrefix : undefined, + options ? options.onPostpone : undefined, + options ? options.temporaryReferences : undefined, + __DEV__ && options ? options.environmentName : undefined, + __DEV__ && options ? options.filterStackFrame : undefined, + onAllReady, + onFatalError, + ); + 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); + }); +} + function decodeReply( body: string | FormData, webpackMap: ServerManifest, @@ -121,4 +180,10 @@ function decodeReply( return root; } -export {renderToReadableStream, decodeReply, decodeAction, decodeFormState}; +export { + renderToReadableStream, + prerender, + decodeReply, + decodeAction, + decodeFormState, +}; diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js index 6a6f2936f8..a4e0c3bef6 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js @@ -100,6 +100,65 @@ function renderToReadableStream( return stream; } +type StaticResult = { + prelude: ReadableStream, +}; + +function prerender( + model: ReactClientValue, + webpackMap: ClientManifest, + options?: Options, +): Promise { + return new Promise((resolve, reject) => { + const onFatalError = reject; + function onAllReady() { + const stream = new ReadableStream( + { + type: 'bytes', + start: (controller): ?Promise => { + startWork(request); + }, + 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}, + ); + resolve({prelude: stream}); + } + const request = createRequest( + model, + webpackMap, + options ? options.onError : undefined, + options ? options.identifierPrefix : undefined, + options ? options.onPostpone : undefined, + options ? options.temporaryReferences : undefined, + __DEV__ && options ? options.environmentName : undefined, + __DEV__ && options ? options.filterStackFrame : undefined, + onAllReady, + onFatalError, + ); + 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); + }); +} + function decodeReply( body: string | FormData, webpackMap: ServerManifest, @@ -121,4 +180,10 @@ function decodeReply( return root; } -export {renderToReadableStream, decodeReply, decodeAction, decodeFormState}; +export { + renderToReadableStream, + prerender, + decodeReply, + decodeAction, + decodeFormState, +}; diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js index 73479bdf3e..1506259476 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js @@ -18,6 +18,8 @@ import type {Busboy} from 'busboy'; import type {Writable} from 'stream'; import type {Thenable} from 'shared/ReactTypes'; +import {Readable} from 'stream'; + import { createRequest, startWork, @@ -125,6 +127,81 @@ function renderToPipeableStream( }; } +function createFakeWritable(readable: any): Writable { + // The current host config expects a Writable so we create + // a fake writable for now to push into the Readable. + return ({ + write(chunk) { + return readable.push(chunk); + }, + end() { + readable.push(null); + }, + destroy(error) { + readable.destroy(error); + }, + }: any); +} + +type PrerenderOptions = { + environmentName?: string | (() => string), + filterStackFrame?: (url: string, functionName: string) => boolean, + onError?: (error: mixed) => void, + onPostpone?: (reason: string) => void, + identifierPrefix?: string, + temporaryReferences?: TemporaryReferenceSet, + signal?: AbortSignal, +}; + +type StaticResult = { + prelude: Readable, +}; + +function prerenderToNodeStream( + model: ReactClientValue, + webpackMap: ClientManifest, + options?: PrerenderOptions, +): Promise { + return new Promise((resolve, reject) => { + const onFatalError = reject; + function onAllReady() { + const readable: Readable = new Readable({ + read() { + startFlowing(request, writable); + }, + }); + const writable = createFakeWritable(readable); + resolve({prelude: readable}); + } + + const request = createRequest( + model, + webpackMap, + options ? options.onError : undefined, + options ? options.identifierPrefix : undefined, + options ? options.onPostpone : undefined, + options ? options.temporaryReferences : undefined, + __DEV__ && options ? options.environmentName : undefined, + __DEV__ && options ? options.filterStackFrame : undefined, + onAllReady, + onFatalError, + ); + 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); + }); +} + function decodeReplyFromBusboy( busboyStream: Busboy, webpackMap: ServerManifest, @@ -208,6 +285,7 @@ function decodeReply( export { renderToPipeableStream, + prerenderToNodeStream, decodeReplyFromBusboy, decodeReply, decodeAction, diff --git a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.browser.js b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.browser.js index 0100b65554..d8373ec551 100644 --- a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.browser.js +++ b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.browser.js @@ -9,6 +9,7 @@ export { renderToReadableStream, + prerender, decodeReply, decodeAction, decodeFormState, diff --git a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.browser.stable.js b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.browser.stable.js new file mode 100644 index 0000000000..0100b65554 --- /dev/null +++ b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.browser.stable.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export { + renderToReadableStream, + decodeReply, + decodeAction, + decodeFormState, + registerServerReference, + registerClientReference, + createClientModuleProxy, + createTemporaryReferenceSet, +} from './ReactFlightDOMServerBrowser'; diff --git a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.edge.js b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.edge.js index eb887b73a8..9521ba6b68 100644 --- a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.edge.js +++ b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.edge.js @@ -9,6 +9,7 @@ export { renderToReadableStream, + prerender, decodeReply, decodeAction, decodeFormState, diff --git a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.edge.stable.js b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.edge.stable.js new file mode 100644 index 0000000000..eb887b73a8 --- /dev/null +++ b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.edge.stable.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export { + renderToReadableStream, + decodeReply, + decodeAction, + decodeFormState, + registerServerReference, + registerClientReference, + createClientModuleProxy, + createTemporaryReferenceSet, +} from './ReactFlightDOMServerEdge'; diff --git a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.js b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.js index 0d15970406..badc2ed50b 100644 --- a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.js +++ b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.js @@ -9,6 +9,7 @@ export { renderToPipeableStream, + prerenderToNodeStream, decodeReplyFromBusboy, decodeReply, decodeAction, diff --git a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.stable.js b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.stable.js new file mode 100644 index 0000000000..0d15970406 --- /dev/null +++ b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.stable.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export { + renderToPipeableStream, + decodeReplyFromBusboy, + decodeReply, + decodeAction, + decodeFormState, + registerServerReference, + registerClientReference, + createClientModuleProxy, + createTemporaryReferenceSet, +} from './ReactFlightDOMServerNode'; diff --git a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.unbundled.js b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.unbundled.js index 0d15970406..badc2ed50b 100644 --- a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.unbundled.js +++ b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.unbundled.js @@ -9,6 +9,7 @@ export { renderToPipeableStream, + prerenderToNodeStream, decodeReplyFromBusboy, decodeReply, decodeAction, diff --git a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.unbundled.stable.js b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.unbundled.stable.js new file mode 100644 index 0000000000..0d15970406 --- /dev/null +++ b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.unbundled.stable.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export { + renderToPipeableStream, + decodeReplyFromBusboy, + decodeReply, + decodeAction, + decodeFormState, + registerServerReference, + registerClientReference, + createClientModuleProxy, + createTemporaryReferenceSet, +} from './ReactFlightDOMServerNode'; diff --git a/packages/react-server-dom-webpack/static.browser.js b/packages/react-server-dom-webpack/static.browser.js new file mode 100644 index 0000000000..2589789163 --- /dev/null +++ b/packages/react-server-dom-webpack/static.browser.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export {prerender} from './src/server/react-flight-dom-server.browser'; diff --git a/packages/react-server-dom-webpack/static.edge.js b/packages/react-server-dom-webpack/static.edge.js new file mode 100644 index 0000000000..a39d54c73f --- /dev/null +++ b/packages/react-server-dom-webpack/static.edge.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export {prerender} from './src/server/react-flight-dom-server.edge'; diff --git a/packages/react-server-dom-webpack/static.js b/packages/react-server-dom-webpack/static.js new file mode 100644 index 0000000000..83d8b8a017 --- /dev/null +++ b/packages/react-server-dom-webpack/static.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +throw new Error( + 'The React Server cannot be used outside a react-server environment. ' + + 'You must configure Node.js using the `--conditions react-server` flag.', +); diff --git a/packages/react-server-dom-webpack/static.node.js b/packages/react-server-dom-webpack/static.node.js new file mode 100644 index 0000000000..d15eddc6f9 --- /dev/null +++ b/packages/react-server-dom-webpack/static.node.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export {prerenderToNodeStream} from './src/server/react-flight-dom-server.node'; diff --git a/packages/react-server-dom-webpack/static.node.unbundled.js b/packages/react-server-dom-webpack/static.node.unbundled.js new file mode 100644 index 0000000000..b2134459af --- /dev/null +++ b/packages/react-server-dom-webpack/static.node.unbundled.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export {prerenderToNodeStream} from './src/server/react-flight-dom-server.node.unbundled'; diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 0f3c53960e..d8ba106d37 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -376,6 +376,8 @@ export type Request = { taintCleanupQueue: Array, onError: (error: mixed) => ?string, onPostpone: (reason: string) => void, + onAllReady: () => void, + onFatalError: mixed => void, // DEV-only environmentName: () => string, filterStackFrame: (url: string, functionName: string) => boolean, @@ -435,6 +437,8 @@ function RequestInstance( temporaryReferences: void | TemporaryReferenceSet, environmentName: void | string | (() => string), // DEV-only filterStackFrame: void | ((url: string, functionName: string) => boolean), // DEV-only + onAllReady: void | (() => void), + onFatalError: void | ((error: mixed) => void), ) { if ( ReactSharedInternals.A !== null && @@ -486,6 +490,8 @@ function RequestInstance( this.onError = onError === undefined ? defaultErrorHandler : onError; this.onPostpone = onPostpone === undefined ? defaultPostponeHandler : onPostpone; + this.onAllReady = onAllReady === undefined ? noop : onAllReady; + this.onFatalError = onFatalError === undefined ? noop : onFatalError; if (__DEV__) { this.environmentName = @@ -513,6 +519,8 @@ function RequestInstance( pingedTasks.push(rootTask); } +function noop(): void {} + export function createRequest( model: ReactClientValue, bundlerConfig: ClientManifest, @@ -522,6 +530,8 @@ export function createRequest( temporaryReferences: void | TemporaryReferenceSet, environmentName: void | string | (() => string), // DEV-only filterStackFrame: void | ((url: string, functionName: string) => boolean), // DEV-only + onAllReady: void | (() => void), + onFatalError: void | (() => void), ): Request { // $FlowFixMe[invalid-constructor]: the shapes are exact here but Flow doesn't like constructors return new RequestInstance( @@ -533,6 +543,8 @@ export function createRequest( temporaryReferences, environmentName, filterStackFrame, + onAllReady, + onFatalError, ); } @@ -2890,6 +2902,8 @@ function logRecoverableError( } function fatalError(request: Request, error: mixed): void { + const onFatalError = request.onFatalError; + onFatalError(error); if (enableTaint) { cleanupTaintQueue(request); } @@ -3753,6 +3767,11 @@ function performWork(request: Request): void { if (request.destination !== null) { flushCompletedChunks(request, request.destination); } + if (request.abortableTasks.size === 0) { + // we're done rendering + const onAllReady = request.onAllReady; + onAllReady(); + } } catch (error) { logRecoverableError(request, error, null); fatalError(request, error); diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index 4a07e03653..be5706c927 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -43,6 +43,8 @@ module.exports = [ 'react-server-dom-webpack/client.node.unbundled', 'react-server-dom-webpack/server', 'react-server-dom-webpack/server.node.unbundled', + 'react-server-dom-webpack/static', + 'react-server-dom-webpack/static.node.unbundled', 'react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js', // react-server-dom-webpack/client.node 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerNode.js', 'react-server-dom-webpack/src/server/react-flight-dom-server.node.unbundled', @@ -82,6 +84,8 @@ module.exports = [ 'react-server-dom-webpack/client.node', 'react-server-dom-webpack/server', 'react-server-dom-webpack/server.node', + 'react-server-dom-webpack/static', + 'react-server-dom-webpack/static.node', 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js', 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpackServer.js', 'react-server-dom-webpack/src/server/react-flight-dom-server.node', @@ -123,6 +127,8 @@ module.exports = [ 'react-server-dom-turbopack/client.node.unbundled', 'react-server-dom-turbopack/server', 'react-server-dom-turbopack/server.node.unbundled', + 'react-server-dom-turbopack/static', + 'react-server-dom-turbopack/static.node.unbundled', 'react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js', // react-server-dom-turbopack/client.node.unbundled 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerNode.js', 'react-server-dom-turbopack/src/server/react-flight-dom-server.node.unbundled', @@ -164,6 +170,8 @@ module.exports = [ 'react-server-dom-turbopack/client.node', 'react-server-dom-turbopack/server', 'react-server-dom-turbopack/server.node', + 'react-server-dom-turbopack/static', + 'react-server-dom-turbopack/static.node', 'react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js', // react-server-dom-turbopack/client.node 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js', 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopackServer.js', @@ -238,6 +246,7 @@ module.exports = [ 'react-server-dom-webpack/client', 'react-server-dom-webpack/client.browser', 'react-server-dom-webpack/server.browser', + 'react-server-dom-webpack/static.browser', 'react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js', // react-server-dom-webpack/client.browser 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js', 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpackBrowser.js', @@ -299,6 +308,7 @@ module.exports = [ 'react-server-dom-turbopack/client', 'react-server-dom-turbopack/client.browser', 'react-server-dom-turbopack/server.browser', + 'react-server-dom-turbopack/static.browser', 'react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js', // react-server-dom-turbopack/client.browser 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js', 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopackBrowser.js', @@ -339,6 +349,7 @@ module.exports = [ 'react-server-dom-webpack', 'react-server-dom-webpack/client.edge', 'react-server-dom-webpack/server.edge', + 'react-server-dom-webpack/static.edge', 'react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js', // react-server-dom-webpack/client.edge 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js', 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpackServer.js', @@ -378,6 +389,7 @@ module.exports = [ 'react-server-dom-turbopack', 'react-server-dom-turbopack/client.edge', 'react-server-dom-turbopack/server.edge', + 'react-server-dom-turbopack/static.edge', 'react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js', // react-server-dom-turbopack/client.edge 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js', 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopackServer.js', @@ -419,6 +431,8 @@ module.exports = [ 'react-server-dom-esm/client.node', 'react-server-dom-esm/server', 'react-server-dom-esm/server.node', + 'react-server-dom-esm/static', + 'react-server-dom-esm/static.node', 'react-server-dom-esm/src/client/ReactFlightDOMClientNode.js', // react-server-dom-esm/client.node 'react-server-dom-esm/src/server/react-flight-dom-server.node', 'react-server-dom-esm/src/server/ReactFlightDOMServerNode.js', // react-server-dom-esm/src/server/react-flight-dom-server.node