From 4900980fc1368bb76a50ba7c0406aa64d1a10ae8 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Wed, 14 Aug 2024 15:51:24 -0700 Subject: [PATCH] [Flight] Implement prerender 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 267bd109081a..f2bcbbd111c8 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 e4ae3a62916a..8133bf791219 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 bc4ba05ddf3b..daf619741a1e 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 027056c51502..08987750eb21 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 000000000000..13a632e64117 --- /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 000000000000..ff0b9b2a42f2 --- /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 bd9e9c394962..a1f8f17f4501 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 c724d89c2b43..bb65ef4b659a 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 d14d2b8ed362..f24946fcae8b 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 000000000000..d14d2b8ed362 --- /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 000000000000..83d8b8a017ff --- /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 000000000000..d15eddc6f9b0 --- /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 000000000000..edc104a45938 --- /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 000000000000..c074f8ffe7ee --- /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 000000000000..13a632e64117 --- /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 000000000000..84083a965189 --- /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 000000000000..e77863bf36a6 --- /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 93e694b3a3e1..93cd7d37a04a 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 58a87992d6c6..ef980764942d 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 58a87992d6c6..ef980764942d 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 d76bcb5759b0..e484d4b7e77d 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 0100b65554ae..d8373ec551bc 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 000000000000..0100b65554ae --- /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 eb887b73a8ae..9521ba6b6884 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 000000000000..eb887b73a8ae --- /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 0d159704067e..badc2ed50b69 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 000000000000..0d159704067e --- /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 0d159704067e..badc2ed50b69 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 000000000000..0d159704067e --- /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 000000000000..258978916320 --- /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 000000000000..a39d54c73f57 --- /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 000000000000..83d8b8a017ff --- /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 000000000000..d15eddc6f9b0 --- /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 000000000000..b2134459afc7 --- /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 000000000000..7d514abd6bf7 --- /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 000000000000..a4ae48f55eb1 --- /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 000000000000..13a632e64117 --- /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 000000000000..dbc4179d3e78 --- /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 000000000000..73c8a3b86e9c --- /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 b8e2ccf92e3e..7a1fe29d4d4a 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 718ddf6c5716..faaf8aef01b0 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 6a6f2936f846..a4e0c3bef693 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 6a6f2936f846..a4e0c3bef693 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 73479bdf3ef0..150625947670 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 0100b65554ae..d8373ec551bc 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 000000000000..0100b65554ae --- /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 eb887b73a8ae..9521ba6b6884 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 000000000000..eb887b73a8ae --- /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 0d159704067e..badc2ed50b69 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 000000000000..0d159704067e --- /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 0d159704067e..badc2ed50b69 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 000000000000..0d159704067e --- /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 000000000000..258978916320 --- /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 000000000000..a39d54c73f57 --- /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 000000000000..83d8b8a017ff --- /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 000000000000..d15eddc6f9b0 --- /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 000000000000..b2134459afc7 --- /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 6c9536d95acf..b5e54ecd0f28 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, ); } @@ -2886,6 +2898,8 @@ function logRecoverableError( } function fatalError(request: Request, error: mixed): void { + const onFatalError = request.onFatalError; + onFatalError(error); if (enableTaint) { cleanupTaintQueue(request); } @@ -3748,6 +3762,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 4a07e036530a..be5706c927c7 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