From 5dbe2be12439988b6e9d4e61e7c1c4f8f3db9afa Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Tue, 11 Aug 2026 05:19:59 -0700 Subject: [PATCH] serializer.unstable_getAsyncDependencyPath: Allow integrators to supply custom path types to `__loadBundleAsync`. Summary: Async dependency `paths` are a property of Metro's `_$$_DEPENDENCY_MAP` passed to `__loadBundleAsync`, used to implement lazy edges - conceptually they are a pointer to the (usually remote) location of an async-loadable segment. As an implementation detail, lazy dev bundling uses strings - relative URLs - in `paths`, and these are passed to `fetch`. See the original RFC: https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0605-lazy-bundling.md At Meta, Metro-Buck, with its custom serialiser, populates `paths` not with strings but with objects containing the data needed to construct URLs. This extends the core serialiser with a hook that may be used to provide custom `paths`, so that Metro-Buck can implement its logic on top of the OSS serialiser rather than forking it. The integrator is responsible for both sides of this serialiser <-> runtime contract: - `unstable_getAsyncDependencyPath` allows the integrator to return any JSON-serialisable value. Metro encodes these into the bundle. - `__loadBundleAsync` at runtime accepts the same type. This change is non-breaking in itself - the argument to `__loadBundleAsync` does not change unless the user supplies `unstable_getAsyncDependencyPath`. Changelog: ``` - **[Experimental]**: Add `serializer.unstable_getAsyncDependencyPath` to supply custom `paths` to framework-defined `__loadBundleAsync` ``` Reviewed By: huntie Differential Revision: D112630265 --- .../metro-runtime/src/modules/asyncRequire.js | 15 ++- packages/metro/API.md | 1 + .../DeltaBundler/Serializers/baseJSBundle.js | 1 + .../Serializers/helpers/__tests__/js-test.js | 55 +++++++++++ .../DeltaBundler/Serializers/helpers/js.js | 96 ++++++++++++------- .../Serializers/helpers/processModules.js | 4 + packages/metro/src/DeltaBundler/types.js | 14 +++ 7 files changed, 152 insertions(+), 34 deletions(-) diff --git a/packages/metro-runtime/src/modules/asyncRequire.js b/packages/metro-runtime/src/modules/asyncRequire.js index d7445a8642..c2fab9e1f9 100644 --- a/packages/metro-runtime/src/modules/asyncRequire.js +++ b/packages/metro-runtime/src/modules/asyncRequire.js @@ -17,7 +17,20 @@ type MetroRequire = { declare var require: MetroRequire; -type DependencyMapPaths = ?Readonly<{[moduleID: number | string]: unknown}>; +// When using `unstable_getAsyncDependencyPath`, path values may be any +// JSON-serialisable value. By default, they are string URLs - otherwise a +// custom `__loadBundleAsync` implementation must be provided. +type ReadonlyJsonData = + | null + | boolean + | number + | string + | ReadonlyArray + | Readonly<{[string]: ReadonlyJsonData}>; + +type DependencyMapPaths = ?Readonly<{ + [moduleID: number | string]: ReadonlyJsonData, +}>; declare var __METRO_GLOBAL_PREFIX__: string; diff --git a/packages/metro/API.md b/packages/metro/API.md index 89baa535d7..26e64f45dc 100644 --- a/packages/metro/API.md +++ b/packages/metro/API.md @@ -293,6 +293,7 @@ export type SerializerOptions = Readonly<{ sourceUrl: null | undefined | string; getSourceUrl: null | undefined | (($$PARAM_0$$: Module) => string); unstable_inlineDependencyMap?: boolean; + unstable_getAsyncDependencyPath?: (dependency: ResolvedDependency, options: unknown) => null | undefined | ReadonlyJsonData; }>; export type ServerOptions = Readonly<{ diff --git a/packages/metro/src/DeltaBundler/Serializers/baseJSBundle.js b/packages/metro/src/DeltaBundler/Serializers/baseJSBundle.js index 1eb091216c..b21a8ee184 100644 --- a/packages/metro/src/DeltaBundler/Serializers/baseJSBundle.js +++ b/packages/metro/src/DeltaBundler/Serializers/baseJSBundle.js @@ -40,6 +40,7 @@ export default function baseJSBundle( sourceUrl: options.sourceUrl, dependencyMapReservedName: options.dependencyMapReservedName, unstable_inlineDependencyMap: options.unstable_inlineDependencyMap, + unstable_getAsyncDependencyPath: options.unstable_getAsyncDependencyPath, }; // Do not prepend polyfills or the require runtime when only modules are requested diff --git a/packages/metro/src/DeltaBundler/Serializers/helpers/__tests__/js-test.js b/packages/metro/src/DeltaBundler/Serializers/helpers/__tests__/js-test.js index 466e94f424..9f222a264b 100644 --- a/packages/metro/src/DeltaBundler/Serializers/helpers/__tests__/js-test.js +++ b/packages/metro/src/DeltaBundler/Serializers/helpers/__tests__/js-test.js @@ -198,6 +198,61 @@ describe('wrapModule()', () => { `__d(function() { console.log("foo") },0,{"0":1,"1":2,"paths":{"1":"/../bar.bundle?modulesOnly=true&runModule=false"}});`, ); }); + + test('unstable_getAsyncDependencyPath overrides the default `.bundle?` URL', () => { + const dep = nullthrows(myModule.dependencies.get('bar')); + myModule.dependencies.set('bar', { + ...dep, + data: {...dep.data, data: {...dep.data.data, asyncType: 'async'}}, + }); + const seenPaths: Array = []; + const output = wrapModule(myModule, { + createModuleId: createModuleIdFactory(), + dev: false, + includeAsyncPaths: true, + projectRoot: '/root', + serverRoot: '/root', + // Deliberately null: proves the hook does NOT require sourceUrl (the + // default's invariant is skipped when the hook is provided). + sourceUrl: null, + unstable_getAsyncDependencyPath: dependency => { + seenPaths.push(dependency.absolutePath); + return { + displayName: 'bar', + mobileConfig: null, + segmentIDs: [42], + }; + }, + }); + // Hook invoked once, with the async dep only (sync dep 'baz' is skipped). + expect(seenPaths).toEqual(['/bar.js']); + expect(raw(output)).toMatchInlineSnapshot( + `__d(function() { console.log("foo") },0,{"0":1,"1":2,"paths":{"1":{"displayName":"bar","mobileConfig":null,"segmentIDs":[42]}}});`, + ); + }); + + test('unstable_getAsyncDependencyPath omits nullish paths', () => { + const dep = nullthrows(myModule.dependencies.get('bar')); + myModule.dependencies.set('bar', { + ...dep, + data: {...dep.data, data: {...dep.data.data, asyncType: 'async'}}, + }); + expect( + raw( + wrapModule(myModule, { + createModuleId: createModuleIdFactory(), + dev: false, + includeAsyncPaths: true, + projectRoot: '/root', + serverRoot: '/root', + sourceUrl: null, + unstable_getAsyncDependencyPath: () => null, + }), + ), + ).toMatchInlineSnapshot( + `__d(function() { console.log("foo") },0,{"0":1,"1":2,"paths":{}});`, + ); + }); }); describe('wrapModule() with inlined module ids', () => { diff --git a/packages/metro/src/DeltaBundler/Serializers/helpers/js.js b/packages/metro/src/DeltaBundler/Serializers/helpers/js.js index 57547d1741..e2a0a5db2d 100644 --- a/packages/metro/src/DeltaBundler/Serializers/helpers/js.js +++ b/packages/metro/src/DeltaBundler/Serializers/helpers/js.js @@ -9,7 +9,12 @@ * @oncall react_native */ -import type {MixedOutput, Module} from '../../types'; +import type { + MixedOutput, + Module, + ReadonlyJsonData, + ResolvedDependency, +} from '../../types'; import type {JsOutput} from 'metro-transform-worker'; import {isResolvedDependency} from '../../../lib/isResolvedDependency'; @@ -31,6 +36,19 @@ export type Options = Readonly<{ // references, instead of being appended as a dependency-map array argument. dependencyMapReservedName?: ?string, unstable_inlineDependencyMap?: boolean, + // Overrides how the value stored in the `__d(...)` `paths` object for an + // async dependency is computed. Called once per async dependency, only after + // `isResolvedDependency` has narrowed the dep. Default builds a `.bundle?` + // URL from `sourceUrl` + `serverRoot` (see `getDefaultAsyncDependencyPath`). + // The returned value is treated as opaque by the serializer and forwarded to + // `__loadBundleAsync` at runtime; the shape is a contract between the + // customSerializer and the on-device runtime. It must be JSON-serializable + // since it is embedded into the bundle via `JSON.stringify`. + // Returning nullish omits the value from `paths`. + unstable_getAsyncDependencyPath?: ( + dependency: ResolvedDependency, + options: Options, + ) => ?ReadonlyJsonData, ... }>; @@ -65,19 +83,57 @@ function getModuleVerboseName(module: Module<>, options: Options): string { ); } +// Default value stored in the `__d(...)` `paths` object for an async +// dependency: a server-relative `.bundle?` URL propagating most search params +// from the main bundle's URL. Overridable via +// `options.unstable_getAsyncDependencyPath`. +function getDefaultAsyncDependencyPath( + dependency: ResolvedDependency, + options: Options, +): string { + invariant( + options.sourceUrl != null, + 'sourceUrl is required when includeAsyncPaths is true', + ); + + // TODO: Only include path if the target is not in the bundle + + // Construct a server-relative URL for the split bundle, propagating + // most parameters from the main bundle's URL. + + const {searchParams} = new URL(jscSafeUrl.toNormalUrl(options.sourceUrl)); + searchParams.set('modulesOnly', 'true'); + searchParams.set('runModule', 'false'); + + const bundlePath = path.relative(options.serverRoot, dependency.absolutePath); + return ( + '/' + + path.join( + // TODO: This is not the proper Metro URL encoding of a file path + path.dirname(bundlePath), + // Strip the file extension + path.basename(bundlePath, path.extname(bundlePath)), + ) + + '.bundle?' + + searchParams.toString() + ); +} + function getModuleDependencies( module: Module<>, options: Options, ): { moduleId: number | string, dependencyMapArray: Array, - paths: {[moduleID: number | string]: unknown}, + paths: {[moduleID: number | string]: ReadonlyJsonData}, hasPaths: boolean, } { const moduleId = options.createModuleId(module.path); - const paths: {[moduleID: number | string]: unknown} = {}; + const paths: {[moduleID: number | string]: ReadonlyJsonData} = {}; let hasPaths = false; + const getAsyncDependencyPath = + options.unstable_getAsyncDependencyPath ?? getDefaultAsyncDependencyPath; const dependencyMapArray = Array.from(module.dependencies.values()).map( dependency => { if (!isResolvedDependency(dependency)) { @@ -88,36 +144,10 @@ function getModuleDependencies( const id = options.createModuleId(dependency.absolutePath); if (options.includeAsyncPaths && dependency.data.data.asyncType != null) { hasPaths = true; - invariant( - options.sourceUrl != null, - 'sourceUrl is required when includeAsyncPaths is true', - ); - - // TODO: Only include path if the target is not in the bundle - - // Construct a server-relative URL for the split bundle, propagating - // most parameters from the main bundle's URL. - - const {searchParams} = new URL( - jscSafeUrl.toNormalUrl(options.sourceUrl), - ); - searchParams.set('modulesOnly', 'true'); - searchParams.set('runModule', 'false'); - - const bundlePath = path.relative( - options.serverRoot, - dependency.absolutePath, - ); - paths[id] = - '/' + - path.join( - // TODO: This is not the proper Metro URL encoding of a file path - path.dirname(bundlePath), - // Strip the file extension - path.basename(bundlePath, path.extname(bundlePath)), - ) + - '.bundle?' + - searchParams.toString(); + const asyncDependencyPath = getAsyncDependencyPath(dependency, options); + if (asyncDependencyPath != null) { + paths[id] = asyncDependencyPath; + } } return id; }, diff --git a/packages/metro/src/DeltaBundler/Serializers/helpers/processModules.js b/packages/metro/src/DeltaBundler/Serializers/helpers/processModules.js index 3319700a88..d6c02c45a5 100644 --- a/packages/metro/src/DeltaBundler/Serializers/helpers/processModules.js +++ b/packages/metro/src/DeltaBundler/Serializers/helpers/processModules.js @@ -10,6 +10,7 @@ */ import type {Module} from '../../types'; +import type {Options as WrapModuleOptions} from './js'; import {isJsModule, wrapModule} from './js'; @@ -25,6 +26,7 @@ export default function processModules( sourceUrl, dependencyMapReservedName, unstable_inlineDependencyMap, + unstable_getAsyncDependencyPath, }: Readonly<{ filter?: (module: Module<>) => boolean, createModuleId: string => number, @@ -35,6 +37,7 @@ export default function processModules( sourceUrl: ?string, dependencyMapReservedName?: ?string, unstable_inlineDependencyMap?: boolean, + unstable_getAsyncDependencyPath?: WrapModuleOptions['unstable_getAsyncDependencyPath'], }>, ): ReadonlyArray<[Module<>, string]> { return [...modules] @@ -51,6 +54,7 @@ export default function processModules( sourceUrl, dependencyMapReservedName, unstable_inlineDependencyMap, + unstable_getAsyncDependencyPath, }), ]); } diff --git a/packages/metro/src/DeltaBundler/types.js b/packages/metro/src/DeltaBundler/types.js index c2490adedc..5fc7146ab7 100644 --- a/packages/metro/src/DeltaBundler/types.js +++ b/packages/metro/src/DeltaBundler/types.js @@ -17,6 +17,16 @@ import type {JsTransformOptions} from 'metro-transform-worker'; import CountingSet from '../lib/CountingSet'; +// Any value produced by `JSON.stringify`. Duplicated (rather than shared) with +// metro-runtime's `asyncRequire` to avoid a new cross-package type coupling. +export type ReadonlyJsonData = + | null + | boolean + | number + | string + | ReadonlyArray + | Readonly<{[string]: ReadonlyJsonData}>; + export type MixedOutput = { readonly data: unknown, readonly type: string, @@ -188,4 +198,8 @@ export type SerializerOptions = Readonly<{ sourceUrl: ?string, getSourceUrl: ?(Module<>) => string, unstable_inlineDependencyMap?: boolean, + unstable_getAsyncDependencyPath?: ( + dependency: ResolvedDependency, + options: unknown, + ) => ?ReadonlyJsonData, }>;