From 2f9a4b6b8b08b9199c456d011f0dddf602c7e5f5 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 27 May 2022 13:06:57 -0400 Subject: [PATCH] Add a module map option to the Webpack Flight Client On the server we have a similar translation map from the file path that the loader uses to the refer to the original module and to the bundled module ID. The Flight server is optimized to emit the smallest format for the client. However during SSR, the same client component might go by a different module ID since it's a different bundle than the client bundle. This provides an option to add a translation map from client ID to SSR ID when reading the Flight stream. Ideally we should have a special SSR Flight Client that takes this option but for now we only have one Client for both. --- .../react-client/src/ReactFlightClient.js | 10 +++- .../src/ReactFlightClientStream.js | 6 +- .../ReactFlightClientHostConfig.custom.js | 1 + .../src/ReactNoopFlightClient.js | 4 +- .../ReactFlightDOMRelayClientHostConfig.js | 14 ++++- .../ReactFlightDOMRelay-test.internal.js | 2 +- .../ReactFlightClientWebpackBundlerConfig.js | 12 ++++ .../src/ReactFlightDOMClient.js | 29 +++++++-- .../__tests__/ReactFlightDOMBrowser-test.js | 59 +++++++++++++++++++ .../ReactFlightNativeRelayClientHostConfig.js | 14 ++++- 10 files changed, 137 insertions(+), 14 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index dabb215c708..13fb0897e74 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -15,6 +15,7 @@ import type { ModuleMetaData, UninitializedModel, Response, + BundlerConfig, } from './ReactFlightClientHostConfig'; import { @@ -97,6 +98,7 @@ Chunk.prototype.then = function(resolve: () => mixed) { }; export type ResponseBase = { + _bundlerConfig: BundlerConfig, _chunks: Map>, readRoot(): T, ... @@ -338,9 +340,10 @@ export function parseModelTuple( return value; } -export function createResponse(): ResponseBase { +export function createResponse(bundlerConfig: BundlerConfig): ResponseBase { const chunks: Map> = new Map(); const response = { + _bundlerConfig: bundlerConfig, _chunks: chunks, readRoot: readRoot, }; @@ -384,7 +387,10 @@ export function resolveModule( const chunks = response._chunks; const chunk = chunks.get(id); const moduleMetaData: ModuleMetaData = parseModel(response, model); - const moduleReference = resolveModuleReference(moduleMetaData); + const moduleReference = resolveModuleReference( + response._bundlerConfig, + moduleMetaData, + ); // TODO: Add an option to encode modules that are lazy loaded. // For now we preload all modules as early as possible since it's likely diff --git a/packages/react-client/src/ReactFlightClientStream.js b/packages/react-client/src/ReactFlightClientStream.js index 8af1734de6b..ed27a10f6e3 100644 --- a/packages/react-client/src/ReactFlightClientStream.js +++ b/packages/react-client/src/ReactFlightClientStream.js @@ -9,6 +9,8 @@ import type {Response} from './ReactFlightClientHostConfigStream'; +import type {BundlerConfig} from './ReactFlightClientHostConfig'; + import { resolveModule, resolveModel, @@ -121,11 +123,11 @@ function createFromJSONCallback(response: Response) { }; } -export function createResponse(): Response { +export function createResponse(bundlerConfig: BundlerConfig): Response { // NOTE: CHECK THE COMPILER OUTPUT EACH TIME YOU CHANGE THIS. // It should be inlined to one object literal but minor changes can break it. const stringDecoder = supportsBinaryStreams ? createStringDecoder() : null; - const response: any = createResponseBase(); + const response: any = createResponseBase(bundlerConfig); response._partialRow = ''; if (supportsBinaryStreams) { response._stringDecoder = stringDecoder; diff --git a/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js b/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js index 5f0b9d2c711..829e5e65e99 100644 --- a/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js +++ b/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js @@ -26,6 +26,7 @@ declare var $$$hostConfig: any; export type Response = any; +export opaque type BundlerConfig = mixed; // eslint-disable-line no-undef export opaque type ModuleMetaData = mixed; // eslint-disable-line no-undef export opaque type ModuleReference = mixed; // eslint-disable-line no-undef export const resolveModuleReference = $$$hostConfig.resolveModuleReference; diff --git a/packages/react-noop-renderer/src/ReactNoopFlightClient.js b/packages/react-noop-renderer/src/ReactNoopFlightClient.js index df586c6efb2..52af83c5ef6 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightClient.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightClient.js @@ -22,7 +22,7 @@ type Source = Array; const {createResponse, processStringChunk, close} = ReactFlightClient({ supportsBinaryStreams: false, - resolveModuleReference(idx: string) { + resolveModuleReference(bundlerConfig: null, idx: string) { return idx; }, preloadModule(idx: string) {}, @@ -35,7 +35,7 @@ const {createResponse, processStringChunk, close} = ReactFlightClient({ }); function read(source: Source): T { - const response = createResponse(source); + const response = createResponse(source, null); for (let i = 0; i < source.length; i++) { processStringChunk(response, source[i], 0); } diff --git a/packages/react-server-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js b/packages/react-server-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js index 30718865ccd..308bbef0ed2 100644 --- a/packages/react-server-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js +++ b/packages/react-server-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js @@ -11,6 +11,8 @@ import type {JSONValue, ResponseBase} from 'react-client/src/ReactFlightClient'; import type {JSResourceReference} from 'JSResourceReference'; +import type {ModuleMetaData} from 'ReactFlightDOMRelayClientIntegration'; + export type ModuleReference = JSResourceReference; import { @@ -19,19 +21,29 @@ import { } from 'react-client/src/ReactFlightClient'; export { - resolveModuleReference, preloadModule, requireModule, } from 'ReactFlightDOMRelayClientIntegration'; +import {resolveModuleReference as resolveModuleReferenceImpl} from 'ReactFlightDOMRelayClientIntegration'; + import isArray from 'shared/isArray'; export type {ModuleMetaData} from 'ReactFlightDOMRelayClientIntegration'; +export type BundlerConfig = null; + export type UninitializedModel = JSONValue; export type Response = ResponseBase; +export function resolveModuleReference( + bundlerConfig: BundlerConfig, + moduleData: ModuleMetaData, +): ModuleReference { + return resolveModuleReferenceImpl(moduleData); +} + function parseModelRecursively(response: Response, parentObj, value) { if (typeof value === 'string') { return parseModelString(response, parentObj, value); diff --git a/packages/react-server-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js b/packages/react-server-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js index daf01d5d69b..0444add0379 100644 --- a/packages/react-server-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js +++ b/packages/react-server-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js @@ -31,7 +31,7 @@ describe('ReactFlightDOMRelay', () => { }); function readThrough(data) { - const response = ReactDOMFlightRelayClient.createResponse(); + const response = ReactDOMFlightRelayClient.createResponse(null); for (let i = 0; i < data.length; i++) { const chunk = data[i]; ReactDOMFlightRelayClient.resolveRow(response, chunk); diff --git a/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js b/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js index f3c4e1bf1c1..d36642532f5 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js +++ b/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js @@ -7,6 +7,14 @@ * @flow */ +export type WebpackSSRMap = { + [clientId: string]: { + [clientExportName: string]: ModuleMetaData, + }, +}; + +export type BundlerConfig = null | WebpackSSRMap; + export opaque type ModuleMetaData = { id: string, chunks: Array, @@ -17,8 +25,12 @@ export opaque type ModuleMetaData = { export opaque type ModuleReference = ModuleMetaData; export function resolveModuleReference( + bundlerConfig: BundlerConfig, moduleData: ModuleMetaData, ): ModuleReference { + if (bundlerConfig) { + return bundlerConfig[moduleData.id][moduleData.name]; + } return moduleData; } diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClient.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClient.js index 9c9d17c11f5..d8b5def41e0 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClient.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClient.js @@ -9,6 +9,8 @@ import type {Response as FlightResponse} from 'react-client/src/ReactFlightClientStream'; +import type {BundlerConfig} from './ReactFlightClientWebpackBundlerConfig'; + import { createResponse, reportGlobalError, @@ -17,6 +19,10 @@ import { close, } from 'react-client/src/ReactFlightClientStream'; +export type Options = { + moduleMap?: BundlerConfig, +}; + function startReadingFromStream( response: FlightResponse, stream: ReadableStream, @@ -37,16 +43,24 @@ function startReadingFromStream( reader.read().then(progress, error); } -function createFromReadableStream(stream: ReadableStream): FlightResponse { - const response: FlightResponse = createResponse(); +function createFromReadableStream( + stream: ReadableStream, + options?: Options, +): FlightResponse { + const response: FlightResponse = createResponse( + options && options.moduleMap ? options.moduleMap : null, + ); startReadingFromStream(response, stream); return response; } function createFromFetch( promiseForResponse: Promise, + options?: Options, ): FlightResponse { - const response: FlightResponse = createResponse(); + const response: FlightResponse = createResponse( + options && options.moduleMap ? options.moduleMap : null, + ); promiseForResponse.then( function(r) { startReadingFromStream(response, (r.body: any)); @@ -58,8 +72,13 @@ function createFromFetch( return response; } -function createFromXHR(request: XMLHttpRequest): FlightResponse { - const response: FlightResponse = createResponse(); +function createFromXHR( + request: XMLHttpRequest, + options?: Options, +): FlightResponse { + const response: FlightResponse = createResponse( + options && options.moduleMap ? options.moduleMap : null, + ); let processedLength = 0; function progress(e: ProgressEvent): void { const chunk = request.responseText; diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index c7b97707c16..3337b19d11f 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -24,6 +24,7 @@ global.__webpack_require__ = function(id) { let act; let React; let ReactDOMClient; +let ReactDOMServer; let ReactServerDOMWriter; let ReactServerDOMReader; @@ -35,6 +36,7 @@ describe('ReactFlightDOMBrowser', () => { act = require('jest-react').act; React = require('react'); ReactDOMClient = require('react-dom/client'); + ReactDOMServer = require('react-dom/server.browser'); ReactServerDOMWriter = require('react-server-dom-webpack/writer.browser.server'); ReactServerDOMReader = require('react-server-dom-webpack'); }); @@ -69,6 +71,18 @@ describe('ReactFlightDOMBrowser', () => { } } + async function readResult(stream) { + const reader = stream.getReader(); + let result = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) { + return result; + } + result += Buffer.from(value).toString('utf8'); + } + } + function makeDelayedText(Model) { let error, _resolve, _reject; let promise = new Promise((resolve, reject) => { @@ -453,4 +467,49 @@ describe('ReactFlightDOMBrowser', () => { // Final pending chunk is written; stream should be closed. expect(isDone).toBeTruthy(); }); + + it('should allow an alternative module mapping to be used for SSR', async () => { + function ClientComponent() { + return Client Component; + } + // The Client build may not have the same IDs as the Server bundles for the same + // component. + const ClientComponentOnTheClient = moduleReference(ClientComponent); + const ClientComponentOnTheServer = moduleReference(ClientComponent); + + // In the SSR bundle this module won't exist. We simulate this by deleting it. + const clientId = webpackMap[ClientComponentOnTheClient.filepath].default.id; + delete webpackModules[clientId]; + + // Instead, we have to provide a translation from the client meta data to the SSR + // meta data. + const ssrMetaData = webpackMap[ClientComponentOnTheServer.filepath].default; + const translationMap = { + [clientId]: { + d: ssrMetaData, + }, + }; + + function App() { + return ; + } + + const stream = ReactServerDOMWriter.renderToReadableStream( + , + webpackMap, + ); + const response = ReactServerDOMReader.createFromReadableStream(stream, { + moduleMap: translationMap, + }); + + function ClientRoot() { + return response.readRoot(); + } + + const ssrStream = await ReactDOMServer.renderToReadableStream( + , + ); + const result = await readResult(ssrStream); + expect(result).toEqual('Client Component'); + }); }); diff --git a/packages/react-server-native-relay/src/ReactFlightNativeRelayClientHostConfig.js b/packages/react-server-native-relay/src/ReactFlightNativeRelayClientHostConfig.js index eb6f0080e37..83ab8800d45 100644 --- a/packages/react-server-native-relay/src/ReactFlightNativeRelayClientHostConfig.js +++ b/packages/react-server-native-relay/src/ReactFlightNativeRelayClientHostConfig.js @@ -11,6 +11,8 @@ import type {JSONValue, ResponseBase} from 'react-client/src/ReactFlightClient'; import type {JSResourceReference} from 'JSResourceReference'; +import type {ModuleMetaData} from 'ReactFlightNativeRelayClientIntegration'; + export type ModuleReference = JSResourceReference; import { @@ -19,19 +21,29 @@ import { } from 'react-client/src/ReactFlightClient'; export { - resolveModuleReference, preloadModule, requireModule, } from 'ReactFlightNativeRelayClientIntegration'; +import {resolveModuleReference as resolveModuleReferenceImpl} from 'ReactFlightNativeRelayClientIntegration'; + import isArray from 'shared/isArray'; export type {ModuleMetaData} from 'ReactFlightNativeRelayClientIntegration'; +export type BundlerConfig = null; + export type UninitializedModel = JSONValue; export type Response = ResponseBase; +export function resolveModuleReference( + bundlerConfig: BundlerConfig, + moduleData: ModuleMetaData, +): ModuleReference { + return resolveModuleReferenceImpl(moduleData); +} + function parseModelRecursively(response: Response, parentObj, value) { if (typeof value === 'string') { return parseModelString(response, parentObj, value);