From 60144a04da7970e30266f591dbcd67afe1097e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 21 Feb 2023 13:18:24 -0500 Subject: [PATCH] Split out Edge and Node implementations of the Flight Client (#26187) This splits out the Edge and Node implementations of Flight Client into their own implementations. The Node implementation now takes a Node Stream as input. I removed the bundler config from the Browser variant because you're never supposed to use that in the browser since it's only for SSR. Similarly, it's required on the server. This also enables generating a SSR manifest from the Webpack plugin. This is necessary for SSR so that you can reverse look up what a client module is called on the server. I also removed the option to pass a callServer from the server. We might want to add it back in the future but basically, we don't recommend calling Server Functions from render for initial render because if that happened client-side it would be a client-side waterfall. If it's never called in initial render, then it also shouldn't ever happen during SSR. This might be considered too restrictive. ~This also compiles the unbundled packages as ESM. This isn't strictly necessary because we only need access to dynamic import to load the modules but we don't have any other build options that leave `import(...)` intact, and seems appropriate that this would also be an ESM module.~ Went with `import(...)` in CJS instead. --- fixtures/flight/loader/index.js | 25 +++- fixtures/flight/server/handler.js | 4 +- .../src/ReactFlightClientHostConfigNode.js | 34 ++++++ ...FlightClientHostConfig.dom-node-webpack.js | 2 +- .../ReactFlightClientHostConfig.dom-node.js | 4 +- .../client.browser.js | 2 +- .../react-server-dom-webpack/client.edge.js | 2 +- .../react-server-dom-webpack/client.node.js | 2 +- .../client.node.unbundled.js | 2 +- ...dom-webpack-node-loader.production.min.js} | 0 .../react-server-dom-webpack/package.json | 2 +- .../src/ReactFlightClientNodeBundlerConfig.js | 98 ++++++++++++++++ ...ient.js => ReactFlightDOMClientBrowser.js} | 25 ++-- .../src/ReactFlightDOMClientEdge.js | 95 +++++++++++++++ .../src/ReactFlightDOMClientNode.js | 54 +++++++++ .../src/ReactFlightWebpackPlugin.js | 52 ++++++--- .../__tests__/ReactFlightDOMBrowser-test.js | 62 ---------- .../src/__tests__/ReactFlightDOMEdge-test.js | 98 ++++++++++++++++ .../src/__tests__/ReactFlightDOMNode-test.js | 108 ++++++++++++++++++ scripts/flow/environment.js | 17 +++ scripts/rollup/build.js | 25 ++-- scripts/rollup/bundles.js | 18 +-- scripts/rollup/packaging.js | 6 +- scripts/rollup/plugins/dynamic-imports.js | 19 +++ scripts/rollup/validate/eslintrc.cjs.js | 2 +- scripts/rollup/validate/eslintrc.esm.js | 2 +- scripts/rollup/wrappers.js | 19 ++- scripts/shared/inlinedHostConfigs.js | 2 + 28 files changed, 657 insertions(+), 124 deletions(-) create mode 100644 packages/react-client/src/ReactFlightClientHostConfigNode.js rename packages/react-server-dom-webpack/esm/{react-server-dom-webpack-node-loader.js => react-server-dom-webpack-node-loader.production.min.js} (100%) create mode 100644 packages/react-server-dom-webpack/src/ReactFlightClientNodeBundlerConfig.js rename packages/react-server-dom-webpack/src/{ReactFlightDOMClient.js => ReactFlightDOMClientBrowser.js} (81%) create mode 100644 packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js create mode 100644 packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js create mode 100644 packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js create mode 100644 packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js create mode 100644 scripts/rollup/plugins/dynamic-imports.js diff --git a/fixtures/flight/loader/index.js b/fixtures/flight/loader/index.js index 6a78bbde04f97..fc2b3ced7ec5f 100644 --- a/fixtures/flight/loader/index.js +++ b/fixtures/flight/loader/index.js @@ -23,8 +23,17 @@ async function babelLoad(url, context, defaultLoad) { const result = await defaultLoad(url, context, defaultLoad); if (result.format === 'module') { const opt = Object.assign({filename: url}, babelOptions); - const {code} = await babel.transformAsync(result.source, opt); - return {source: code, format: 'module'}; + const newResult = await babel.transformAsync(result.source, opt); + if (!newResult) { + if (typeof result.source === 'string') { + return result; + } + return { + source: Buffer.from(result.source).toString('utf8'), + format: 'module', + }; + } + return {source: newResult.code, format: 'module'}; } return defaultLoad(url, context, defaultLoad); } @@ -39,8 +48,16 @@ async function babelTransformSource(source, context, defaultTransformSource) { const {format} = context; if (format === 'module') { const opt = Object.assign({filename: context.url}, babelOptions); - const {code} = await babel.transformAsync(source, opt); - return {source: code}; + const newResult = await babel.transformAsync(source, opt); + if (!newResult) { + if (typeof source === 'string') { + return {source}; + } + return { + source: Buffer.from(source).toString('utf8'), + }; + } + return {source: newResult.code}; } return defaultTransformSource(source, context, defaultTransformSource); } diff --git a/fixtures/flight/server/handler.js b/fixtures/flight/server/handler.js index 549424afaf525..b67147b7dca36 100644 --- a/fixtures/flight/server/handler.js +++ b/fixtures/flight/server/handler.js @@ -1,11 +1,13 @@ 'use strict'; -const {renderToPipeableStream} = require('react-server-dom-webpack/server'); const {readFile} = require('fs').promises; const {resolve} = require('path'); const React = require('react'); module.exports = async function (req, res) { + const {renderToPipeableStream} = await import( + 'react-server-dom-webpack/server' + ); switch (req.method) { case 'POST': { const serverReference = JSON.parse(req.get('rsc-action')); diff --git a/packages/react-client/src/ReactFlightClientHostConfigNode.js b/packages/react-client/src/ReactFlightClientHostConfigNode.js new file mode 100644 index 0000000000000..16d3e75316ac2 --- /dev/null +++ b/packages/react-client/src/ReactFlightClientHostConfigNode.js @@ -0,0 +1,34 @@ +/** + * 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 + */ + +import {TextDecoder} from 'util'; + +export type StringDecoder = TextDecoder; + +export const supportsBinaryStreams = true; + +export function createStringDecoder(): StringDecoder { + return new TextDecoder(); +} + +const decoderOptions = {stream: true}; + +export function readPartialStringChunk( + decoder: StringDecoder, + buffer: Uint8Array, +): string { + return decoder.decode(buffer, decoderOptions); +} + +export function readFinalStringChunk( + decoder: StringDecoder, + buffer: Uint8Array, +): string { + return decoder.decode(buffer); +} diff --git a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-node-webpack.js b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-node-webpack.js index 4aae8141fd56e..8b9b2defedff5 100644 --- a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-node-webpack.js +++ b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-node-webpack.js @@ -7,6 +7,6 @@ * @flow */ -export * from 'react-client/src/ReactFlightClientHostConfigBrowser'; +export * from 'react-client/src/ReactFlightClientHostConfigNode'; export * from 'react-client/src/ReactFlightClientHostConfigStream'; export * from 'react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig'; diff --git a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-node.js b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-node.js index 4aae8141fd56e..5c20adb286414 100644 --- a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-node.js +++ b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-node.js @@ -7,6 +7,6 @@ * @flow */ -export * from 'react-client/src/ReactFlightClientHostConfigBrowser'; +export * from 'react-client/src/ReactFlightClientHostConfigNode'; export * from 'react-client/src/ReactFlightClientHostConfigStream'; -export * from 'react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig'; +export * from 'react-server-dom-webpack/src/ReactFlightClientNodeBundlerConfig'; diff --git a/packages/react-server-dom-webpack/client.browser.js b/packages/react-server-dom-webpack/client.browser.js index 9b9c654fb5804..7d26c2771e50a 100644 --- a/packages/react-server-dom-webpack/client.browser.js +++ b/packages/react-server-dom-webpack/client.browser.js @@ -7,4 +7,4 @@ * @flow */ -export * from './src/ReactFlightDOMClient'; +export * from './src/ReactFlightDOMClientBrowser'; diff --git a/packages/react-server-dom-webpack/client.edge.js b/packages/react-server-dom-webpack/client.edge.js index 9b9c654fb5804..fadceeaf8443a 100644 --- a/packages/react-server-dom-webpack/client.edge.js +++ b/packages/react-server-dom-webpack/client.edge.js @@ -7,4 +7,4 @@ * @flow */ -export * from './src/ReactFlightDOMClient'; +export * from './src/ReactFlightDOMClientEdge'; diff --git a/packages/react-server-dom-webpack/client.node.js b/packages/react-server-dom-webpack/client.node.js index 9b9c654fb5804..4f435353a20f0 100644 --- a/packages/react-server-dom-webpack/client.node.js +++ b/packages/react-server-dom-webpack/client.node.js @@ -7,4 +7,4 @@ * @flow */ -export * from './src/ReactFlightDOMClient'; +export * from './src/ReactFlightDOMClientNode'; diff --git a/packages/react-server-dom-webpack/client.node.unbundled.js b/packages/react-server-dom-webpack/client.node.unbundled.js index 9b9c654fb5804..4f435353a20f0 100644 --- a/packages/react-server-dom-webpack/client.node.unbundled.js +++ b/packages/react-server-dom-webpack/client.node.unbundled.js @@ -7,4 +7,4 @@ * @flow */ -export * from './src/ReactFlightDOMClient'; +export * from './src/ReactFlightDOMClientNode'; diff --git a/packages/react-server-dom-webpack/esm/react-server-dom-webpack-node-loader.js b/packages/react-server-dom-webpack/esm/react-server-dom-webpack-node-loader.production.min.js similarity index 100% rename from packages/react-server-dom-webpack/esm/react-server-dom-webpack-node-loader.js rename to packages/react-server-dom-webpack/esm/react-server-dom-webpack-node-loader.production.min.js diff --git a/packages/react-server-dom-webpack/package.json b/packages/react-server-dom-webpack/package.json index e1a0945b1d089..3f09719bb06e7 100644 --- a/packages/react-server-dom-webpack/package.json +++ b/packages/react-server-dom-webpack/package.json @@ -64,7 +64,7 @@ "./server.edge": "./server.edge.js", "./server.node": "./server.node.js", "./server.node.unbundled": "./server.node.unbundled.js", - "./node-loader": "./esm/react-server-dom-webpack-node-loader.js", + "./node-loader": "./esm/react-server-dom-webpack-node-loader.production.min.js", "./node-register": "./node-register.js", "./src/*": "./src/*", "./package.json": "./package.json" diff --git a/packages/react-server-dom-webpack/src/ReactFlightClientNodeBundlerConfig.js b/packages/react-server-dom-webpack/src/ReactFlightClientNodeBundlerConfig.js new file mode 100644 index 0000000000000..a32f7e9596c4c --- /dev/null +++ b/packages/react-server-dom-webpack/src/ReactFlightClientNodeBundlerConfig.js @@ -0,0 +1,98 @@ +/** + * 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 + */ + +import type { + Thenable, + FulfilledThenable, + RejectedThenable, +} from 'shared/ReactTypes'; + +export type WebpackSSRMap = { + [clientId: string]: { + [clientExportName: string]: ClientReference, + }, +}; + +export type BundlerConfig = WebpackSSRMap; + +export opaque type ClientReferenceMetadata = { + id: string, + chunks: Array, + name: string, + async: boolean, +}; + +// eslint-disable-next-line no-unused-vars +export opaque type ClientReference = { + specifier: string, + name: string, +}; + +export function resolveClientReference( + bundlerConfig: BundlerConfig, + metadata: ClientReferenceMetadata, +): ClientReference { + const resolvedModuleData = bundlerConfig[metadata.id][metadata.name]; + return resolvedModuleData; +} + +const asyncModuleCache: Map> = new Map(); + +export function preloadModule( + metadata: ClientReference, +): null | Thenable { + const existingPromise = asyncModuleCache.get(metadata.specifier); + if (existingPromise) { + if (existingPromise.status === 'fulfilled') { + return null; + } + return existingPromise; + } else { + // $FlowFixMe[unsupported-syntax] + const modulePromise: Thenable = import(metadata.specifier); + modulePromise.then( + value => { + const fulfilledThenable: FulfilledThenable = + (modulePromise: any); + fulfilledThenable.status = 'fulfilled'; + fulfilledThenable.value = value; + }, + reason => { + const rejectedThenable: RejectedThenable = (modulePromise: any); + rejectedThenable.status = 'rejected'; + rejectedThenable.reason = reason; + }, + ); + asyncModuleCache.set(metadata.specifier, modulePromise); + return modulePromise; + } +} + +export function requireModule(metadata: ClientReference): T { + let moduleExports; + // We assume that preloadModule has been called before, which + // should have added something to the module cache. + const promise: any = asyncModuleCache.get(metadata.specifier); + if (promise.status === 'fulfilled') { + moduleExports = promise.value; + } else { + throw promise.reason; + } + if (metadata.name === '*') { + // This is a placeholder value that represents that the caller imported this + // as a CommonJS module as is. + return moduleExports; + } + if (metadata.name === '') { + // This is a placeholder value that represents that the caller accessed the + // default property of this if it was an ESM interop module. + return moduleExports.default; + } + return moduleExports[metadata.name]; +} diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClient.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js similarity index 81% rename from packages/react-server-dom-webpack/src/ReactFlightDOMClient.js rename to packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js index f2b9d0d567e4b..5434876ee4946 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClient.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js @@ -11,8 +11,6 @@ import type {Thenable} from 'shared/ReactTypes.js'; import type {Response as FlightResponse} from 'react-client/src/ReactFlightClientStream'; -import type {BundlerConfig} from './ReactFlightClientWebpackBundlerConfig'; - import { createResponse, getRoot, @@ -28,10 +26,16 @@ type CallServerCallback = ( ) => Promise; export type Options = { - moduleMap?: BundlerConfig, callServer?: CallServerCallback, }; +function createResponseFromOptions(options: void | Options) { + return createResponse( + null, + options && options.callServer ? options.callServer : undefined, + ); +} + function startReadingFromStream( response: FlightResponse, stream: ReadableStream, @@ -63,10 +67,7 @@ function createFromReadableStream( stream: ReadableStream, options?: Options, ): Thenable { - const response: FlightResponse = createResponse( - options && options.moduleMap ? options.moduleMap : null, - options && options.callServer ? options.callServer : undefined, - ); + const response: FlightResponse = createResponseFromOptions(options); startReadingFromStream(response, stream); return getRoot(response); } @@ -75,10 +76,7 @@ function createFromFetch( promiseForResponse: Promise, options?: Options, ): Thenable { - const response: FlightResponse = createResponse( - options && options.moduleMap ? options.moduleMap : null, - options && options.callServer ? options.callServer : undefined, - ); + const response: FlightResponse = createResponseFromOptions(options); promiseForResponse.then( function (r) { startReadingFromStream(response, (r.body: any)); @@ -94,10 +92,7 @@ function createFromXHR( request: XMLHttpRequest, options?: Options, ): Thenable { - const response: FlightResponse = createResponse( - options && options.moduleMap ? options.moduleMap : null, - options && options.callServer ? options.callServer : undefined, - ); + const response: FlightResponse = createResponseFromOptions(options); let processedLength = 0; function progress(e: ProgressEvent): void { const chunk = request.responseText; diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js new file mode 100644 index 0000000000000..24dd5699beb45 --- /dev/null +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js @@ -0,0 +1,95 @@ +/** + * 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 + */ + +import type {Thenable} from 'shared/ReactTypes.js'; + +import type {Response as FlightResponse} from 'react-client/src/ReactFlightClientStream'; + +import type {BundlerConfig} from './ReactFlightClientWebpackBundlerConfig'; + +import { + createResponse, + getRoot, + reportGlobalError, + processBinaryChunk, + close, +} from 'react-client/src/ReactFlightClientStream'; + +function noServerCall() { + throw new Error( + 'Server Functions cannot be called during initial render. ' + + 'This would create a fetch waterfall. Try to use a Server Component ' + + 'to pass data to Client Components instead.', + ); +} + +export type Options = { + moduleMap?: BundlerConfig, +}; + +function createResponseFromOptions(options: void | Options) { + return createResponse( + options && options.moduleMap ? options.moduleMap : null, + noServerCall, + ); +} + +function startReadingFromStream( + response: FlightResponse, + stream: ReadableStream, +): void { + const reader = stream.getReader(); + function progress({ + done, + value, + }: { + done: boolean, + value: ?any, + ... + }): void | Promise { + if (done) { + close(response); + return; + } + const buffer: Uint8Array = (value: any); + processBinaryChunk(response, buffer); + return reader.read().then(progress).catch(error); + } + function error(e: any) { + reportGlobalError(response, e); + } + reader.read().then(progress).catch(error); +} + +function createFromReadableStream( + stream: ReadableStream, + options?: Options, +): Thenable { + const response: FlightResponse = createResponseFromOptions(options); + startReadingFromStream(response, stream); + return getRoot(response); +} + +function createFromFetch( + promiseForResponse: Promise, + options?: Options, +): Thenable { + const response: FlightResponse = createResponseFromOptions(options); + promiseForResponse.then( + function (r) { + startReadingFromStream(response, (r.body: any)); + }, + function (e) { + reportGlobalError(response, e); + }, + ); + return getRoot(response); +} + +export {createFromFetch, createFromReadableStream}; diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js new file mode 100644 index 0000000000000..532ee43b81e19 --- /dev/null +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js @@ -0,0 +1,54 @@ +/** + * 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 + */ + +import type {Thenable} from 'shared/ReactTypes.js'; + +import type {Response} from 'react-client/src/ReactFlightClientStream'; + +import type {BundlerConfig} from 'react-client/src/ReactFlightClientHostConfig'; + +import type {Readable} from 'stream'; + +import { + createResponse, + getRoot, + reportGlobalError, + processBinaryChunk, + close, +} from 'react-client/src/ReactFlightClientStream'; +import {processStringChunk} from '../../react-client/src/ReactFlightClientStream'; + +function noServerCall() { + throw new Error( + 'Server Functions cannot be called during initial render. ' + + 'This would create a fetch waterfall. Try to use a Server Component ' + + 'to pass data to Client Components instead.', + ); +} + +function createFromNodeStream( + stream: Readable, + moduleMap: $NonMaybeType, +): Thenable { + const response: Response = createResponse(moduleMap, noServerCall); + stream.on('data', chunk => { + if (typeof chunk === 'string') { + processStringChunk(response, chunk, 0); + } else { + processBinaryChunk(response, chunk); + } + }); + stream.on('error', error => { + reportGlobalError(response, error); + }); + stream.on('end', () => close(response)); + return getRoot(response); +} + +export {createFromNodeStream}; diff --git a/packages/react-server-dom-webpack/src/ReactFlightWebpackPlugin.js b/packages/react-server-dom-webpack/src/ReactFlightWebpackPlugin.js index 987a803f64431..859993112e551 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightWebpackPlugin.js +++ b/packages/react-server-dom-webpack/src/ReactFlightWebpackPlugin.js @@ -55,7 +55,8 @@ type Options = { isServer: boolean, clientReferences?: ClientReferencePath | $ReadOnlyArray, chunkName?: string, - manifestFilename?: string, + clientManifestFilename?: string, + ssrManifestFilename?: string, }; const PLUGIN_NAME = 'React Server Plugin'; @@ -63,7 +64,8 @@ const PLUGIN_NAME = 'React Server Plugin'; export default class ReactFlightWebpackPlugin { clientReferences: $ReadOnlyArray; chunkName: string; - manifestFilename: string; + clientManifestFilename: string; + ssrManifestFilename: string; constructor(options: Options) { if (!options || typeof options.isServer !== 'boolean') { @@ -99,8 +101,10 @@ export default class ReactFlightWebpackPlugin { } else { this.chunkName = 'client[index]'; } - this.manifestFilename = - options.manifestFilename || 'react-client-manifest.json'; + this.clientManifestFilename = + options.clientManifestFilename || 'react-client-manifest.json'; + this.ssrManifestFilename = + options.ssrManifestFilename || 'react-ssr-manifest.json'; } apply(compiler: any) { @@ -209,15 +213,20 @@ export default class ReactFlightWebpackPlugin { if (clientFileNameFound === false) { compilation.warnings.push( new WebpackError( - `Client runtime at ${clientImportName} was not found. React Server Components module map file ${_this.manifestFilename} was not created.`, + `Client runtime at ${clientImportName} was not found. React Server Components module map file ${_this.clientManifestFilename} was not created.`, ), ); return; } - const json: { + const clientManifest: { [string]: { - [string]: {chunks: $FlowFixMe, id: $FlowFixMe, name: string}, + [string]: {chunks: $FlowFixMe, id: string, name: string}, + }, + } = {}; + const ssrManifest: { + [string]: { + [string]: {specifier: string, name: string}, }, } = {}; compilation.chunkGroups.forEach(function (chunkGroup) { @@ -239,9 +248,16 @@ export default class ReactFlightWebpackPlugin { .getExportsInfo(module) .getProvidedExports(); - const moduleExports: { + const clientExports: { [string]: {chunks: $FlowFixMe, id: $FlowFixMe, name: string}, } = {}; + + const ssrExports: { + [string]: {specifier: string, name: string}, + } = {}; + + ssrManifest[id] = ssrExports; + ['', '*'] .concat( Array.isArray(moduleProvidedExports) @@ -249,16 +265,21 @@ export default class ReactFlightWebpackPlugin { : [], ) .forEach(function (name) { - moduleExports[name] = { + clientExports[name] = { id, chunks: chunkIds, name: name, }; + ssrExports[name] = { + specifier: module.resource, + name: name, + }; }); const href = pathToFileURL(module.resource).href; if (href !== undefined) { - json[href] = moduleExports; + clientManifest[href] = clientExports; + ssrManifest[href] = ssrExports; } } @@ -280,10 +301,15 @@ export default class ReactFlightWebpackPlugin { }); }); - const output = JSON.stringify(json, null, 2); + const clientOutput = JSON.stringify(clientManifest, null, 2); + compilation.emitAsset( + _this.clientManifestFilename, + new sources.RawSource(clientOutput, false), + ); + const ssrOutput = JSON.stringify(ssrManifest, null, 2); compilation.emitAsset( - _this.manifestFilename, - new sources.RawSource(output, false), + _this.ssrManifestFilename, + new sources.RawSource(ssrOutput, false), ); }, ); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index c9cb1562b4f6e..157301ba58e0b 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -18,12 +18,10 @@ global.TextDecoder = require('util').TextDecoder; let clientExports; let serverExports; let webpackMap; -let webpackModules; let webpackServerMap; let act; let React; let ReactDOMClient; -let ReactDOMServer; let ReactServerDOMWriter; let ReactServerDOMReader; let Suspense; @@ -37,29 +35,15 @@ describe('ReactFlightDOMBrowser', () => { clientExports = WebpackMock.clientExports; serverExports = WebpackMock.serverExports; webpackMap = WebpackMock.webpackMap; - webpackModules = WebpackMock.webpackModules; webpackServerMap = WebpackMock.webpackServerMap; React = require('react'); ReactDOMClient = require('react-dom/client'); - ReactDOMServer = require('react-dom/server.browser'); ReactServerDOMWriter = require('react-server-dom-webpack/server.browser'); ReactServerDOMReader = require('react-server-dom-webpack/client'); Suspense = React.Suspense; use = React.use; }); - async function readResult(stream) { - const reader = stream.getReader(); - let result = ''; - while (true) { - const {done, value} = await reader.read(); - if (done) { - return result; - } - result += Buffer.from(value).toString('utf8'); - } - } - function makeDelayedText(Model) { let error, _resolve, _reject; let promise = new Promise((resolve, reject) => { @@ -466,52 +450,6 @@ describe('ReactFlightDOMBrowser', () => { expect(isDone).toBeTruthy(); }); - // @gate enableUseHook - it('should allow an alternative module mapping to be used for SSR', async () => { - function ClientComponent() { - return Client Component; - } - // The Client build may not have the same IDs as the Server bundles for the same - // component. - const ClientComponentOnTheClient = clientExports(ClientComponent); - const ClientComponentOnTheServer = clientExports(ClientComponent); - - // In the SSR bundle this module won't exist. We simulate this by deleting it. - const clientId = webpackMap[ClientComponentOnTheClient.filepath]['*'].id; - delete webpackModules[clientId]; - - // Instead, we have to provide a translation from the client meta data to the SSR - // meta data. - const ssrMetadata = webpackMap[ClientComponentOnTheServer.filepath]['*']; - const translationMap = { - [clientId]: { - '*': ssrMetadata, - }, - }; - - function App() { - return ; - } - - const stream = ReactServerDOMWriter.renderToReadableStream( - , - webpackMap, - ); - const response = ReactServerDOMReader.createFromReadableStream(stream, { - moduleMap: translationMap, - }); - - function ClientRoot() { - return use(response); - } - - const ssrStream = await ReactDOMServer.renderToReadableStream( - , - ); - const result = await readResult(ssrStream); - expect(result).toEqual('Client Component'); - }); - // @gate enableUseHook it('should be able to complete after aborting and throw the reason client-side', async () => { const reportedErrors = []; diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js new file mode 100644 index 0000000000000..a491e71096258 --- /dev/null +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -0,0 +1,98 @@ +/** + * 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. + * + * @emails react-core + */ + +'use strict'; + +// Polyfills for test environment +global.ReadableStream = + require('web-streams-polyfill/ponyfill/es6').ReadableStream; +global.TextEncoder = require('util').TextEncoder; +global.TextDecoder = require('util').TextDecoder; + +let clientExports; +let webpackMap; +let webpackModules; +let React; +let ReactDOMServer; +let ReactServerDOMWriter; +let ReactServerDOMReader; +let use; + +describe('ReactFlightDOMEdge', () => { + beforeEach(() => { + jest.resetModules(); + const WebpackMock = require('./utils/WebpackMock'); + clientExports = WebpackMock.clientExports; + webpackMap = WebpackMock.webpackMap; + webpackModules = WebpackMock.webpackModules; + React = require('react'); + ReactDOMServer = require('react-dom/server.edge'); + ReactServerDOMWriter = require('react-server-dom-webpack/server.edge'); + ReactServerDOMReader = require('react-server-dom-webpack/client.edge'); + use = React.use; + }); + + async function readResult(stream) { + const reader = stream.getReader(); + let result = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) { + return result; + } + result += Buffer.from(value).toString('utf8'); + } + } + + // @gate enableUseHook + it('should allow an alternative module mapping to be used for SSR', async () => { + function ClientComponent() { + return Client Component; + } + // The Client build may not have the same IDs as the Server bundles for the same + // component. + const ClientComponentOnTheClient = clientExports(ClientComponent); + const ClientComponentOnTheServer = clientExports(ClientComponent); + + // In the SSR bundle this module won't exist. We simulate this by deleting it. + const clientId = webpackMap[ClientComponentOnTheClient.filepath]['*'].id; + delete webpackModules[clientId]; + + // Instead, we have to provide a translation from the client meta data to the SSR + // meta data. + const ssrMetadata = webpackMap[ClientComponentOnTheServer.filepath]['*']; + const translationMap = { + [clientId]: { + '*': ssrMetadata, + }, + }; + + function App() { + return ; + } + + const stream = ReactServerDOMWriter.renderToReadableStream( + , + webpackMap, + ); + const response = ReactServerDOMReader.createFromReadableStream(stream, { + moduleMap: translationMap, + }); + + function ClientRoot() { + return use(response); + } + + const ssrStream = await ReactDOMServer.renderToReadableStream( + , + ); + const result = await readResult(ssrStream); + expect(result).toEqual('Client Component'); + }); +}); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js new file mode 100644 index 0000000000000..e5467a2f13cda --- /dev/null +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -0,0 +1,108 @@ +/** + * 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. + * + * @emails react-core + */ + +'use strict'; + +// Don't wait before processing work on the server. +// TODO: we can replace this with FlightServer.act(). +global.setImmediate = cb => cb(); + +let clientExports; +let webpackMap; +let webpackModules; +let React; +let ReactDOMServer; +let ReactServerDOMWriter; +let ReactServerDOMReader; +let Stream; +let use; + +describe('ReactFlightDOMNode', () => { + beforeEach(() => { + jest.resetModules(); + const WebpackMock = require('./utils/WebpackMock'); + clientExports = WebpackMock.clientExports; + webpackMap = WebpackMock.webpackMap; + webpackModules = WebpackMock.webpackModules; + React = require('react'); + ReactDOMServer = require('react-dom/server.node'); + ReactServerDOMWriter = require('react-server-dom-webpack/server.node'); + ReactServerDOMReader = require('react-server-dom-webpack/client.node'); + Stream = require('stream'); + use = React.use; + }); + + function readResult(stream) { + return new Promise((resolve, reject) => { + let buffer = ''; + const writable = new Stream.PassThrough(); + writable.setEncoding('utf8'); + writable.on('data', chunk => { + buffer += chunk; + }); + writable.on('error', error => { + reject(error); + }); + writable.on('end', () => { + resolve(buffer); + }); + stream.pipe(writable); + }); + } + + // @gate enableUseHook + it('should allow an alternative module mapping to be used for SSR', async () => { + function ClientComponent() { + return Client Component; + } + // The Client build may not have the same IDs as the Server bundles for the same + // component. + const ClientComponentOnTheClient = clientExports(ClientComponent); + const ClientComponentOnTheServer = clientExports(ClientComponent); + + // In the SSR bundle this module won't exist. We simulate this by deleting it. + const clientId = webpackMap[ClientComponentOnTheClient.filepath]['*'].id; + delete webpackModules[clientId]; + + // Instead, we have to provide a translation from the client meta data to the SSR + // meta data. + const ssrMetadata = webpackMap[ClientComponentOnTheServer.filepath]['*']; + const translationMap = { + [clientId]: { + '*': ssrMetadata, + }, + }; + + function App() { + return ; + } + + const stream = ReactServerDOMWriter.renderToPipeableStream( + , + webpackMap, + ); + const readable = new Stream.PassThrough(); + const response = ReactServerDOMReader.createFromNodeStream( + readable, + translationMap, + ); + + stream.pipe(readable); + + function ClientRoot() { + return use(response); + } + + const ssrStream = await ReactDOMServer.renderToPipeableStream( + , + ); + const result = await readResult(ssrStream); + expect(result).toEqual('Client Component'); + }); +}); diff --git a/scripts/flow/environment.js b/scripts/flow/environment.js index ee0caa0e66608..6ebf107207bd5 100644 --- a/scripts/flow/environment.js +++ b/scripts/flow/environment.js @@ -141,6 +141,23 @@ declare module 'util' { declare function deprecate(f: Function, string: string): Function; declare function promisify(f: Function): Function; declare function callbackify(f: Function): Function; + declare class TextDecoder { + constructor( + encoding?: string, + options?: { + fatal?: boolean, + ignoreBOM?: boolean, + ... + }, + ): void; + decode( + input?: ArrayBuffer | DataView | $TypedArray, + options?: {stream?: boolean, ...}, + ): string; + encoding: string; + fatal: boolean; + ignoreBOM: boolean; + } declare class TextEncoder { constructor(encoding?: string): TextEncoder; encode(buffer: string): Uint8Array; diff --git a/scripts/rollup/build.js b/scripts/rollup/build.js index 4c8ed5d9e4dff..bb33ea02c4e36 100644 --- a/scripts/rollup/build.js +++ b/scripts/rollup/build.js @@ -19,6 +19,7 @@ const Sync = require('./sync'); const sizes = require('./plugins/sizes-plugin'); const useForks = require('./plugins/use-forks-plugin'); const stripUnusedImports = require('./plugins/strip-unused-imports'); +const dynamicImports = require('./plugins/dynamic-imports'); const Packaging = require('./packaging'); const {asyncRimRaf} = require('./utils'); const codeFrame = require('@babel/code-frame'); @@ -45,7 +46,8 @@ process.on('unhandledRejection', err => { const { NODE_ES2015, - NODE_ESM, + ESM_DEV, + ESM_PROD, UMD_DEV, UMD_PROD, UMD_PROFILING, @@ -216,7 +218,8 @@ function getFormat(bundleType) { case RN_FB_PROD: case RN_FB_PROFILING: return `cjs`; - case NODE_ESM: + case ESM_DEV: + case ESM_PROD: return `es`; case BROWSER_SCRIPT: return `iife`; @@ -226,8 +229,8 @@ function getFormat(bundleType) { function isProductionBundleType(bundleType) { switch (bundleType) { case NODE_ES2015: - case NODE_ESM: return true; + case ESM_DEV: case UMD_DEV: case NODE_DEV: case BUN_DEV: @@ -235,6 +238,7 @@ function isProductionBundleType(bundleType) { case RN_OSS_DEV: case RN_FB_DEV: return false; + case ESM_PROD: case UMD_PROD: case NODE_PROD: case BUN_PROD: @@ -256,7 +260,6 @@ function isProductionBundleType(bundleType) { function isProfilingBundleType(bundleType) { switch (bundleType) { case NODE_ES2015: - case NODE_ESM: case FB_WWW_DEV: case FB_WWW_PROD: case NODE_DEV: @@ -267,6 +270,8 @@ function isProfilingBundleType(bundleType) { case RN_FB_PROD: case RN_OSS_DEV: case RN_OSS_PROD: + case ESM_DEV: + case ESM_PROD: case UMD_DEV: case UMD_PROD: case BROWSER_SCRIPT: @@ -328,6 +333,8 @@ function getPlugins( bundleType === RN_FB_PROFILING; const shouldStayReadable = isFBWWWBundle || isRNBundle || forcePrettyOutput; return [ + // Keep dynamic imports as externals + dynamicImports(), { name: 'rollup-plugin-flow-remove-types', transform(code) { @@ -385,7 +392,7 @@ function getPlugins( // Apply dead code elimination and/or minification. // closure doesn't yet support leaving ESM imports intact isProduction && - bundleType !== NODE_ESM && + bundleType !== ESM_PROD && closure({ compilation_level: 'SIMPLE', language_in: 'ECMASCRIPT_2020', @@ -396,7 +403,9 @@ function getPlugins( ? 'ECMASCRIPT5' : 'ECMASCRIPT5_STRICT', emit_use_strict: - bundleType !== BROWSER_SCRIPT && bundleType !== NODE_ESM, + bundleType !== BROWSER_SCRIPT && + bundleType !== ESM_PROD && + bundleType !== ESM_DEV, env: 'CUSTOM', warning_level: 'QUIET', apply_input_source_maps: false, @@ -404,6 +413,7 @@ function getPlugins( process_common_js_modules: false, rewrite_polyfills: false, inject_libraries: false, + allow_dynamic_import: true, // Don't let it create global variables in the browser. // https://github.com/facebook/react/issues/10909 @@ -740,7 +750,8 @@ async function buildEverything() { for (const bundle of Bundles.bundles) { bundles.push( [bundle, NODE_ES2015], - [bundle, NODE_ESM], + [bundle, ESM_DEV], + [bundle, ESM_PROD], [bundle, UMD_DEV], [bundle, UMD_PROD], [bundle, UMD_PROFILING], diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 5a0f140a84a00..e4ff1cf8bf0f7 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -9,7 +9,8 @@ const __EXPERIMENTAL__ = const bundleTypes = { NODE_ES2015: 'NODE_ES2015', - NODE_ESM: 'NODE_ESM', + ESM_DEV: 'ESM_DEV', + ESM_PROD: 'ESM_PROD', UMD_DEV: 'UMD_DEV', UMD_PROD: 'UMD_PROD', UMD_PROFILING: 'UMD_PROFILING', @@ -32,7 +33,8 @@ const bundleTypes = { const { NODE_ES2015, - NODE_ESM, + ESM_DEV, + ESM_PROD, UMD_DEV, UMD_PROD, UMD_PROFILING, @@ -393,7 +395,7 @@ const bundles = [ global: 'ReactServerDOMClient', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, - externals: ['react'], + externals: ['react', 'util'], }, { bundleTypes: [NODE_DEV, NODE_PROD], @@ -402,7 +404,7 @@ const bundles = [ global: 'ReactServerDOMClient', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, - externals: ['react'], + externals: ['react', 'util'], }, { bundleTypes: [NODE_DEV, NODE_PROD], @@ -427,7 +429,7 @@ const bundles = [ /******* React Server DOM Webpack Node.js Loader *******/ { - bundleTypes: [NODE_ESM], + bundleTypes: [ESM_PROD], moduleType: RENDERER_UTILS, entry: 'react-server-dom-webpack/node-loader', global: 'ReactServerWebpackNodeLoader', @@ -1025,12 +1027,14 @@ function getFilename(bundle, bundleType) { switch (bundleType) { case NODE_ES2015: return `${name}.js`; - case NODE_ESM: - return `${name}.js`; case BUN_DEV: return `${name}.development.js`; case BUN_PROD: return `${name}.production.min.js`; + case ESM_DEV: + return `${name}.development.js`; + case ESM_PROD: + return `${name}.production.min.js`; case UMD_DEV: return `${name}.development.js`; case UMD_PROD: diff --git a/scripts/rollup/packaging.js b/scripts/rollup/packaging.js index 63affa7ebf7ee..d9e7b42608fb4 100644 --- a/scripts/rollup/packaging.js +++ b/scripts/rollup/packaging.js @@ -17,7 +17,8 @@ const { const { NODE_ES2015, - NODE_ESM, + ESM_DEV, + ESM_PROD, UMD_DEV, UMD_PROD, UMD_PROFILING, @@ -49,7 +50,8 @@ function getBundleOutputPath(bundle, bundleType, filename, packageName) { switch (bundleType) { case NODE_ES2015: return `build/node_modules/${packageName}/cjs/${filename}`; - case NODE_ESM: + case ESM_DEV: + case ESM_PROD: return `build/node_modules/${packageName}/esm/${filename}`; case BUN_DEV: case BUN_PROD: diff --git a/scripts/rollup/plugins/dynamic-imports.js b/scripts/rollup/plugins/dynamic-imports.js new file mode 100644 index 0000000000000..10bda3f05a4f1 --- /dev/null +++ b/scripts/rollup/plugins/dynamic-imports.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. + */ +'use strict'; + +module.exports = function dynamicImports() { + return { + name: 'scripts/rollup/plugins/dynamic-imports', + renderDynamicImport({targetModuleId}) { + if (targetModuleId === null) { + return {left: 'import(', right: ')'}; + } + return null; + }, + }; +}; diff --git a/scripts/rollup/validate/eslintrc.cjs.js b/scripts/rollup/validate/eslintrc.cjs.js index 67d3c06dfc84e..64d3ddc8aeef7 100644 --- a/scripts/rollup/validate/eslintrc.cjs.js +++ b/scripts/rollup/validate/eslintrc.cjs.js @@ -53,7 +53,7 @@ module.exports = { IS_REACT_ACT_ENVIRONMENT: 'readonly', }, parserOptions: { - ecmaVersion: 5, + ecmaVersion: 2020, sourceType: 'script', }, rules: { diff --git a/scripts/rollup/validate/eslintrc.esm.js b/scripts/rollup/validate/eslintrc.esm.js index 9197517204608..e3ab6ec62fd3d 100644 --- a/scripts/rollup/validate/eslintrc.esm.js +++ b/scripts/rollup/validate/eslintrc.esm.js @@ -52,7 +52,7 @@ module.exports = { IS_REACT_ACT_ENVIRONMENT: 'readonly', }, parserOptions: { - ecmaVersion: 2017, + ecmaVersion: 2020, sourceType: 'module', }, rules: { diff --git a/scripts/rollup/wrappers.js b/scripts/rollup/wrappers.js index 2475fd1e62523..3c64e462ae33c 100644 --- a/scripts/rollup/wrappers.js +++ b/scripts/rollup/wrappers.js @@ -6,7 +6,8 @@ const {bundleTypes, moduleTypes} = require('./bundles'); const { NODE_ES2015, - NODE_ESM, + ESM_DEV, + ESM_PROD, UMD_DEV, UMD_PROD, UMD_PROFILING, @@ -66,8 +67,20 @@ ${license} ${source}`; }, - /***************** NODE_ESM *****************/ - [NODE_ESM](source, globalName, filename, moduleType) { + /***************** ESM_DEV *****************/ + [ESM_DEV](source, globalName, filename, moduleType) { + return `/** +* @license React + * ${filename} + * +${license} + */ + +${source}`; + }, + + /***************** ESM_PROD *****************/ + [ESM_PROD](source, globalName, filename, moduleType) { return `/** * @license React * ${filename} diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index 4f79d5e6f8f9c..cf65774ca8048 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -83,6 +83,7 @@ module.exports = [ 'react-server-dom-webpack/client', 'react-server-dom-webpack/client.browser', 'react-server-dom-webpack/server.browser', + 'react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js', // react-server-dom-webpack/client.browser 'react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js', // react-server-dom-webpack/server.browser 'react-client/src/ReactFlightClientStream.js', // We can only type check this in streaming configurations. 'react-devtools', @@ -114,6 +115,7 @@ module.exports = [ 'react-server-dom-webpack', 'react-server-dom-webpack/client.edge', 'react-server-dom-webpack/server.edge', + 'react-server-dom-webpack/src/ReactFlightDOMClientEdge.js', // react-server-dom-webpack/client.edge 'react-server-dom-webpack/src/ReactFlightDOMServerEdge.js', // react-server-dom-webpack/server.edge 'react-client/src/ReactFlightClientStream.js', // We can only type check this in streaming configurations. 'react-devtools',