From 244d1aac3736ed90dde69244a596de4dacb56fc6 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 13 Jun 2023 17:26:10 -0700 Subject: [PATCH] Updates flight client and associated webpack plugin to preinitialize imports when resolving client references on the server (SSR). The result is that the SSR stream will end up streaming in async scripts for the chunks needed to hydrate the SSR'd content instead of waiting for the flight payload to start processing rows on the client to discover imports there. On the client however we need to be able to load the required chunks for a given import. We can't just use webpack's chunk loading because we don't have the chunkIds and are only transmitting the filepath. We implement our own chunk loading implementation which mimics webpack's with some differences. Namely there is no explicit timeout, we wait until the network fails if an earlier load or error even does not happen first. One consequence of this approach is we may insert the same script twice for a chunk, once during SSR, and again when the flight client starts processing the flight payload for hydration. Since chunks register modules the operation is idempotent and as long as there is some cache-control in place for the resource the network requests should not be duplicated. This does mean however that it is important that if a chunk contains the webpack runtime it is not ever loaded using this custom loader implementation. --- .eslintrc.js | 2 +- fixtures/flight/.nvmrc | 1 + fixtures/flight/config/webpack.config.js | 6 ++ fixtures/flight/server/global.js | 26 ++++- .../react-client/src/ReactFlightClient.js | 8 ++ .../forks/ReactFlightClientConfig.custom.js | 3 + .../ReactFlightClientConfig.dom-browser.js | 4 +- .../forks/ReactFlightClientConfig.dom-bun.js | 1 + ...eactFlightClientConfig.dom-edge-webpack.js | 4 +- .../ReactFlightClientConfig.dom-legacy.js | 4 +- ...eactFlightClientConfig.dom-node-webpack.js | 4 +- .../forks/ReactFlightClientConfig.dom-node.js | 3 +- .../src/shared/ReactFlightClientConfigDOM.js | 11 +++ .../react-dom/src/shared/ReactDOMTypes.js | 2 +- .../src/ReactNoopFlightClient.js | 1 + ... => ReactFlightClientConfigBundlerNode.js} | 42 +++++--- ... ReactFlightClientConfigBundlerWebpack.js} | 96 ++++++++++++------- ...FlightClientConfigBundlerWebpackBrowser.js | 28 ++++++ ...actFlightClientConfigBundlerWebpackEdge.js | 12 +++ ...actFlightClientConfigBundlerWebpackNode.js | 12 +++ ...ghtClientConfigWebpackDestinationClient.js | 19 ++++ ...ghtClientConfigWebpackDestinationServer.js | 28 ++++++ .../src/ReactFlightDOMClientBrowser.js | 1 + .../src/ReactFlightDOMClientEdge.js | 23 ++++- .../src/ReactFlightDOMClientNode.js | 12 ++- .../ReactFlightServerConfigWebpackBundler.js | 25 +++-- .../src/ReactFlightWebpackPlugin.js | 90 +++++++++++++---- .../src/__tests__/ReactFlightDOMEdge-test.js | 21 +++- .../src/__tests__/ReactFlightDOMNode-test.js | 14 ++- .../src/shared/ReactFlightClientReference.js | 45 +++++++++ .../react/src/__tests__/ReactFetch-test.js | 17 +++- scripts/flow/environment.js | 4 +- scripts/shared/inlinedHostConfigs.js | 4 + 33 files changed, 462 insertions(+), 111 deletions(-) create mode 100644 fixtures/flight/.nvmrc rename packages/react-server-dom-webpack/src/{ReactFlightClientConfigNodeBundler.js => ReactFlightClientConfigBundlerNode.js} (74%) rename packages/react-server-dom-webpack/src/{ReactFlightClientConfigWebpackBundler.js => ReactFlightClientConfigBundlerWebpack.js} (68%) create mode 100644 packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackBrowser.js create mode 100644 packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackEdge.js create mode 100644 packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackNode.js create mode 100644 packages/react-server-dom-webpack/src/ReactFlightClientConfigWebpackDestinationClient.js create mode 100644 packages/react-server-dom-webpack/src/ReactFlightClientConfigWebpackDestinationServer.js create mode 100644 packages/react-server-dom-webpack/src/shared/ReactFlightClientReference.js 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,