From af24e987be553faccf7d8e664a9faa579dfbba4b Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 2 Apr 2026 16:13:03 +0200 Subject: [PATCH] [Flight] Standardize `ClientReferenceMetadata` tuple type Previously, `ClientReferenceMetadata` was declared as an opaque type in the Flight server config (`ReactFlightServerConfigBundlerCustom.js`), with each bundler defining its own tuple shape. Webpack, Turbopack, and Unbundled used `[id, chunks, name, async?]` while Parcel used `[id, name, bundles, importMap?]`, placing the chunks array at different indices. This made it impossible for `ReactFlightServer.js` to access the chunks array without bundler-specific accessor functions. This change standardizes all bundlers on a common shape where `id` is at index 0, `name` is at index 1, and `chunks` is at index 2. A bundler- specific extra like the async flag or Parcel's `importMap` can go at index 3. The `ClientReferenceMetadata` type is changed from an opaque type to a concrete union type, so `ReactFlightServer.js` can now directly access `metadata[2]` to get the chunks array with proper Flow typing. This only affects the internal wire format tuple, not the `ClientManifest` that frameworks pass into Flight. The manifest entry shape (`{id, chunks, name, async?}`) remains unchanged. The motivation is to enable deduplicating individual chunk URLs across client references in the RSC stream. With a known, non-opaque type, `emitImportChunk` can extract, deduplicate, and replace chunk entries without needing bundler-specific callback functions or accessor methods. --- .../react-noop-renderer/src/ReactNoopFlightClient.js | 10 +++++++--- .../react-noop-renderer/src/ReactNoopFlightServer.js | 2 +- .../src/shared/ReactFlightImportMetadata.js | 2 +- .../client/ReactFlightClientConfigBundlerTurbopack.js | 8 ++++---- .../server/ReactFlightServerConfigTurbopackBundler.js | 4 ++-- .../src/shared/ReactFlightImportMetadata.js | 8 ++++---- .../server/ReactFlightServerConfigUnbundledBundler.js | 4 ++-- .../src/shared/ReactFlightImportMetadata.js | 10 +++++----- .../client/ReactFlightClientConfigBundlerWebpack.js | 8 ++++---- .../server/ReactFlightServerConfigWebpackBundler.js | 4 ++-- .../src/shared/ReactFlightImportMetadata.js | 10 +++++----- .../src/ReactFlightServerConfigBundlerCustom.js | 5 ++++- .../src/forks/ReactFlightServerConfig.markup.js | 5 ++++- 13 files changed, 45 insertions(+), 35 deletions(-) diff --git a/packages/react-noop-renderer/src/ReactNoopFlightClient.js b/packages/react-noop-renderer/src/ReactNoopFlightClient.js index cf7e4168bd89..4bde73dbcf15 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightClient.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightClient.js @@ -37,10 +37,14 @@ const {createResponse, createStreamState, processBinaryChunk, getRoot, close} = readFinalStringChunk(decoder: TextDecoder, buffer: Uint8Array): string { return decoder.decode(buffer); }, - resolveClientReference(bundlerConfig: null, idx: string) { - return idx; + resolveClientReference(bundlerConfig: null, metadata: [string, string]) { + return metadata[0]; }, - prepareDestinationForModule(moduleLoading: null, metadata: string) {}, + prepareDestinationForModule( + moduleLoading: null, + nonce: ?string, + metadata: [string, string], + ) {}, preloadModule(idx: string) {}, requireModule(idx: string) { return readModule(idx); diff --git a/packages/react-noop-renderer/src/ReactNoopFlightServer.js b/packages/react-noop-renderer/src/ReactNoopFlightServer.js index a10edd2b7737..b902275739a9 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightServer.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightServer.js @@ -63,7 +63,7 @@ const ReactNoopFlightServer = ReactFlightServer({ config: void, reference: {$$typeof: symbol, value: any}, ) { - return saveModule(reference.value); + return [saveModule(reference.value), '*']; }, }); diff --git a/packages/react-server-dom-parcel/src/shared/ReactFlightImportMetadata.js b/packages/react-server-dom-parcel/src/shared/ReactFlightImportMetadata.js index d91f5d825c22..225290e9d74f 100644 --- a/packages/react-server-dom-parcel/src/shared/ReactFlightImportMetadata.js +++ b/packages/react-server-dom-parcel/src/shared/ReactFlightImportMetadata.js @@ -15,7 +15,7 @@ export type ImportMetadata = [ id: string, name: string, bundles: Array, - importMap?: {[string]: string}, + importMap?: {[string]: string} | void, /* eslint-enable */ ]; diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js b/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js index 2c26859280ad..e8f78d00ba2a 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js @@ -95,12 +95,12 @@ export function resolveClientReference( if (isAsyncImport(metadata)) { return [ resolvedModuleData.id, - resolvedModuleData.chunks, name, + resolvedModuleData.chunks, 1 /* async */, ]; } else { - return [resolvedModuleData.id, resolvedModuleData.chunks, name]; + return [resolvedModuleData.id, name, resolvedModuleData.chunks]; } } return metadata; @@ -142,12 +142,12 @@ export function resolveServerReference( // manifest. return [ resolvedModuleData.id, - resolvedModuleData.chunks, name, + resolvedModuleData.chunks, 1 /* async */, ]; } - return [resolvedModuleData.id, resolvedModuleData.chunks, name]; + return [resolvedModuleData.id, name, resolvedModuleData.chunks]; } function requireAsyncModule(id: string): null | Thenable { diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightServerConfigTurbopackBundler.js b/packages/react-server-dom-turbopack/src/server/ReactFlightServerConfigTurbopackBundler.js index 219391f8f819..1753a658bf48 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightServerConfigTurbopackBundler.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightServerConfigTurbopackBundler.js @@ -80,9 +80,9 @@ export function resolveClientReferenceMetadata( ); } if (resolvedModuleData.async === true || clientReference.$$async === true) { - return [resolvedModuleData.id, resolvedModuleData.chunks, name, 1]; + return [resolvedModuleData.id, name, resolvedModuleData.chunks, 1]; } else { - return [resolvedModuleData.id, resolvedModuleData.chunks, name]; + return [resolvedModuleData.id, name, resolvedModuleData.chunks]; } } diff --git a/packages/react-server-dom-turbopack/src/shared/ReactFlightImportMetadata.js b/packages/react-server-dom-turbopack/src/shared/ReactFlightImportMetadata.js index 7cfce93deb25..1ec155b3c243 100644 --- a/packages/react-server-dom-turbopack/src/shared/ReactFlightImportMetadata.js +++ b/packages/react-server-dom-turbopack/src/shared/ReactFlightImportMetadata.js @@ -20,15 +20,15 @@ export type ImportManifestEntry = { export type ImportMetadata = | [ /* id */ string, - /* chunk filenames */ Array, /* name */ string, + /* chunk filenames */ Array, /* async */ 1, ] - | [/* id */ string, /* chunk filenames */ Array, /* name */ string]; + | [/* id */ string, /* name */ string, /* chunk filenames */ Array]; export const ID = 0; -export const CHUNKS = 1; -export const NAME = 2; +export const NAME = 1; +export const CHUNKS = 2; // export const ASYNC = 3; // This logic is correct because currently only include the 4th tuple member diff --git a/packages/react-server-dom-unbundled/src/server/ReactFlightServerConfigUnbundledBundler.js b/packages/react-server-dom-unbundled/src/server/ReactFlightServerConfigUnbundledBundler.js index d2c8b038c06e..19fd5e5e2420 100644 --- a/packages/react-server-dom-unbundled/src/server/ReactFlightServerConfigUnbundledBundler.js +++ b/packages/react-server-dom-unbundled/src/server/ReactFlightServerConfigUnbundledBundler.js @@ -80,9 +80,9 @@ export function resolveClientReferenceMetadata( ); } if (resolvedModuleData.async === true || clientReference.$$async === true) { - return [resolvedModuleData.id, resolvedModuleData.chunks, name, 1]; + return [resolvedModuleData.id, name, resolvedModuleData.chunks, 1]; } else { - return [resolvedModuleData.id, resolvedModuleData.chunks, name]; + return [resolvedModuleData.id, name, resolvedModuleData.chunks]; } } diff --git a/packages/react-server-dom-unbundled/src/shared/ReactFlightImportMetadata.js b/packages/react-server-dom-unbundled/src/shared/ReactFlightImportMetadata.js index 29b012f60520..2171aea4caf5 100644 --- a/packages/react-server-dom-unbundled/src/shared/ReactFlightImportMetadata.js +++ b/packages/react-server-dom-unbundled/src/shared/ReactFlightImportMetadata.js @@ -16,23 +16,23 @@ export type ImportManifestEntry = { }; // This is the parsed shape of the wire format which is why it is -// condensed to only the essentialy information +// condensed to only the essentially information export type ImportMetadata = | [ /* id */ string, - /* chunks id/filename pairs, double indexed */ Array, /* name */ string, + /* chunks id/filename pairs, double indexed */ Array, /* async */ 1, ] | [ /* id */ string, - /* chunks id/filename pairs, double indexed */ Array, /* name */ string, + /* chunks id/filename pairs, double indexed */ Array, ]; export const ID = 0; -export const CHUNKS = 1; -export const NAME = 2; +export const NAME = 1; +export const CHUNKS = 2; // export const ASYNC = 3; // This logic is correct because currently only include the 4th tuple member diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js b/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js index 23825c4dcf7b..3aadda1e8033 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js @@ -102,12 +102,12 @@ export function resolveClientReference( if (isAsyncImport(metadata)) { return [ resolvedModuleData.id, - resolvedModuleData.chunks, name, + resolvedModuleData.chunks, 1 /* async */, ]; } else { - return [resolvedModuleData.id, resolvedModuleData.chunks, name]; + return [resolvedModuleData.id, name, resolvedModuleData.chunks]; } } return metadata; @@ -149,12 +149,12 @@ export function resolveServerReference( // manifest. return [ resolvedModuleData.id, - resolvedModuleData.chunks, name, + resolvedModuleData.chunks, 1 /* async */, ]; } - return [resolvedModuleData.id, resolvedModuleData.chunks, name]; + return [resolvedModuleData.id, name, resolvedModuleData.chunks]; } // The chunk cache contains all the chunks we've preloaded so far. diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightServerConfigWebpackBundler.js b/packages/react-server-dom-webpack/src/server/ReactFlightServerConfigWebpackBundler.js index f9d9bf4ea916..7bc13c568f47 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightServerConfigWebpackBundler.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightServerConfigWebpackBundler.js @@ -80,9 +80,9 @@ export function resolveClientReferenceMetadata( ); } if (resolvedModuleData.async === true || clientReference.$$async === true) { - return [resolvedModuleData.id, resolvedModuleData.chunks, name, 1]; + return [resolvedModuleData.id, name, resolvedModuleData.chunks, 1]; } else { - return [resolvedModuleData.id, resolvedModuleData.chunks, name]; + return [resolvedModuleData.id, name, resolvedModuleData.chunks]; } } diff --git a/packages/react-server-dom-webpack/src/shared/ReactFlightImportMetadata.js b/packages/react-server-dom-webpack/src/shared/ReactFlightImportMetadata.js index 29b012f60520..2171aea4caf5 100644 --- a/packages/react-server-dom-webpack/src/shared/ReactFlightImportMetadata.js +++ b/packages/react-server-dom-webpack/src/shared/ReactFlightImportMetadata.js @@ -16,23 +16,23 @@ export type ImportManifestEntry = { }; // This is the parsed shape of the wire format which is why it is -// condensed to only the essentialy information +// condensed to only the essentially information export type ImportMetadata = | [ /* id */ string, - /* chunks id/filename pairs, double indexed */ Array, /* name */ string, + /* chunks id/filename pairs, double indexed */ Array, /* async */ 1, ] | [ /* id */ string, - /* chunks id/filename pairs, double indexed */ Array, /* name */ string, + /* chunks id/filename pairs, double indexed */ Array, ]; export const ID = 0; -export const CHUNKS = 1; -export const NAME = 2; +export const NAME = 1; +export const CHUNKS = 2; // export const ASYNC = 3; // This logic is correct because currently only include the 4th tuple member diff --git a/packages/react-server/src/ReactFlightServerConfigBundlerCustom.js b/packages/react-server/src/ReactFlightServerConfigBundlerCustom.js index ac3e17c63017..dca569d10606 100644 --- a/packages/react-server/src/ReactFlightServerConfigBundlerCustom.js +++ b/packages/react-server/src/ReactFlightServerConfigBundlerCustom.js @@ -12,7 +12,10 @@ declare const $$$config: any; export opaque type ClientManifest = mixed; export opaque type ClientReference = mixed; // eslint-disable-line no-unused-vars export opaque type ServerReference = mixed; // eslint-disable-line no-unused-vars -export opaque type ClientReferenceMetadata: any = mixed; +export type ClientReferenceMetadata = + | [string, string] + | [string, string, $ReadOnlyArray] + | [string, string, $ReadOnlyArray, mixed]; export opaque type ServerReferenceId: any = mixed; export opaque type ClientReferenceKey: any = mixed; export const isClientReference = $$$config.isClientReference; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.markup.js b/packages/react-server/src/forks/ReactFlightServerConfig.markup.js index ca8c4670834f..3ecc7aa4afe7 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.markup.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.markup.js @@ -48,7 +48,10 @@ export * from '../ReactServerConsoleConfigPlain'; export type ClientManifest = null; export opaque type ClientReference = null; // eslint-disable-line no-unused-vars export opaque type ServerReference = null; // eslint-disable-line no-unused-vars -export opaque type ClientReferenceMetadata: any = null; +export type ClientReferenceMetadata = + | [string, string] + | [string, string, $ReadOnlyArray] + | [string, string, $ReadOnlyArray, mixed]; export opaque type ServerReferenceId: string = string; export opaque type ClientReferenceKey: any = string;