diff --git a/packages/metro-config/API.md b/packages/metro-config/API.md index 6f2d251d24..553483f022 100644 --- a/packages/metro-config/API.md +++ b/packages/metro-config/API.md @@ -179,6 +179,7 @@ export type SerializerConfigT = { processModuleFilter: (modules: Module) => boolean; isThirdPartyModule: (module: Readonly<{path: string}>) => boolean; unstable_inlineDependencyMap: boolean; + unstable_lazilyDefineModules: boolean; }; export type ServerConfigT = { diff --git a/packages/metro-config/src/defaults/index.js b/packages/metro-config/src/defaults/index.js index 24507326b0..3da8b3b439 100644 --- a/packages/metro-config/src/defaults/index.js +++ b/packages/metro-config/src/defaults/index.js @@ -71,6 +71,7 @@ const getDefaultValues = (projectRoot: ?string): ConfigT => ({ isThirdPartyModule: module => /(?:^|[/\\])node_modules[/\\]/.test(module.path), unstable_inlineDependencyMap: false, + unstable_lazilyDefineModules: false, }, server: { diff --git a/packages/metro-config/src/types.js b/packages/metro-config/src/types.js index 8b0480500f..7678e4e1ab 100644 --- a/packages/metro-config/src/types.js +++ b/packages/metro-config/src/types.js @@ -148,6 +148,12 @@ type SerializerConfigT = { processModuleFilter: (modules: Module<>) => boolean, isThirdPartyModule: (module: Readonly<{path: string, ...}>) => boolean, unstable_inlineDependencyMap: boolean, + // When true, the default bundle serializer emits modules inside a single + // segment definer (`__registerSegment(0, function (moduleId) { switch ... })`) + // so each `__d(...)` runs lazily on first require instead of eagerly at + // startup. Reduces startup registration cost and peak heap for large graphs. + // Experimental; source maps are supported. Does not affect delta/HMR. + unstable_lazilyDefineModules: boolean, }; type TransformerConfigT = { diff --git a/packages/metro/src/DeltaBundler/Serializers/__tests__/baseJSBundleWithLazyModuleDefinition-test.js b/packages/metro/src/DeltaBundler/Serializers/__tests__/baseJSBundleWithLazyModuleDefinition-test.js new file mode 100644 index 0000000000..e4971b1952 --- /dev/null +++ b/packages/metro/src/DeltaBundler/Serializers/__tests__/baseJSBundleWithLazyModuleDefinition-test.js @@ -0,0 +1,207 @@ +/** + * 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 strict-local + * @format + * @oncall react_native + */ + +import type {Module, TransformInputOptions} from '../../types'; + +import CountingSet from '../../../lib/CountingSet'; +import baseJSBundleWithLazyModuleDefinition from '../baseJSBundleWithLazyModuleDefinition'; + +const polyfill: Module<> = { + path: '/polyfill', + dependencies: new Map(), + inverseDependencies: new CountingSet(), + output: [ + { + type: 'js/script', + data: {code: '__d(function() {/* code for polyfill */});', lineCount: 1}, + }, + ], + getSource: () => Buffer.from('polyfill-source'), +}; + +const fooModule: Module<> = { + path: '/root/foo', + dependencies: new Map([ + [ + './bar', + { + absolutePath: '/root/bar', + data: { + data: {asyncType: null, isESMImport: false, locs: [], key: './bar'}, + name: './bar', + }, + }, + ], + ]), + inverseDependencies: new CountingSet(), + output: [ + { + type: 'js/module', + data: { + code: '__d(function() {/* code for foo */});', + map: [], + lineCount: 1, + }, + }, + ], + getSource: () => Buffer.from('foo-source'), +}; + +const barModule: Module<> = { + path: '/root/bar', + dependencies: new Map(), + inverseDependencies: new CountingSet(['/root/foo']), + output: [ + { + type: 'js/module', + data: { + code: '__d(function() {/* code for bar */});', + map: [], + lineCount: 1, + }, + }, + ], + getSource: () => Buffer.from('bar-source'), +}; + +const transformOptions: TransformInputOptions = { + customTransformOptions: {}, + dev: true, + minify: true, + platform: 'web', + type: 'module', + unstable_transformProfile: 'default', +}; + +function serialize() { + return baseJSBundleWithLazyModuleDefinition( + '/root/foo', + [polyfill], + { + dependencies: new Map([ + ['/root/foo', fooModule], + ['/root/bar', barModule], + ]), + entryPoints: new Set(['/root/foo']), + transformOptions, + }, + { + asyncRequireModulePath: '', + createModuleId: (filePath: string) => (filePath === '/root/foo' ? 0 : 1), + dev: true, + getRunModuleStatement: (moduleId: number | string) => + `require(${JSON.stringify(moduleId)});`, + globalPrefix: '', + includeAsyncPaths: false, + inlineSourceMap: false, + modulesOnly: false, + processModuleFilter: () => true, + projectRoot: '/root', + runBeforeMainModule: [], + runModule: true, + serverRoot: '/root', + shouldAddToIgnoreList: () => false, + sourceMapUrl: 'http://localhost/bundle.map', + sourceUrl: null, + getSourceUrl: null, + }, + ); +} + +test('wraps real modules in a segment switch and emits polyfills eagerly', () => { + const {code} = serialize(); + + // Polyfill is emitted eagerly at the top. + expect(code).toContain('__d(function() {/* code for polyfill */});'); + + // Real modules are registered lazily via a single segment definer. + expect(code).toContain('__registerSegment(0, function defSeg0(moduleId) {'); + expect(code).toContain('var ___d = __d;'); + expect(code).toContain('switch (moduleId) {'); + expect(code).toContain('case 0:'); + expect(code).toContain('case 1:'); + expect(code).toContain('___d(function() {/* code for foo */}'); + expect(code).toContain('return;'); + expect(code).toContain( + 'default: new Error("No module found for ID " + moduleId);', + ); + + // Run-module call comes after registration. + expect(code).toContain('require(0);'); +}); + +test('polyfills lead, modules are inside the switch, run-module trails', () => { + const {code} = serialize(); + const polyfillAt = code.indexOf('code for polyfill'); + const segmentAt = code.indexOf('__registerSegment'); + const fooAt = code.indexOf('code for foo'); + const runAt = code.indexOf('require(0);'); + + expect(polyfillAt).toBeGreaterThanOrEqual(0); + expect(segmentAt).toBeGreaterThan(polyfillAt); + // foo's __d is inside the switch (after __registerSegment), not eager. + expect(fooAt).toBeGreaterThan(segmentAt); + expect(runAt).toBeGreaterThan(segmentAt); +}); + +test('produces a valid indexed source map', () => { + const {map} = serialize(); + const parsed = JSON.parse(map); + expect(parsed.version).toBe(3); + // BundleBuilder emits a sectioned (indexed) map. + expect(Array.isArray(parsed.sections)).toBe(true); + expect(parsed.sections.length).toBeGreaterThan(0); + for (const section of parsed.sections) { + expect(section.offset).toEqual( + expect.objectContaining({ + line: expect.any(Number), + column: expect.any(Number), + }), + ); + } +}); + +test('modulesOnly omits the eager polyfills but keeps the segment', () => { + const {code} = baseJSBundleWithLazyModuleDefinition( + '/root/foo', + [polyfill], + { + dependencies: new Map([['/root/foo', fooModule]]), + entryPoints: new Set(['/root/foo']), + transformOptions, + }, + { + asyncRequireModulePath: '', + createModuleId: () => 0, + dev: true, + getRunModuleStatement: (moduleId: number | string) => + `require(${JSON.stringify(moduleId)});`, + globalPrefix: '', + includeAsyncPaths: false, + inlineSourceMap: false, + modulesOnly: true, + processModuleFilter: () => true, + projectRoot: '/root', + runBeforeMainModule: [], + runModule: true, + serverRoot: '/root', + shouldAddToIgnoreList: () => false, + sourceMapUrl: null, + sourceUrl: null, + getSourceUrl: null, + }, + ); + + expect(code).not.toContain('code for polyfill'); + expect(code).toContain('__registerSegment(0, function defSeg0(moduleId) {'); + expect(code).toContain('var ___d = __d;'); + expect(code).toContain('case 0:'); +}); diff --git a/packages/metro/src/DeltaBundler/Serializers/baseJSBundleWithLazyModuleDefinition.js b/packages/metro/src/DeltaBundler/Serializers/baseJSBundleWithLazyModuleDefinition.js new file mode 100644 index 0000000000..7365f1ec1b --- /dev/null +++ b/packages/metro/src/DeltaBundler/Serializers/baseJSBundleWithLazyModuleDefinition.js @@ -0,0 +1,157 @@ +/** + * 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 strict-local + * @format + * @oncall react_native + */ + +import type {Module, ReadOnlyGraph, SerializerOptions} from '../types'; +import type {LazyModuleSwitchEntry} from './helpers/lazyModuleSwitch'; + +import getAppendScripts from '../../lib/getAppendScripts'; +import getSourceMapInfo from './helpers/getSourceMapInfo'; +import {isJsModule, wrapModule} from './helpers/js'; +import {appendLazyModuleSwitch} from './helpers/lazyModuleSwitch'; +import processModules from './helpers/processModules'; +import {BundleBuilder, fromRawMappings} from 'metro-source-map'; + +type Options = Readonly<{ + ...SerializerOptions, + excludeSource?: boolean, +}>; + +/** + * Serializes a bundle in which the graph's modules are placed inside a single + * segment definer -- `__registerSegment(0, function (moduleId) { switch (...) })` + * -- so that each module's `__d(...)` call runs lazily on first require instead + * of eagerly at startup. Polyfills and the require runtime (pre-modules) and the + * run-module calls (append scripts) are emitted eagerly, as usual. + * + * This is the OSS equivalent of the metro-buck "plain bundle with switch" + * output, gated behind `serializer.unstable_lazilyDefineModules`. Unlike + * `baseJSBundle` + `bundleToString`, which keep modules as independently + * addressable top-level `__d(...)` statements, the switch form wraps them all in + * one function -- so the code and source map must be assembled together (a + * `BundleBuilder`), rather than via the flat index-map path which assumes the + * plain layout. + * + * The structured graph is untouched, so deltas and HMR (which address modules + * individually via `hmrJSBundle`) are unaffected: an HMR update is still a + * top-level `__d(...)` that shadows the switch branch. The runtime materialises + * a not-yet-required module on demand via its segment definer (see + * `ensureModuleRegistered` in the require polyfill), so Fast Refresh keeps + * working with behaviour identical to an eager bundle. + */ +export default function baseJSBundleWithLazyModuleDefinition( + entryPoint: string, + preModules: ReadonlyArray>, + graph: ReadOnlyGraph<>, + options: Options, +): {code: string, map: string} { + // Assign ids up front so ordering and dependency-map ids are stable, matching + // `baseJSBundle`. + for (const module of graph.dependencies.values()) { + options.createModuleId(module.path); + } + + const excludeSource = options.excludeSource === true; + + const wrapOptions = { + createModuleId: options.createModuleId, + dev: options.dev, + includeAsyncPaths: options.includeAsyncPaths, + projectRoot: options.projectRoot, + serverRoot: options.serverRoot, + sourceUrl: options.sourceUrl, + dependencyMapReservedName: options.dependencyMapReservedName, + unstable_inlineDependencyMap: options.unstable_inlineDependencyMap, + unstable_getAsyncDependencyPath: options.unstable_getAsyncDependencyPath, + }; + + const mapOptions = { + excludeSource, + shouldAddToIgnoreList: options.shouldAddToIgnoreList, + getSourceUrl: options.getSourceUrl, + }; + + const builder = new BundleBuilder(options.sourceUrl ?? 'bundle.js'); + + const getModuleEntry = (module: Module<>): LazyModuleSwitchEntry => { + const code = wrapModule(module, wrapOptions); + const info = getSourceMapInfo(module, mapOptions); + // Per-module map in its own coordinate space; `BundleBuilder` offsets each + // section by the current output position, so the wrapping (`case N:` prefix, + // segment preamble, preceding modules) is accounted for automatically. + const map = fromRawMappings([info]).toMap(undefined, {excludeSource}); + return { + code, + map, + moduleId: options.createModuleId(module.path), + sourcePath: module.path, + }; + }; + + const appendModule = (module: Module<>): void => { + const entry = getModuleEntry(module); + builder.append(entry.code, entry.map); + }; + + // Pre-modules (polyfills + require runtime) are emitted eagerly at the top. + if (!options.modulesOnly) { + for (const module of preModules) { + if (isJsModule(module) && options.processModuleFilter(module)) { + appendModule(module); + builder.append('\n'); + } + } + } + + const modules = [...graph.dependencies.values()] + .filter(isJsModule) + .filter(options.processModuleFilter) + .sort( + (a, b) => options.createModuleId(a.path) - options.createModuleId(b.path), + ); + + builder.append('__registerSegment(0, function defSeg0(moduleId) {'); + appendLazyModuleSwitch(builder, modules.map(getModuleEntry), { + globalPrefix: options.globalPrefix, + }); + builder.append('});\n'); + + // Run-module calls (and the trailing sourceMappingURL comment) stay eager, + // after registration. + const postScripts = processModules( + getAppendScripts(entryPoint, [...preModules, ...modules], { + asyncRequireModulePath: options.asyncRequireModulePath, + createModuleId: options.createModuleId, + getRunModuleStatement: options.getRunModuleStatement, + globalPrefix: options.globalPrefix, + inlineSourceMap: options.inlineSourceMap, + runBeforeMainModule: options.runBeforeMainModule, + runModule: options.runModule, + shouldAddToIgnoreList: options.shouldAddToIgnoreList, + sourceMapUrl: options.sourceMapUrl, + sourceUrl: options.sourceUrl, + getSourceUrl: options.getSourceUrl, + }), + { + filter: options.processModuleFilter, + ...wrapOptions, + }, + ); + for (const [, code] of postScripts) { + if (code.length > 0) { + builder.append(code + '\n'); + } + } + + return { + code: builder.getCode(), + map: JSON.stringify(builder.getMap()), + }; +} diff --git a/packages/metro/src/DeltaBundler/Serializers/helpers/lazyModuleSwitch.js b/packages/metro/src/DeltaBundler/Serializers/helpers/lazyModuleSwitch.js new file mode 100644 index 0000000000..799b6f620f --- /dev/null +++ b/packages/metro/src/DeltaBundler/Serializers/helpers/lazyModuleSwitch.js @@ -0,0 +1,53 @@ +/** + * 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 strict-local + * @format + * @oncall react_native + */ + +import type {BasicSourceMap, BundleBuilder} from 'metro-source-map'; + +export type LazyModuleSwitchEntry = Readonly<{ + moduleId: number | string, + code: string, + map: ?BasicSourceMap, + sourcePath: string, +}>; + +export function appendLazyModuleSwitch( + builder: BundleBuilder, + entries: ReadonlyArray, + options: Readonly<{globalPrefix: string}>, +): void { + const modulePrefix = '_'; + + builder.append( + `var ${modulePrefix}${options.globalPrefix}__d = ${options.globalPrefix}__d;`, + ); + builder.append('switch (moduleId) {\n'); + + for (const entry of entries) { + builder + .append( + 'case ' + entry.moduleId + ':\n' + modulePrefix, + moduleAnchorMap(entry.sourcePath), + ) + .append(entry.code, entry.map) + .append('return;\n'); + } + + builder.append('default: new Error("No module found for ID " + moduleId);}'); +} + +function moduleAnchorMap(sourcePath: string): BasicSourceMap { + return { + version: 3, + sources: [sourcePath], + names: [], + mappings: 'AAAA', + }; +}