diff --git a/.eslintrc.js b/.eslintrc.js index 941c2e3b23ca8..cf77d322af702 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -426,7 +426,7 @@ module.exports = { files: ['packages/react-server-dom-webpack/**/*.js'], globals: { __webpack_chunk_load__: 'readonly', - __webpack_require__: 'readonly', + __webpack_require__: true, }, }, { diff --git a/fixtures/flight/.nvmrc b/fixtures/flight/.nvmrc new file mode 100644 index 0000000000000..0828ab79473bf --- /dev/null +++ b/fixtures/flight/.nvmrc @@ -0,0 +1 @@ +v18 \ No newline at end of file diff --git a/fixtures/flight/config/webpack.config.js b/fixtures/flight/config/webpack.config.js index de6eb9916bbf0..b634cf2c4e52d 100644 --- a/fixtures/flight/config/webpack.config.js +++ b/fixtures/flight/config/webpack.config.js @@ -248,6 +248,12 @@ module.exports = function (webpackEnv) { tsconfig: [paths.appTsConfig, paths.appJsConfig].filter(f => fs.existsSync(f) ), + react: [ + 'react/', + 'react-dom/', + 'react-server-dom-webpack/', + 'scheduler/', + ], }, }, infrastructureLogging: { diff --git a/fixtures/flight/server/global.js b/fixtures/flight/server/global.js index 16184287b1228..67ee53888a242 100644 --- a/fixtures/flight/server/global.js +++ b/fixtures/flight/server/global.js @@ -33,6 +33,7 @@ const compress = require('compression'); const chalk = require('chalk'); const express = require('express'); const http = require('http'); +const React = require('react'); const {renderToPipeableStream} = require('react-dom/server'); const {createFromNodeStream} = require('react-server-dom-webpack/client'); @@ -62,6 +63,11 @@ if (process.env.NODE_ENV === 'development') { webpackMiddleware(compiler, { publicPath: paths.publicUrlOrPath.slice(0, -1), serverSideRender: true, + headers: () => { + return { + 'Cache-Control': 'no-store, must-revalidate', + }; + }, }) ); app.use(webpackHotMiddleware(compiler)); @@ -121,9 +127,9 @@ app.all('/', async function (req, res, next) { buildPath = path.join(__dirname, '../build/'); } // Read the module map from the virtual file system. - const moduleMap = JSON.parse( + const ssrBundleConfig = JSON.parse( await virtualFs.readFile( - path.join(buildPath, 'react-ssr-manifest.json'), + path.join(buildPath, 'react-ssr-bundle-config.json'), 'utf8' ) ); @@ -138,10 +144,21 @@ app.all('/', async function (req, res, next) { // For HTML, we're a "client" emulator that runs the client code, // so we start by consuming the RSC payload. This needs a module // map that reverse engineers the client-side path to the SSR path. - const root = await createFromNodeStream(rscResponse, moduleMap); + let root; + let Root = () => { + if (root) { + return root; + } + root = createFromNodeStream( + rscResponse, + ssrBundleConfig.chunkLoading, + ssrBundleConfig.ssrManifest + ); + return root; + }; // Render it into HTML by resolving the client components res.set('Content-type', 'text/html'); - const {pipe} = renderToPipeableStream(root, { + const {pipe} = renderToPipeableStream(React.createElement(Root), { bootstrapScripts: mainJSChunks, }); pipe(res); @@ -173,7 +190,6 @@ app.all('/', async function (req, res, next) { if (process.env.NODE_ENV === 'development') { app.use(express.static('public')); } else { - // In production we host the static build output. app.use(express.static('build')); } diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 522a11e6d9da8..113a60ae28887 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -13,6 +13,7 @@ import type {LazyComponent} from 'react/src/ReactLazy'; import type { ClientReference, ClientReferenceMetadata, + ChunkLoading, SSRManifest, StringDecoder, } from './ReactFlightClientConfig'; @@ -32,6 +33,7 @@ import { readFinalStringChunk, createStringDecoder, usedWithSSR, + prepareDestinationForModule, } from './ReactFlightClientConfig'; import { @@ -174,6 +176,7 @@ Chunk.prototype.then = function ( export type Response = { _bundlerConfig: SSRManifest, + _chunkLoading: ChunkLoading, _callServer: CallServerCallback, _chunks: Map>, _fromJSON: (key: string, value: JSONValue) => any, @@ -707,11 +710,13 @@ function missingCall() { export function createResponse( bundlerConfig: SSRManifest, + chunkLoading: ChunkLoading, callServer: void | CallServerCallback, ): Response { const chunks: Map> = new Map(); const response: Response = { _bundlerConfig: bundlerConfig, + _chunkLoading: chunkLoading, _callServer: callServer !== undefined ? callServer : missingCall, _chunks: chunks, _stringDecoder: createStringDecoder(), @@ -769,6 +774,9 @@ function resolveModule( response, model, ); + + prepareDestinationForModule(response._chunkLoading, clientReferenceMetadata); + const clientReference = resolveClientReference<$FlowFixMe>( response._bundlerConfig, clientReferenceMetadata, diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.custom.js b/packages/react-client/src/forks/ReactFlightClientConfig.custom.js index b5de594d4d46f..3f17840157d0e 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.custom.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.custom.js @@ -25,6 +25,7 @@ declare var $$$config: any; +export opaque type ChunkLoading = mixed; export opaque type SSRManifest = mixed; export opaque type ServerManifest = mixed; export opaque type ServerReferenceId = string; @@ -36,6 +37,8 @@ export const preloadModule = $$$config.preloadModule; export const requireModule = $$$config.requireModule; export const dispatchHint = $$$config.dispatchHint; export const usedWithSSR = true; +export const prepareDestinationForModule = + $$$config.prepareDestinationForModule; export opaque type Source = mixed; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js index 52212d1e0c869..da8a378224d96 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js @@ -8,6 +8,8 @@ */ export * from 'react-client/src/ReactFlightClientConfigBrowser'; -export * from 'react-server-dom-webpack/src/ReactFlightClientConfigWebpackBundler'; +export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack'; +export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackBrowser'; +export * from 'react-server-dom-webpack/src/ReactFlightClientConfigWebpackDestinationClient'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; export const usedWithSSR = false; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js index 0ad00d57cdac4..d2c14f2021ad4 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js @@ -11,6 +11,7 @@ export * from 'react-client/src/ReactFlightClientConfigBrowser'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; export type Response = any; +export opaque type ChunkLoading = mixed; export opaque type SSRManifest = mixed; export opaque type ServerManifest = mixed; export opaque type ServerReferenceId = string; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js index 212290670bd57..3970ffca42319 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js @@ -8,6 +8,8 @@ */ export * from 'react-client/src/ReactFlightClientConfigBrowser'; -export * from 'react-server-dom-webpack/src/ReactFlightClientConfigWebpackBundler'; +export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack'; +export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackEdge'; +export * from 'react-server-dom-webpack/src/ReactFlightClientConfigWebpackDestinationServer'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; export const usedWithSSR = true; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js index 212290670bd57..043bb9eaf2d39 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js @@ -8,6 +8,8 @@ */ export * from 'react-client/src/ReactFlightClientConfigBrowser'; -export * from 'react-server-dom-webpack/src/ReactFlightClientConfigWebpackBundler'; +export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack'; +export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackBrowser'; +export * from 'react-server-dom-webpack/src/ReactFlightClientConfigWebpackDestinationClient'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; export const usedWithSSR = true; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js index 4df4617caec67..ac0741c3630c8 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js @@ -8,6 +8,8 @@ */ export * from 'react-client/src/ReactFlightClientConfigNode'; -export * from 'react-server-dom-webpack/src/ReactFlightClientConfigWebpackBundler'; +export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack'; +export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackNode'; +export * from 'react-server-dom-webpack/src/ReactFlightClientConfigWebpackDestinationServer'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; export const usedWithSSR = true; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js index bf0ddb29fa434..e717a8d96f30f 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js @@ -8,6 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientConfigNode'; -export * from 'react-server-dom-webpack/src/ReactFlightClientConfigNodeBundler'; +export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerNode'; +export * from 'react-server-dom-webpack/src/ReactFlightClientConfigWebpackDestinationServer'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; export const usedWithSSR = true; diff --git a/packages/react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js b/packages/react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js index 3f1e0a1b66f67..7a7e2c3c3d86e 100644 --- a/packages/react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js +++ b/packages/react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js @@ -51,3 +51,14 @@ export function dispatchHint(code: string, model: HintModel): void { } } } + +export function preinitModulesForSSR(href: string, crossOrigin: ?string) { + const dispatcher = ReactDOMCurrentDispatcher.current; + if (dispatcher) { + if (typeof crossOrigin === 'string') { + dispatcher.preinit(href, {as: 'script', crossOrigin}); + } else { + dispatcher.preinit(href, {as: 'script'}); + } + } +} diff --git a/packages/react-dom/src/shared/ReactDOMTypes.js b/packages/react-dom/src/shared/ReactDOMTypes.js index 1ff0360617b87..7bfcfd3740ea4 100644 --- a/packages/react-dom/src/shared/ReactDOMTypes.js +++ b/packages/react-dom/src/shared/ReactDOMTypes.js @@ -31,7 +31,7 @@ export type PreinitOptions = { export type HostDispatcher = { prefetchDNS: (href: string, options?: ?PrefetchDNSOptions) => void, - preconnect: (href: string, options: ?PreconnectOptions) => void, + preconnect: (href: string, options?: ?PreconnectOptions) => void, preload: (href: string, options: PreloadOptions) => void, preinit: (href: string, options: PreinitOptions) => void, }; diff --git a/packages/react-noop-renderer/src/ReactNoopFlightClient.js b/packages/react-noop-renderer/src/ReactNoopFlightClient.js index 013c663cb0c4e..00fed14142eeb 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightClient.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightClient.js @@ -35,6 +35,7 @@ const {createResponse, processBinaryChunk, getRoot, close} = ReactFlightClient({ resolveClientReference(bundlerConfig: null, idx: string) { return idx; }, + prepareDestinationForModule(chunkLoading: mixed, metadata: mixed) {}, preloadModule(idx: string) {}, requireModule(idx: string) { return readModule(idx); diff --git a/packages/react-server-dom-webpack/src/ReactFlightClientConfigNodeBundler.js b/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerNode.js similarity index 74% rename from packages/react-server-dom-webpack/src/ReactFlightClientConfigNodeBundler.js rename to packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerNode.js index 0789a52ffc0e1..dc46e7bae90fd 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightClientConfigNodeBundler.js +++ b/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerNode.js @@ -13,6 +13,17 @@ import type { RejectedThenable, } from 'shared/ReactTypes'; +import type {ClientReferenceMetadata as SharedClientReferenceMetadata} from './shared/ReactFlightClientReference'; +import type {ChunkLoading} from 'react-client/src/ReactFlightClientConfig'; + +import { + ID, + CHUNKS, + NAME, + isAsyncClientReference, +} from './shared/ReactFlightClientReference'; +import {prepareDestinationWithChunks} from 'react-client/src/ReactFlightClientConfig'; + export type SSRManifest = { [clientId: string]: { [clientExportName: string]: ClientReference, @@ -23,12 +34,7 @@ export type ServerManifest = void; export type ServerReferenceId = string; -export opaque type ClientReferenceMetadata = { - id: string, - chunks: Array, - name: string, - async?: boolean, -}; +export opaque type ClientReferenceMetadata = SharedClientReferenceMetadata; // eslint-disable-next-line no-unused-vars export opaque type ClientReference = { @@ -37,12 +43,25 @@ export opaque type ClientReference = { async?: boolean, }; +// The reason this function needs to defined here in this file instead of just +// being exported directly from the WebpackDestination... file is because the +// ClientReferenceMetadata is opaque and we can't unwrap it there. +// This should get inlined and we could also just implement an unwrapping function +// though that risks it getting used in places it shouldn't be. This is unfortunate +// but currently it seems to be the best option we have. +export function prepareDestinationForModule( + chunkLoading: ChunkLoading, + metadata: ClientReferenceMetadata, +) { + prepareDestinationWithChunks(chunkLoading, metadata[CHUNKS]); +} + export function resolveClientReference( bundlerConfig: SSRManifest, metadata: ClientReferenceMetadata, ): ClientReference { - const moduleExports = bundlerConfig[metadata.id]; - let resolvedModuleData = moduleExports[metadata.name]; + const moduleExports = bundlerConfig[metadata[ID]]; + let resolvedModuleData = moduleExports[metadata[NAME]]; let name; if (resolvedModuleData) { // The potentially aliased name. @@ -53,17 +72,18 @@ export function resolveClientReference( if (!resolvedModuleData) { throw new Error( 'Could not find the module "' + - metadata.id + + metadata[ID] + '" in the React SSR Manifest. ' + 'This is probably a bug in the React Server Components bundler.', ); } - name = metadata.name; + name = metadata[NAME]; } + return { specifier: resolvedModuleData.specifier, name: name, - async: metadata.async, + async: isAsyncClientReference(metadata), }; } diff --git a/packages/react-server-dom-webpack/src/ReactFlightClientConfigWebpackBundler.js b/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack.js similarity index 68% rename from packages/react-server-dom-webpack/src/ReactFlightClientConfigWebpackBundler.js rename to packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack.js index ae94267a672c1..384a903ea67a7 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightClientConfigWebpackBundler.js +++ b/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack.js @@ -7,41 +7,68 @@ * @flow */ +import type { + ClientReferenceMetadata as SharedClientReferenceMetadata, + ClientReferenceManifestEntry as SharedClientReferenceManifestEntry, +} from './shared/ReactFlightClientReference'; + import type { Thenable, FulfilledThenable, RejectedThenable, } from 'shared/ReactTypes'; +import type {ChunkLoading} from 'react-client/src/ReactFlightClientConfig'; + +import { + loadChunk, + prepareDestinationWithChunks, +} from 'react-client/src/ReactFlightClientConfig'; + +import { + ID, + NAME, + CHUNKS, + isAsyncClientReference, +} from './shared/ReactFlightClientReference'; export type SSRManifest = null | { [clientId: string]: { - [clientExportName: string]: ClientReferenceMetadata, + [clientExportName: string]: ClientReferenceManifestEntry, }, }; export type ServerManifest = { - [id: string]: ClientReference, + [id: string]: ClientReferenceManifestEntry, }; export type ServerReferenceId = string; -export opaque type ClientReferenceMetadata = { - id: string, - chunks: Array, - name: string, - async: boolean, -}; +export opaque type ClientReferenceManifestEntry = SharedClientReferenceManifestEntry; +export opaque type ClientReferenceMetadata = SharedClientReferenceMetadata; // eslint-disable-next-line no-unused-vars export opaque type ClientReference = ClientReferenceMetadata; +// The reason this function needs to defined here in this file instead of just +// being exported directly from the WebpackDestination... file is because the +// ClientReferenceMetadata is opaque and we can't unwrap it there. +// This should get inlined and we could also just implement an unwrapping function +// though that risks it getting used in places it shouldn't be. This is unfortunate +// but currently it seems to be the best option we have. +export function prepareDestinationForModule( + chunkLoading: ChunkLoading, + metadata: ClientReferenceMetadata, +) { + prepareDestinationWithChunks(chunkLoading, metadata[CHUNKS]); +} + export function resolveClientReference( bundlerConfig: SSRManifest, metadata: ClientReferenceMetadata, ): ClientReference { if (bundlerConfig) { - const moduleExports = bundlerConfig[metadata.id]; - let resolvedModuleData = moduleExports[metadata.name]; + const moduleExports = bundlerConfig[metadata[ID]]; + let resolvedModuleData = moduleExports[metadata[NAME]]; let name; if (resolvedModuleData) { // The potentially aliased name. @@ -52,19 +79,19 @@ export function resolveClientReference( if (!resolvedModuleData) { throw new Error( 'Could not find the module "' + - metadata.id + + metadata[ID] + '" in the React SSR Manifest. ' + 'This is probably a bug in the React Server Components bundler.', ); } - name = metadata.name; + name = metadata[NAME]; + } + + if (isAsyncClientReference(metadata)) { + return [resolvedModuleData.id, resolvedModuleData.chunks, name, true]; + } else { + return [resolvedModuleData.id, resolvedModuleData.chunks, name]; } - return { - id: resolvedModuleData.id, - chunks: resolvedModuleData.chunks, - name: name, - async: !!metadata.async, - }; } return metadata; } @@ -98,12 +125,7 @@ export function resolveServerReference( } } // TODO: This needs to return async: true if it's an async module. - return { - id: resolvedModuleData.id, - chunks: resolvedModuleData.chunks, - name: name, - async: false, - }; + return [resolvedModuleData.id, resolvedModuleData.chunks, name]; } // The chunk cache contains all the chunks we've preloaded so far. @@ -147,13 +169,15 @@ function ignoreReject() { export function preloadModule( metadata: ClientReference, ): null | Thenable { - const chunks = metadata.chunks; + const chunks = metadata[CHUNKS]; const promises = []; - for (let i = 0; i < chunks.length; i++) { - const chunkId = chunks[i]; + let i = 0; + while (i < chunks.length) { + const chunkId = chunks[i++]; + const chunkFilename = chunks[i++]; const entry = chunkCache.get(chunkId); if (entry === undefined) { - const thenable = __webpack_chunk_load__(chunkId); + const thenable = loadChunk(chunkId, chunkFilename); promises.push(thenable); // $FlowFixMe[method-unbinding] const resolve = chunkCache.set.bind(chunkCache, chunkId, null); @@ -163,12 +187,12 @@ export function preloadModule( promises.push(entry); } } - if (metadata.async) { + if (isAsyncClientReference(metadata)) { if (promises.length === 0) { - return requireAsyncModule(metadata.id); + return requireAsyncModule(metadata[ID]); } else { return Promise.all(promises).then(() => { - return requireAsyncModule(metadata.id); + return requireAsyncModule(metadata[ID]); }); } } else if (promises.length > 0) { @@ -181,8 +205,8 @@ export function preloadModule( // Actually require the module or suspend if it's not yet ready. // Increase priority if necessary. export function requireModule(metadata: ClientReference): T { - let moduleExports = __webpack_require__(metadata.id); - if (metadata.async) { + let moduleExports = __webpack_require__(metadata[ID]); + if (isAsyncClientReference(metadata)) { if (typeof moduleExports.then !== 'function') { // This wasn't a promise after all. } else if (moduleExports.status === 'fulfilled') { @@ -192,15 +216,15 @@ export function requireModule(metadata: ClientReference): T { throw moduleExports.reason; } } - if (metadata.name === '*') { + 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 === '') { + 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.__esModule ? moduleExports.default : moduleExports; } - return moduleExports[metadata.name]; + return moduleExports[metadata[NAME]]; } diff --git a/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackBrowser.js new file mode 100644 index 0000000000000..48779fb1e65e9 --- /dev/null +++ b/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackBrowser.js @@ -0,0 +1,28 @@ +/** + * 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 + */ + +const chunkMap: Map = new Map(); + +/** + * We patch the chunk filename function in webpack to insert our own resolution + * of chunks that come from Flight and may not be known to the webpack runtime + */ +const webpackGetChunkFilename = __webpack_require__.u; +__webpack_require__.u = function (chunkId: string) { + const flightChunk = chunkMap.get(chunkId); + if (flightChunk !== undefined) { + return flightChunk; + } + return webpackGetChunkFilename(chunkId); +}; + +export function loadChunk(chunkId: string, filename: string): Promise { + chunkMap.set(chunkId, filename); + return __webpack_chunk_load__(chunkId); +} diff --git a/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackEdge.js b/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackEdge.js new file mode 100644 index 0000000000000..74b3b9a1f9a22 --- /dev/null +++ b/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackEdge.js @@ -0,0 +1,12 @@ +/** + * 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 function loadChunk(chunkId: string, filename: string): Promise { + return Promise.resolve(); +} diff --git a/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackNode.js b/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackNode.js new file mode 100644 index 0000000000000..74b3b9a1f9a22 --- /dev/null +++ b/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackNode.js @@ -0,0 +1,12 @@ +/** + * 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 function loadChunk(chunkId: string, filename: string): Promise { + return Promise.resolve(); +} diff --git a/packages/react-server-dom-webpack/src/ReactFlightClientConfigWebpackDestinationClient.js b/packages/react-server-dom-webpack/src/ReactFlightClientConfigWebpackDestinationClient.js new file mode 100644 index 0000000000000..ec9fff8ce0a19 --- /dev/null +++ b/packages/react-server-dom-webpack/src/ReactFlightClientConfigWebpackDestinationClient.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 type ChunkLoading = null; + +export function prepareDestinationWithChunks( + chunkLoading: ChunkLoading, + chunks: mixed, +): void { + // The client is ultimately the destination so there is nothing further to prepare. + // On the server this is where we would potentially emit a script tag to kick start + // chunk loading before hydration +} diff --git a/packages/react-server-dom-webpack/src/ReactFlightClientConfigWebpackDestinationServer.js b/packages/react-server-dom-webpack/src/ReactFlightClientConfigWebpackDestinationServer.js new file mode 100644 index 0000000000000..bea0f3a47c078 --- /dev/null +++ b/packages/react-server-dom-webpack/src/ReactFlightClientConfigWebpackDestinationServer.js @@ -0,0 +1,28 @@ +/** + * 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 {preinitModulesForSSR} from 'react-client/src/ReactFlightClientConfig'; + +export type ChunkLoading = { + prefix: string, + crossOrigin?: 'use-credentials' | '', +}; + +export function prepareDestinationWithChunks( + chunkLoading: ChunkLoading, + // Chunks are double-indexed [..., idx, filenamex, idy, filenamey, ...] + chunks: Array, +) { + for (let i = 1; i < chunks.length; i += 2) { + preinitModulesForSSR( + chunkLoading.prefix + chunks[i], + chunkLoading.crossOrigin, + ); + } +} diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js index d91e7d7a755cb..d3c75495ec5af 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js @@ -34,6 +34,7 @@ export type Options = { function createResponseFromOptions(options: void | Options) { return createResponse( + null, null, options && options.callServer ? options.callServer : undefined, ); diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js index d9ce8f35a5262..5b0322834eaeb 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js @@ -11,7 +11,10 @@ import type {Thenable} from 'shared/ReactTypes.js'; import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient'; -import type {SSRManifest} from './ReactFlightClientConfigWebpackBundler'; +import type { + SSRManifest, + ChunkLoading, +} from 'react-client/src/ReactFlightClientConfig'; import { createResponse, @@ -42,9 +45,13 @@ export type Options = { moduleMap?: $NonMaybeType, }; -function createResponseFromOptions(options: void | Options) { +function createResponseFromOptions( + chunkLoading: ChunkLoading, + options: void | Options, +) { return createResponse( options && options.moduleMap ? options.moduleMap : null, + chunkLoading, noServerCall, ); } @@ -78,18 +85,26 @@ function startReadingFromStream( function createFromReadableStream( stream: ReadableStream, + chunkLoading: ChunkLoading, options?: Options, ): Thenable { - const response: FlightResponse = createResponseFromOptions(options); + const response: FlightResponse = createResponseFromOptions( + chunkLoading, + options, + ); startReadingFromStream(response, stream); return getRoot(response); } function createFromFetch( promiseForResponse: Promise, + chunkLoading: ChunkLoading, options?: Options, ): Thenable { - const response: FlightResponse = createResponseFromOptions(options); + const response: FlightResponse = createResponseFromOptions( + chunkLoading, + options, + ); promiseForResponse.then( function (r) { startReadingFromStream(response, (r.body: any)); diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js index c6a14fb6b20e7..e4a370548de4a 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js @@ -11,7 +11,10 @@ import type {Thenable} from 'shared/ReactTypes.js'; import type {Response} from 'react-client/src/ReactFlightClient'; -import type {SSRManifest} from 'react-client/src/ReactFlightClientConfig'; +import type { + SSRManifest, + ChunkLoading, +} from 'react-client/src/ReactFlightClientConfig'; import type {Readable} from 'stream'; @@ -42,9 +45,14 @@ export function createServerReference, T>( function createFromNodeStream( stream: Readable, + chunkLoading: ChunkLoading, moduleMap: $NonMaybeType, ): Thenable { - const response: Response = createResponse(moduleMap, noServerCall); + const response: Response = createResponse( + moduleMap, + chunkLoading, + noServerCall, + ); stream.on('data', chunk => { processBinaryChunk(response, chunk); }); diff --git a/packages/react-server-dom-webpack/src/ReactFlightServerConfigWebpackBundler.js b/packages/react-server-dom-webpack/src/ReactFlightServerConfigWebpackBundler.js index b217ac1ef21fe..0f6ec5be64c90 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightServerConfigWebpackBundler.js +++ b/packages/react-server-dom-webpack/src/ReactFlightServerConfigWebpackBundler.js @@ -7,6 +7,10 @@ * @flow */ +import type { + ClientReferenceMetadata as SharedClientReferenceMetadata, + ClientReferenceManifestEntry as SharedClientReferenceManifestEntry, +} from './shared/ReactFlightClientReference'; import type {ReactClientValue} from 'react-server/src/ReactFlightServer'; import type { @@ -17,17 +21,13 @@ import type { export type {ClientReference, ServerReference}; export type ClientManifest = { - [id: string]: ClientReferenceMetadata, + [id: string]: ClientReferenceManifestEntry, }; export type ServerReferenceId = string; -export type ClientReferenceMetadata = { - id: string, - chunks: Array, - name: string, - async: boolean, -}; +export type ClientReferenceMetadata = SharedClientReferenceMetadata; +export opaque type ClientReferenceManifestEntry = SharedClientReferenceManifestEntry; export type ClientReferenceKey = string; @@ -71,12 +71,11 @@ export function resolveClientReferenceMetadata( ); } } - return { - id: resolvedModuleData.id, - chunks: resolvedModuleData.chunks, - name: name, - async: !!clientReference.$$async, - }; + if (clientReference.$$async === true) { + return [resolvedModuleData.id, resolvedModuleData.chunks, name, true]; + } else { + return [resolvedModuleData.id, resolvedModuleData.chunks, name]; + } } export function getServerReferenceId( diff --git a/packages/react-server-dom-webpack/src/ReactFlightWebpackPlugin.js b/packages/react-server-dom-webpack/src/ReactFlightWebpackPlugin.js index 096f5ce0d1dc4..d594e3c4defb6 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightWebpackPlugin.js +++ b/packages/react-server-dom-webpack/src/ReactFlightWebpackPlugin.js @@ -7,6 +7,8 @@ * @flow */ +import type {ClientReferenceManifestEntry} from './shared/ReactFlightClientReference'; + import {join} from 'path'; import {pathToFileURL} from 'url'; import asyncLib from 'neo-async'; @@ -56,7 +58,7 @@ type Options = { clientReferences?: ClientReferencePath | $ReadOnlyArray, chunkName?: string, clientManifestFilename?: string, - ssrManifestFilename?: string, + ssrBundleConfigFilename?: string, }; const PLUGIN_NAME = 'React Server Plugin'; @@ -65,7 +67,7 @@ export default class ReactFlightWebpackPlugin { clientReferences: $ReadOnlyArray; chunkName: string; clientManifestFilename: string; - ssrManifestFilename: string; + ssrBundleConfigFilename: string; constructor(options: Options) { if (!options || typeof options.isServer !== 'boolean') { @@ -103,8 +105,8 @@ export default class ReactFlightWebpackPlugin { } this.clientManifestFilename = options.clientManifestFilename || 'react-client-manifest.json'; - this.ssrManifestFilename = - options.ssrManifestFilename || 'react-ssr-manifest.json'; + this.ssrBundleConfigFilename = + options.ssrBundleConfigFilename || 'react-ssr-bundle-config.json'; } apply(compiler: any) { @@ -221,21 +223,68 @@ export default class ReactFlightWebpackPlugin { return; } + const configuredCrossOriginLoading = + compilation.outputOptions.crossOriginLoading; + const crossOriginMode = + typeof configuredCrossOriginLoading === 'string' + ? configuredCrossOriginLoading === 'use-credentials' + ? configuredCrossOriginLoading + : 'anonymous' + : null; + const resolvedClientFiles = new Set( (resolvedClientReferences || []).map(ref => ref.request), ); const clientManifest: { - [string]: {chunks: $FlowFixMe, id: string, name: string}, + [string]: ClientReferenceManifestEntry, } = {}; - const ssrManifest: { - [string]: { - [string]: {specifier: string, name: string}, + const ssrConfig: { + chunkLoading: { + prefix: string, + crossOrigin: string | null, }, - } = {}; + ssrManifest: { + [string]: { + [string]: { + specifier: string, + name: string, + }, + }, + }, + } = { + chunkLoading: { + prefix: compilation.outputOptions.publicPath || '', + crossOrigin: crossOriginMode, + }, + ssrManifest: {}, + }; + + const ssrManifest = ssrConfig.ssrManifest; + + // We figure out which files are always loaded by any initial chunk (entrypoint). + // We use this to filter out chunks that Flight will never need to load + const emptySet: Set = new Set(); + const runtimeChunkFiles: Set = emptySet; + compilation.entrypoints.forEach(entrypoint => { + const runtimeChunk = entrypoint.getRuntimeChunk(); + if (runtimeChunk) { + runtimeChunk.files.forEach(runtimeFile => { + runtimeChunkFiles.add(runtimeFile); + }); + } + }); + compilation.chunkGroups.forEach(function (chunkGroup) { - const chunkIds = chunkGroup.chunks.map(function (c) { - return c.id; + const chunks: Array = []; + chunkGroup.chunks.forEach(c => { + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const file of c.files) { + if (!file.endsWith('.js')) return; + if (file.endsWith('.hot-update.js')) return; + chunks.push(c.id, file); + break; + } }); // $FlowFixMe[missing-local-annot] @@ -251,12 +300,15 @@ export default class ReactFlightWebpackPlugin { if (href !== undefined) { const ssrExports: { - [string]: {specifier: string, name: string}, + [string]: { + specifier: string, + name: string, + }, } = {}; clientManifest[href] = { id, - chunks: chunkIds, + chunks, name: '*', }; ssrExports['*'] = { @@ -272,7 +324,7 @@ export default class ReactFlightWebpackPlugin { /* clientManifest[href + '#'] = { id, - chunks: chunkIds, + chunks, name: '', }; ssrExports[''] = { @@ -286,11 +338,7 @@ export default class ReactFlightWebpackPlugin { if (Array.isArray(moduleProvidedExports)) { moduleProvidedExports.forEach(function (name) { - clientManifest[href + '#' + name] = { - id, - chunks: chunkIds, - name: name, - }; + clientManifest[href + '#' + name] = { id, chunks, name }; ssrExports[name] = { specifier: href, name: name, @@ -326,9 +374,9 @@ export default class ReactFlightWebpackPlugin { _this.clientManifestFilename, new sources.RawSource(clientOutput, false), ); - const ssrOutput = JSON.stringify(ssrManifest, null, 2); + const ssrOutput = JSON.stringify(ssrConfig, null, 2); compilation.emitAsset( - _this.ssrManifestFilename, + _this.ssrBundleConfigFilename, new sources.RawSource(ssrOutput, false), ); }, diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index 4e0f5780b5b35..998863ddcc56f 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -22,6 +22,7 @@ global.setTimeout = cb => cb(); let clientExports; let webpackMap; let webpackModules; +let webpackChunkLoading; let React; let ReactDOMServer; let ReactServerDOMServer; @@ -41,6 +42,9 @@ describe('ReactFlightDOMEdge', () => { clientExports = WebpackMock.clientExports; webpackMap = WebpackMock.webpackMap; webpackModules = WebpackMock.webpackModules; + webpackChunkLoading = { + perfix: '...prefix/', + }; React = require('react'); ReactDOMServer = require('react-dom/server.edge'); ReactServerDOMServer = require('react-server-dom-webpack/server.edge'); @@ -107,7 +111,7 @@ describe('ReactFlightDOMEdge', () => { // Instead, we have to provide a translation from the client meta data to the SSR // meta data. const ssrMetadata = webpackMap[ClientComponentOnTheServer.$$id]; - const translationMap = { + const moduleMap = { [clientId]: { '*': ssrMetadata, }, @@ -121,9 +125,13 @@ describe('ReactFlightDOMEdge', () => { , webpackMap, ); - const response = ReactServerDOMClient.createFromReadableStream(stream, { - moduleMap: translationMap, - }); + const response = ReactServerDOMClient.createFromReadableStream( + stream, + webpackChunkLoading, + { + moduleMap, + }, + ); function ClientRoot() { return use(response); @@ -154,7 +162,10 @@ describe('ReactFlightDOMEdge', () => { expect(serializedContent).not.toContain('\\"'); expect(serializedContent).toContain('\t'); - const result = await ReactServerDOMClient.createFromReadableStream(stream2); + const result = await ReactServerDOMClient.createFromReadableStream( + stream2, + webpackChunkLoading, + ); // Should still match the result when parsed expect(result.text).toBe(testString); expect(result.text2).toBe(testString2); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index 5fb6b75071c47..8dec90f2f4fb1 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -16,6 +16,7 @@ global.setImmediate = cb => cb(); let clientExports; let webpackMap; let webpackModules; +let webpackChunkLoading; let React; let ReactDOMServer; let ReactServerDOMServer; @@ -36,6 +37,9 @@ describe('ReactFlightDOMNode', () => { clientExports = WebpackMock.clientExports; webpackMap = WebpackMock.webpackMap; webpackModules = WebpackMock.webpackModules; + webpackChunkLoading = { + prefix: '...prefix/', + }; React = require('react'); ReactDOMServer = require('react-dom/server.node'); ReactServerDOMServer = require('react-server-dom-webpack/server.node'); @@ -78,7 +82,7 @@ describe('ReactFlightDOMNode', () => { // Instead, we have to provide a translation from the client meta data to the SSR // meta data. const ssrMetadata = webpackMap[ClientComponentOnTheServer.$$id]; - const translationMap = { + const moduleMap = { [clientId]: { '*': ssrMetadata, }, @@ -95,7 +99,8 @@ describe('ReactFlightDOMNode', () => { const readable = new Stream.PassThrough(); const response = ReactServerDOMClient.createFromNodeStream( readable, - translationMap, + webpackChunkLoading, + moduleMap, ); stream.pipe(readable); @@ -121,7 +126,10 @@ describe('ReactFlightDOMNode', () => { const readable = new Stream.PassThrough(); const stringResult = readResult(readable); - const parsedResult = ReactServerDOMClient.createFromNodeStream(readable); + const parsedResult = ReactServerDOMClient.createFromNodeStream( + readable, + webpackChunkLoading, + ); stream.pipe(readable); diff --git a/packages/react-server-dom-webpack/src/shared/ReactFlightClientReference.js b/packages/react-server-dom-webpack/src/shared/ReactFlightClientReference.js new file mode 100644 index 0000000000000..dd2d63dd34113 --- /dev/null +++ b/packages/react-server-dom-webpack/src/shared/ReactFlightClientReference.js @@ -0,0 +1,45 @@ +/** + * 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 type ClientReferenceManifestEntry = { + id: string, + // chunks is a double indexed array of chunkId / chunkFilename pairs + chunks: Array, + name: string, +}; + +// This is the parsed shape of the wire format which is why it is +// condensed to only the essentialy information +export type ClientReferenceMetadata = + | [ + /* id */ string, + /* chunks id/filename pairs, double indexed */ Array, + /* name */ string, + /* async */ true, + ] + | [ + /* id */ string, + /* chunks id/filename pairs, double indexed */ Array, + /* name */ string, + ]; + +export const ID = 0; +export const CHUNKS = 1; +export const NAME = 2; +// export const ASYNC = 3; + +// This logic is correct because currently only include the 4th tuple member +// when the module is async. If that changes we will need to actually assert +// the value is true. We don't index into the 4th slot because flow does not +// like the potential out of bounds access +export function isAsyncClientReference( + metadata: ClientReferenceMetadata, +): boolean { + return metadata.length === 4; +} diff --git a/packages/react/src/__tests__/ReactFetch-test.js b/packages/react/src/__tests__/ReactFetch-test.js index 5a8911888bdf8..4cf0021d1a4b1 100644 --- a/packages/react/src/__tests__/ReactFetch-test.js +++ b/packages/react/src/__tests__/ReactFetch-test.js @@ -37,6 +37,13 @@ let ReactServerDOMClient; let use; let cache; +async function act(callback) { + await callback(); + await new Promise(resolve => { + setImmediate(resolve); + }); +} + describe('ReactFetch', () => { beforeEach(() => { jest.resetModules(); @@ -48,7 +55,7 @@ describe('ReactFetch', () => { } React = require('react'); - ReactServerDOMServer = require('react-server-dom-webpack/server.browser'); + ReactServerDOMServer = require('react-server-dom-webpack/server.edge'); ReactServerDOMClient = require('react-server-dom-webpack/client'); use = React.use; cache = React.cache; @@ -70,13 +77,17 @@ describe('ReactFetch', () => { }); // @gate enableFetchInstrumentation && enableCache - it('can dedupe fetches inside of render', async () => { + fit('can dedupe fetches inside of render', async () => { function Component() { const response = use(fetch('world')); const text = use(response.text()); return text; } - expect(await render(Component)).toMatchInlineSnapshot(`"GET world []"`); + let result; + await act(() => { + result = render(Component); + }); + expect(result).toMatchInlineSnapshot(`"GET world []"`); expect(fetchCount).toBe(1); }); diff --git a/scripts/flow/environment.js b/scripts/flow/environment.js index 18ba25264138e..42812413ddbf4 100644 --- a/scripts/flow/environment.js +++ b/scripts/flow/environment.js @@ -74,7 +74,9 @@ declare module 'EventListener' { } declare function __webpack_chunk_load__(id: string): Promise; -declare function __webpack_require__(id: string): any; +declare var __webpack_require__: ((id: string) => any) & { + u: string => string, +}; declare module 'fs/promises' { declare var access: (path: string, mode?: number) => Promise; diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index 4b35976aeba29..bcadbe14ce1da 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -84,6 +84,7 @@ module.exports = [ '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-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack.js', 'react-devtools', 'react-devtools-core', 'react-devtools-shell', @@ -115,6 +116,7 @@ module.exports = [ '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-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack.js', 'react-devtools', 'react-devtools-core', 'react-devtools-shell', @@ -147,6 +149,7 @@ module.exports = [ 'react-server-dom-webpack/src/ReactFlightDOMServerNode.js', // react-server-dom-webpack/server.node 'react-server-dom-webpack/node-register', 'react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js', + 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack.js', 'react-devtools', 'react-devtools-core', 'react-devtools-shell', @@ -205,6 +208,7 @@ module.exports = [ 'react-dom/src/server/ReactDOMLegacyServerNode.js', // react-dom/server.node 'react-dom/src/server/ReactDOMLegacyServerNode.classic.fb.js', 'react-dom/src/server/ReactDOMLegacyServerNodeStream.js', // file indirection to support partial forking of some methods in *Node + 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack.js', 'shared/ReactDOMSharedInternals', ], isFlowTyped: true,