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',