Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/metro-config/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions packages/metro-config/src/defaults/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const getDefaultValues = (projectRoot: ?string): ConfigT => ({
isThirdPartyModule: module =>
/(?:^|[/\\])node_modules[/\\]/.test(module.path),
unstable_inlineDependencyMap: false,
unstable_lazilyDefineModules: false,
},

server: {
Expand Down
6 changes: 6 additions & 0 deletions packages/metro-config/src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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:');
});
Original file line number Diff line number Diff line change
@@ -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<Module<>>,
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()),
};
}
Loading
Loading