diff --git a/packages/metro-config/src/defaults/index.js b/packages/metro-config/src/defaults/index.js index 2d7df87df2..cd39221677 100644 --- a/packages/metro-config/src/defaults/index.js +++ b/packages/metro-config/src/defaults/index.js @@ -137,6 +137,7 @@ const getDefaultValues = (projectRoot: ?string): ConfigT => ({ unstable_compactOutput: false, unstable_memoizeInlineRequires: false, unstable_workerThreads: false, + unstable_treeShake: false, }, watcher: { additionalExts, diff --git a/packages/metro-config/src/types.js b/packages/metro-config/src/types.js index 79141c4427..e608dbac63 100644 --- a/packages/metro-config/src/types.js +++ b/packages/metro-config/src/types.js @@ -156,6 +156,8 @@ type TransformerConfigT = { transformVariants: {+[name: string]: Partial}, publicPath: string, unstable_workerThreads: boolean, + /** Enable tree shaking (production only). Default: false. */ + unstable_treeShake: boolean, }; type MetalConfigT = { diff --git a/packages/metro-resolver/src/types.js b/packages/metro-resolver/src/types.js index 69e5263e00..199ffafd38 100644 --- a/packages/metro-resolver/src/types.js +++ b/packages/metro-resolver/src/types.js @@ -87,6 +87,7 @@ export type PackageJson = Readonly<{ main?: string, exports?: ExportsField, imports?: ExportsLikeMap, + sideEffects?: boolean | ReadonlyArray, ... }>; diff --git a/packages/metro-transform-worker/src/__tests__/fixtures/passthroughTransformer.js b/packages/metro-transform-worker/src/__tests__/fixtures/passthroughTransformer.js new file mode 100644 index 0000000000..75a34ebe2d --- /dev/null +++ b/packages/metro-transform-worker/src/__tests__/fixtures/passthroughTransformer.js @@ -0,0 +1,17 @@ +'use strict'; + +const parser = require('@babel/parser'); + +module.exports.transform = function ({src}) { + return { + ast: parser.parse(src, { + plugins: ['flow', 'dynamicImport'], + sourceType: 'unambiguous', + }), + metadata: {}, + }; +}; + +module.exports.getCacheKey = function () { + return 'passthrough-transformer'; +}; diff --git a/packages/metro-transform-worker/src/__tests__/index-test.js b/packages/metro-transform-worker/src/__tests__/index-test.js index 81aba4c9f0..daa8deabcc 100644 --- a/packages/metro-transform-worker/src/__tests__/index-test.js +++ b/packages/metro-transform-worker/src/__tests__/index-test.js @@ -561,3 +561,149 @@ test('allows outputting comments when `minify: true`', async () => { });" `); }); + +test('preserves ESM for tree-shake analysis and finalizes selected exports', async () => { + const passthroughTransformerPath = require.resolve( + './fixtures/passthroughTransformer', + ); + const result = await Transformer.transform( + {...baseConfig, babelTransformerPath: passthroughTransformerPath}, + '/root', + 'local/file.js', + Buffer.from( + [ + 'import {dep} from "./dep";', + 'export const foo = dep + 1;', + 'export const bar = dep + 2;', + ].join('\n'), + 'utf8', + ), + { + ...baseTransformOptions, + dev: false, + minify: false, + unstable_treeShake: true, + }, + ); + + expect(result.output[0].type).toBe('js/module'); + expect(result.output[0].data.code).toContain('export const foo'); + expect(result.output[0].data.code).toContain('export const bar'); + expect(result.output[0].data.code).not.toContain('__d(function'); + expect(result.moduleSyntax?.isESModule).toBe(true); + expect(result.dependencies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: './dep', + data: expect.objectContaining({ + importBindings: [{type: 'named', name: 'dep'}], + }), + }), + ]), + ); + + const moduleSyntax = result.moduleSyntax; + expect(moduleSyntax).toBeDefined(); + if (moduleSyntax == null) { + throw new Error('Expected moduleSyntax metadata to be present'); + } + + const finalized = await Transformer.finalizeModule( + result.output[0].data.code, + moduleSyntax, + { + usedExports: {type: 'named', names: new Set(['foo'])}, + filename: 'local/file.js', + reexportDemandBySource: {}, + dependencyMapName: '_dependencyMap', + globalPrefix: '', + minify: false, + minifierPath: 'minifyModulePath', + minifierConfig: {output: {comments: false}}, + dev: false, + eliminatedReexportSources: {}, + parserPlugins: moduleSyntax.parserPlugins, + }, + ); + + expect(finalized.code).toContain('__d(function'); + expect(finalized.code).toContain('exports.foo='); + expect(finalized.code).not.toContain('exports.bar='); +}); + +test('does not defer CJS modules when tree-shake mode is enabled', async () => { + const result = await Transformer.transform( + baseConfig, + '/root', + 'local/file.js', + Buffer.from('const x = require("./dep"); module.exports = x;', 'utf8'), + { + ...baseTransformOptions, + dev: false, + minify: false, + unstable_treeShake: true, + }, + ); + + expect(result.output[0].type).toBe('js/module'); + expect(result.output[0].data.code).toContain('__d(function'); + expect(result.output[0].data.code).toContain('_$$_REQUIRE'); + expect(result.moduleSyntax).toBeUndefined(); +}); + +test('narrows export-star during finalization when demand is proven', async () => { + const finalized = await Transformer.finalizeModule( + 'export * from "./dep";', + { + directExportNames: new Set(), + exports: [{source: './dep', type: 'reExportAll'}], + isESModule: true, + parserPlugins: ['flow'], + }, + { + usedExports: {type: 'named', names: new Set(['foo'])}, + filename: 'local/file.js', + reexportDemandBySource: {'./dep': ['foo']}, + dependencyMapName: '_dependencyMap', + globalPrefix: '', + minify: false, + minifierPath: 'minifyModulePath', + minifierConfig: {output: {comments: false}}, + dev: false, + eliminatedReexportSources: {}, + parserPlugins: ['flow'], + }, + ); + + expect(finalized.code).toContain('./dep'); + expect(finalized.code).toContain('exports.foo='); + expect(finalized.code).not.toContain('for(var _key in'); +}); + +test('keeps export-star during finalization when demand is not proven', async () => { + const finalized = await Transformer.finalizeModule( + 'export * from "./dep";', + { + directExportNames: new Set(), + exports: [{source: './dep', type: 'reExportAll'}], + isESModule: true, + parserPlugins: ['flow'], + }, + { + usedExports: {type: 'named', names: new Set(['foo'])}, + filename: 'local/file.js', + reexportDemandBySource: {}, + dependencyMapName: '_dependencyMap', + globalPrefix: '', + minify: false, + minifierPath: 'minifyModulePath', + minifierConfig: {output: {comments: false}}, + dev: false, + eliminatedReexportSources: {}, + parserPlugins: ['flow'], + }, + ); + + expect(finalized.code).toContain('./dep'); + expect(finalized.code).toContain('for(var _key in'); +}); diff --git a/packages/metro-transform-worker/src/index.js b/packages/metro-transform-worker/src/index.js index c6a86ce133..41845f5813 100644 --- a/packages/metro-transform-worker/src/index.js +++ b/packages/metro-transform-worker/src/index.js @@ -35,7 +35,7 @@ import type { import * as assetTransformer from './utils/assetTransformer'; import getMinifier from './utils/getMinifier'; -import {transformFromAstSync} from '@babel/core'; +import {transformFromAstSync, traverse} from '@babel/core'; import generate from '@babel/generator'; import * as babylon from '@babel/parser'; import * as types from '@babel/types'; @@ -110,6 +110,8 @@ export type JsTransformerConfig = Readonly<{ unstable_nonMemoizedInlineRequires?: ReadonlyArray, /** Whether to rename scoped `require` functions to `_$$_REQUIRE`, usually an extraneous operation when serializing to iife (default). */ unstable_renameRequire?: boolean, + /** Enable tree shaking (production only). Preserves ESM syntax for deferred finalization. */ + unstable_treeShake?: boolean, }>; export type {CustomTransformOptions} from 'metro-babel-transformer'; @@ -128,6 +130,8 @@ export type JsTransformOptions = Readonly<{ unstable_nonMemoizedInlineRequires?: ReadonlyArray, unstable_staticHermesOptimizedRequire?: boolean, unstable_transformProfile: TransformProfile, + /** Enable tree shaking (production only, forced false in dev). */ + unstable_treeShake?: boolean, }>; opaque type Path = string; @@ -174,9 +178,168 @@ export type JsOutput = Readonly<{ type: JSFileType, }>; +type ExportBinding = + | {type: 'named', name: string, localName: string} + | {type: 'default', localName: ?string} + | {type: 'reExportNamed', name: string, as: string, source: string} + | {type: 'reExportAll', source: string} + | {type: 'reExportNamespace', as: string, source: string}; + +export type ModuleSyntaxMeta = { + exports: ReadonlyArray, + isESModule: boolean, + directExportNames: ReadonlySet, + parserPlugins: ReadonlyArray, +}; + +function collectModuleSyntaxMeta( + ast: BabelNodeFile, + parserPlugins: ReadonlyArray, +): ModuleSyntaxMeta { + const exportBindings: Array = []; + const directExportNames: Set = new Set(); + let isESModule = false; + + traverse(ast, { + ImportDeclaration(path: $FlowFixMe) { + if ( + path.node.importKind !== 'type' && + path.node.importKind !== 'typeof' + ) { + isESModule = true; + } + }, + ExportDefaultDeclaration(path: $FlowFixMe) { + isESModule = true; + const decl = path.node.declaration; + const localName = decl.id?.name ?? null; + exportBindings.push({type: 'default', localName}); + directExportNames.add('default'); + }, + ExportNamedDeclaration(path: $FlowFixMe) { + if ( + path.node.exportKind === 'type' || + path.node.exportKind === 'typeof' + ) { + return; + } + isESModule = true; + if (path.node.source) { + for (const spec of path.node.specifiers) { + exportBindings.push({ + type: 'reExportNamed', + name: + spec.local.type === 'StringLiteral' + ? spec.local.value + : spec.local.name, + as: + spec.exported.type === 'StringLiteral' + ? spec.exported.value + : spec.exported.name, + source: path.node.source.value, + }); + } + } else if (path.node.declaration) { + const decl = path.node.declaration; + if (decl.type === 'VariableDeclaration') { + for (const declarator of decl.declarations) { + if (declarator.id.type === 'Identifier') { + exportBindings.push({ + type: 'named', + name: declarator.id.name, + localName: declarator.id.name, + }); + directExportNames.add(declarator.id.name); + } else if (declarator.id.type === 'ObjectPattern') { + for (const prop of declarator.id.properties) { + if ( + prop.type === 'ObjectProperty' && + prop.value.type === 'Identifier' + ) { + exportBindings.push({ + type: 'named', + name: prop.value.name, + localName: prop.value.name, + }); + directExportNames.add(prop.value.name); + } + } + } else if (declarator.id.type === 'ArrayPattern') { + for (const element of declarator.id.elements) { + if (element?.type === 'Identifier') { + exportBindings.push({ + type: 'named', + name: element.name, + localName: element.name, + }); + directExportNames.add(element.name); + } + } + } + } + } else if (decl.id) { + exportBindings.push({ + type: 'named', + name: decl.id.name, + localName: decl.id.name, + }); + directExportNames.add(decl.id.name); + } + } else { + for (const spec of path.node.specifiers) { + const name = + spec.exported.type === 'StringLiteral' + ? spec.exported.value + : spec.exported.name; + exportBindings.push({ + type: 'named', + name, + localName: + spec.local.type === 'StringLiteral' + ? spec.local.value + : spec.local.name, + }); + directExportNames.add(name); + } + } + }, + ExportAllDeclaration(path: $FlowFixMe) { + if (path.node.exportKind === 'type') { + return; + } + isESModule = true; + if (path.node.exported != null) { + const exportedName = + path.node.exported.type === 'StringLiteral' + ? path.node.exported.value + : path.node.exported.name; + exportBindings.push({ + type: 'reExportNamespace', + as: exportedName, + source: path.node.source.value, + }); + directExportNames.add(exportedName); + } else { + exportBindings.push({ + type: 'reExportAll', + source: path.node.source.value, + }); + } + }, + }); + + return { + exports: exportBindings, + isESModule, + directExportNames, + parserPlugins, + }; +} + type TransformResponse = Readonly<{ dependencies: ReadonlyArray, output: ReadonlyArray, + moduleSyntax?: ModuleSyntaxMeta, }>; function getDynamicDepsBehavior( @@ -292,8 +455,145 @@ async function transformJS( directives.push(types.directive(types.directiveLiteral('use strict'))); } - // Perform the import-export transform (in case it's still needed), then - // fold requires and perform constant folding (if in dev). + if ( + options.unstable_treeShake === true && + file.type !== 'js/script' && + ast.program.sourceType === 'module' + ) { + const parserPlugins = getParserPluginsForFile(file.filename); + const moduleSyntax = collectModuleSyntaxMeta(ast, parserPlugins); + + if (moduleSyntax.isESModule) { + const esmPlugins: Array = []; + + if (options.inlineRequires) { + esmPlugins.push([ + metroTransformPlugins.inlineRequiresPlugin, + { + ignoredRequires: options.nonInlinedRequires, + inlineableCalls: [importDefault, importAll], + memoizeCalls: + // $FlowFixMe[incompatible-type] is this always (?boolean)? + options.customTransformOptions?.unstable_memoizeInlineRequires ?? + options.unstable_memoizeInlineRequires, + nonMemoizedModules: options.unstable_nonMemoizedInlineRequires, + } as InlineRequiresPluginOptions, + ]); + } + + esmPlugins.push([ + metroTransformPlugins.inlinePlugin, + { + dev: options.dev, + inlinePlatform: options.inlinePlatform, + isWrapped: false, + // $FlowFixMe[incompatible-type] expects a string if inlinePlatform + platform: options.platform, + } as InlinePluginOptions, + ]); + + let esmAst = nullthrows( + transformFromAstSync(ast, '', { + ast: true, + babelrc: false, + cloneInputAst: true, + code: false, + comments: true, + configFile: false, + filename: file.filename, + plugins: esmPlugins, + sourceMaps: false, + }).ast, + ); + + if (!options.dev) { + esmAst = nullthrows( + transformFromAstSync(esmAst, '', { + ast: true, + babelrc: false, + cloneInputAst: false, + code: false, + comments: true, + configFile: false, + filename: file.filename, + plugins: [metroTransformPlugins.constantFoldingPlugin], + sourceMaps: false, + }).ast, + ); + } + + const importDeclarationLocs = file.unstable_importDeclarationLocs ?? null; + const collectOpts = { + allowOptionalDependencies: config.allowOptionalDependencies, + asyncRequireModulePath: config.asyncRequireModulePath, + dependencyMapName: config.unstable_dependencyMapReservedName, + dynamicRequires: getDynamicDepsBehavior( + config.dynamicDepsInPackages, + file.filename, + ), + inlineableCalls: [importDefault, importAll], + keepRequireNames: options.dev, + unstable_allowRequireContext: config.unstable_allowRequireContext, + unstable_isESMImportAtSource: + importDeclarationLocs != null + ? (loc: BabelSourceLocation) => + importDeclarationLocs.has(locToKey(loc)) + : null, + }; + let esmDependencies; + try { + ({ast: esmAst, dependencies: esmDependencies} = collectDependencies( + esmAst, + collectOpts, + )); + } catch (error) { + if (error instanceof InternalInvalidRequireCallError) { + throw new InvalidRequireCallError(error, file.filename); + } + throw error; + } + + const esmResult = generate( + esmAst, + { + comments: true, + compact: false, + filename: file.filename, + retainLines: false, + sourceFileName: file.filename, + sourceMaps: true, + }, + file.code, + ); + + let esmMap = esmResult.rawMappings + ? esmResult.rawMappings.map(toSegmentTuple) + : []; + const esmCode = esmResult.code; + let esmLineCount; + ({lineCount: esmLineCount, map: esmMap} = countLinesAndTerminateMap( + esmCode, + esmMap, + )); + + return { + dependencies: esmDependencies, + output: [ + { + data: { + code: esmCode, + functionMap: file.functionMap, + lineCount: esmLineCount, + map: esmMap, + }, + type: file.type, + }, + ], + moduleSyntax, + }; + } + } + const plugins: Array = []; if (options.experimentalImportSupport === true) { @@ -718,6 +1018,373 @@ export const transform = async ( return await transformJSWithBabel(file, context); }; +export type UsedExports = + | {type: 'all'} + | {type: 'named', names: Set} + | {type: 'none'}; + +export type FinalizeOptions = Readonly<{ + usedExports: UsedExports, + filename: string, + sourceMap?: ReadonlyArray, + reexportDemandBySource: {[sourceLiteral: string]: ReadonlyArray}, + dependencyMapName: string, + globalPrefix: string, + minify: boolean, + minifierPath: string, + minifierConfig: MinifierConfig, + dev: boolean, + eliminatedReexportSources: {[sourceLiteral: string]: true}, + parserPlugins: ReadonlyArray, +}>; + +export type FinalizedOutput = { + code: string, + map: Array, + lineCount: number, +}; + +function getParserPluginsForFile( + filename: string, +): ReadonlyArray { + if (filename.endsWith('.ts')) { + return ['typescript']; + } + if (filename.endsWith('.tsx')) { + return ['typescript', 'jsx']; + } + if (filename.endsWith('.jsx')) { + return ['flow', 'jsx']; + } + return ['flow', 'jsx']; +} + +/** + * Strip the `export` keyword from unused export declarations in an ESM AST. + * Conservative by design: + * - Named declarations: keep the declaration, only remove `export` keyword. + * - Re-exports with alive target: downgrade to `import 'x'` (Invariant #8). + * - Re-exports with eliminated target: remove entirely (Invariant #11). + * - `export *` is narrowed only when per-source demand is provably unambiguous. + */ +function stripUnusedExports( + ast: BabelNodeFile, + moduleSyntax: ModuleSyntaxMeta, + usedExports: UsedExports, + eliminatedReexportSources: {[sourceLiteral: string]: true}, + reexportDemandBySource: {[sourceLiteral: string]: ReadonlyArray}, +): BabelNodeFile { + const usedNames: Set = + usedExports.type === 'named' ? usedExports.names : new Set(); + + traverse(ast, { + ExportDefaultDeclaration(path: $FlowFixMe) { + if (!usedNames.has('default')) { + const decl = path.node.declaration; + if ( + decl.type === 'FunctionDeclaration' || + decl.type === 'ClassDeclaration' + ) { + if (decl.id != null) { + path.replaceWith(decl); + } else { + path.remove(); + } + } else if (decl.type === 'Identifier') { + path.remove(); + } else { + path.replaceWith(types.expressionStatement(decl)); + } + } + }, + + ExportNamedDeclaration(path: $FlowFixMe) { + if (path.node.source != null) { + const sourceName: string = path.node.source.value; + const keptSpecifiers = path.node.specifiers.filter( + (spec: $FlowFixMe) => { + const exported = + spec.exported.type === 'StringLiteral' + ? spec.exported.value + : spec.exported.name; + return usedNames.has(exported); + }, + ); + if (keptSpecifiers.length === 0) { + if (eliminatedReexportSources[sourceName] === true) { + path.remove(); + } else { + path.replaceWith(types.importDeclaration([], path.node.source)); + } + } else { + path.node.specifiers = keptSpecifiers; + } + } else if (path.node.declaration != null) { + const decl = path.node.declaration; + const names = getDeclaredNames(decl); + const anyUsed = names.some((name: string) => usedNames.has(name)); + if (!anyUsed) { + path.replaceWith(decl); + } + } else { + const keptSpecifiers = path.node.specifiers.filter( + (spec: $FlowFixMe) => { + const exported = + spec.exported.type === 'StringLiteral' + ? spec.exported.value + : spec.exported.name; + return usedNames.has(exported); + }, + ); + if (keptSpecifiers.length === 0) { + path.remove(); + } else { + path.node.specifiers = keptSpecifiers; + } + } + }, + + ExportAllDeclaration(path: $FlowFixMe) { + const sourceName: string = path.node.source.value; + if (path.node.exported != null) { + const exportedName: string = + path.node.exported.type === 'StringLiteral' + ? path.node.exported.value + : path.node.exported.name; + if (!usedNames.has(exportedName)) { + if (eliminatedReexportSources[sourceName] === true) { + path.remove(); + } else { + path.replaceWith(types.importDeclaration([], path.node.source)); + } + } + } else { + if (usedExports.type === 'named') { + const demanded = reexportDemandBySource[sourceName] ?? []; + if (demanded.length > 0) { + const safeToNarrow = demanded.filter( + (name: string) => !moduleSyntax.directExportNames.has(name), + ); + + if (safeToNarrow.length === 0) { + return; + } + + const specifiers: Array< + | BabelNodeExportSpecifier + | BabelNodeExportDefaultSpecifier + | BabelNodeExportNamespaceSpecifier, + > = []; + for (const name of safeToNarrow) { + if (!types.isValidIdentifier(name)) { + continue; + } + const id = types.identifier(name); + specifiers.push(types.exportSpecifier(id, id)); + } + if (specifiers.length === 0) { + return; + } + path.replaceWith( + types.exportNamedDeclaration( + undefined, + specifiers, + path.node.source, + ), + ); + return; + } + } + + if (usedExports.type === 'none') { + if (eliminatedReexportSources[sourceName] === true) { + path.remove(); + } else { + path.replaceWith(types.importDeclaration([], path.node.source)); + } + } + } + }, + }); + + return ast; +} + +function getDeclaredNames(decl: $FlowFixMe): Array { + const names: Array = []; + if (decl.type === 'VariableDeclaration') { + for (const declarator of decl.declarations) { + if (declarator.id.type === 'Identifier') { + names.push(declarator.id.name); + } else if (declarator.id.type === 'ObjectPattern') { + for (const prop of declarator.id.properties) { + if ( + prop.type === 'ObjectProperty' && + prop.value.type === 'Identifier' + ) { + names.push(prop.value.name); + } + } + } else if (declarator.id.type === 'ArrayPattern') { + for (const element of declarator.id.elements) { + if (element?.type === 'Identifier') { + names.push(element.name); + } + } + } + } + } else if (decl.id != null) { + names.push(decl.id.name); + } + return names; +} + +/** + * Finalize an ESM module for inclusion in the bundle: + * 1. Parse the ESM code + * 2. Strip unused exports (conservative) + * 3. Convert ESM → CJS via import-export-plugin + * 4. Wrap in __d() factory + * 5. Generate code + * 6. Minify (if requested) + */ +export async function finalizeModule( + code: string, + moduleSyntax: ModuleSyntaxMeta, + options: FinalizeOptions, +): Promise { + const parsePlugins = + options.parserPlugins.length > 0 + ? options.parserPlugins + : getParserPluginsForFile(options.filename); + // $FlowFixMe[incompatible-call] `parserPlugins` is validated upstream and may include plugin tuples accepted by Babel parser. + let ast: BabelNodeFile = babylon.parse(code, { + sourceType: 'module', + // $FlowFixMe[incompatible-type] parser plugin names/options are valid at runtime but broader than current Flow libdef literals. + plugins: [...parsePlugins], + }); + + const {importDefault, importAll} = generateImportNames(ast); + + if (options.usedExports.type !== 'all') { + ast = stripUnusedExports( + ast, + moduleSyntax, + options.usedExports, + options.eliminatedReexportSources, + options.reexportDemandBySource, + ); + } + + const transformPlugins: Array = [ + [ + metroTransformPlugins.importExportPlugin, + { + importDefault, + importAll, + resolve: false, + } as ImportExportPluginOptions, + ], + ]; + if (!options.dev) { + transformPlugins.push(metroTransformPlugins.constantFoldingPlugin); + } + + const inputSourceMap = + options.sourceMap != null + ? fromRawMappings([ + { + code, + functionMap: null, + isIgnored: false, + map: options.sourceMap, + path: options.filename, + source: code, + }, + ]).toMap(undefined, {}) + : undefined; + + ast = nullthrows( + transformFromAstSync(ast, code, { + ast: true, + babelrc: false, + cloneInputAst: false, + code: false, + comments: true, + configFile: false, + // $FlowFixMe[incompatible-type] Metro source-map shape is accepted by Babel at runtime. + inputSourceMap, + plugins: transformPlugins, + sourceMaps: true, + }).ast, + ); + + ({ast} = JsFileWrapping.wrapModule( + ast, + importDefault, + importAll, + options.dependencyMapName, + options.globalPrefix, + false, + {unstable_useStaticHermesModuleFactory: false}, + )); + + const generated = generate( + ast, + { + comments: false, + compact: true, + sourceMaps: true, + }, + code, + ); + + let map = generated.rawMappings + ? generated.rawMappings.map(toSegmentTuple) + : []; + let finalCode = generated.code; + + // Step 6: Minify + if (options.minify) { + ({map, code: finalCode} = await minifyCode( + { + // Build a minimal config-like object for minifyCode + minifierPath: options.minifierPath, + minifierConfig: options.minifierConfig, + // $FlowFixMe[incompatible-call] these fields are not used by minifyCode + assetPlugins: [], + assetRegistryPath: '', + asyncRequireModulePath: '', + babelTransformerPath: '', + dynamicDepsInPackages: 'throwAtRuntime', + enableBabelRCLookup: false, + enableBabelRuntime: false, + globalPrefix: options.globalPrefix, + hermesParser: false, + optimizationSizeLimit: Infinity, + publicPath: '', + allowOptionalDependencies: false, + unstable_dependencyMapReservedName: null, + unstable_disableModuleWrapping: false, + unstable_disableNormalizePseudoGlobals: false, + unstable_compactOutput: true, + unstable_allowRequireContext: false, + }, + '', + '', + finalCode, + code, + map, + options.dependencyMapName != null ? [options.dependencyMapName] : [], + )); + } + + let lineCount; + ({lineCount, map} = countLinesAndTerminateMap(finalCode, map)); + + return {code: finalCode, map, lineCount}; +} + export const getCacheKey = ( config: JsTransformerConfig, opts?: Readonly<{projectRoot: string, ...}>, @@ -801,4 +1468,5 @@ function countLinesAndTerminateMap( export default { getCacheKey, transform, + finalizeModule, }; diff --git a/packages/metro/package.json b/packages/metro/package.json index 2cde139d13..552a3c4d8c 100644 --- a/packages/metro/package.json +++ b/packages/metro/package.json @@ -52,6 +52,7 @@ "metro-symbolicate": "0.84.2", "metro-transform-plugins": "0.84.2", "metro-transform-worker": "0.84.2", + "micromatch": "^4.0.4", "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", diff --git a/packages/metro/src/Bundler.js b/packages/metro/src/Bundler.js index 0178235d6d..1a46ffb252 100644 --- a/packages/metro/src/Bundler.js +++ b/packages/metro/src/Bundler.js @@ -13,6 +13,11 @@ import type {TransformResultWithSource} from './DeltaBundler'; import type {TransformOptions} from './DeltaBundler/Worker'; import type EventEmitter from 'events'; import type {ConfigT} from 'metro-config'; +import type { + FinalizedOutput, + FinalizeOptions, + ModuleSyntaxMeta, +} from 'metro-transform-worker'; import Transformer from './DeltaBundler/Transformer'; import DependencyGraph from './node-haste/DependencyGraph'; @@ -83,6 +88,21 @@ export default class Bundler { ); } + async finalizeModule( + transformResultKey: string, + code: string, + moduleSyntax: ModuleSyntaxMeta, + options: FinalizeOptions, + ): Promise { + await this.ready(); + return this._transformer.finalizeModule( + transformResultKey, + code, + moduleSyntax, + options, + ); + } + // Waits for the bundler to become ready. async ready(): Promise { await this._initializedPromise; diff --git a/packages/metro/src/DeltaBundler/Graph.js b/packages/metro/src/DeltaBundler/Graph.js index 61e01283de..8c1c486d35 100644 --- a/packages/metro/src/DeltaBundler/Graph.js +++ b/packages/metro/src/DeltaBundler/Graph.js @@ -758,8 +758,15 @@ export class Graph { } _moduleSnapshot(module: Module): ModuleData { - const {dependencies, getSource, output, unstable_transformResultKey} = - module; + const { + dependencies, + getSource, + output, + unstable_transformResultKey, + moduleSyntax, + sideEffects, + sideEffectsRoot, + } = module; const resolvedContexts: Map = new Map(); for (const [key, dependency] of dependencies) { @@ -779,6 +786,9 @@ export class Graph { output, resolvedContexts, unstable_transformResultKey, + moduleSyntax, + sideEffects, + sideEffectsRoot, }; } diff --git a/packages/metro/src/DeltaBundler/Serializers/baseJSBundle.js b/packages/metro/src/DeltaBundler/Serializers/baseJSBundle.js index dcfa8297dc..35afb7a759 100644 --- a/packages/metro/src/DeltaBundler/Serializers/baseJSBundle.js +++ b/packages/metro/src/DeltaBundler/Serializers/baseJSBundle.js @@ -16,22 +16,34 @@ import type { SerializerOptions, } from '../types'; import type {Bundle} from 'metro-runtime/src/modules/types'; +import type {FinalizedOutput} from 'metro-transform-worker'; import getAppendScripts from '../../lib/getAppendScripts'; import processModules from './helpers/processModules'; +export type TreeShakeOptions = Readonly<{ + eliminable: Set, + finalizedModules: Map, +}>; + export default function baseJSBundle( entryPoint: string, preModules: ReadonlyArray>, graph: ReadOnlyGraph<>, options: SerializerOptions, + treeShakeOptions?: TreeShakeOptions, ): Bundle { for (const module of graph.dependencies.values()) { options.createModuleId(module.path); } const processModulesOptions = { - filter: options.processModuleFilter, + filter: (module: Module<>) => { + if (treeShakeOptions?.eliminable.has(module.path)) { + return false; + } + return options.processModuleFilter(module); + }, createModuleId: options.createModuleId, dev: options.dev, includeAsyncPaths: options.includeAsyncPaths, @@ -40,7 +52,6 @@ export default function baseJSBundle( sourceUrl: options.sourceUrl, }; - // Do not prepend polyfills or the require runtime when only modules are requested if (options.modulesOnly) { preModules = []; } @@ -49,13 +60,42 @@ export default function baseJSBundle( .map(([_, code]) => code) .join('\n'); - const modules = [...graph.dependencies.values()].sort( + const allModules = [...graph.dependencies.values()].sort( (a: Module, b: Module) => options.createModuleId(a.path) - options.createModuleId(b.path), ); + const modules: ReadonlyArray> = + treeShakeOptions != null && treeShakeOptions.finalizedModules.size > 0 + ? allModules.map((module: Module) => { + const finalized = treeShakeOptions.finalizedModules.get(module.path); + if (finalized == null) { + return module; + } + const newOutput = module.output.map((out: MixedOutput) => { + if (!out.type.startsWith('js/')) { + return out; + } + return { + ...out, + data: { + ...out.data, + code: finalized.code, + map: finalized.map, + lineCount: finalized.lineCount, + }, + }; + }); + return {...module, output: newOutput}; + }) + : allModules; + + const runtimeModules = modules.filter((module: Module<>) => + processModulesOptions.filter(module), + ); + const postCode = processModules( - getAppendScripts(entryPoint, [...preModules, ...modules], { + getAppendScripts(entryPoint, [...preModules, ...runtimeModules], { asyncRequireModulePath: options.asyncRequireModulePath, createModuleId: options.createModuleId, getRunModuleStatement: options.getRunModuleStatement, @@ -76,9 +116,8 @@ export default function baseJSBundle( return { pre: preCode, post: postCode, - modules: processModules( - [...graph.dependencies.values()], - processModulesOptions, - ).map(([module, code]) => [options.createModuleId(module.path), code]), + modules: processModules(modules, processModulesOptions).map( + ([module, code]) => [options.createModuleId(module.path), code], + ), }; } diff --git a/packages/metro/src/DeltaBundler/Transformer.js b/packages/metro/src/DeltaBundler/Transformer.js index 28c4a502da..c5c48a8698 100644 --- a/packages/metro/src/DeltaBundler/Transformer.js +++ b/packages/metro/src/DeltaBundler/Transformer.js @@ -12,6 +12,11 @@ import type {TransformResult, TransformResultWithSource} from '../DeltaBundler'; import type {TransformerConfig, TransformOptions} from './Worker'; import type {ConfigT} from 'metro-config'; +import type { + FinalizedOutput, + FinalizeOptions, + ModuleSyntaxMeta, +} from 'metro-transform-worker'; import {normalizePathSeparatorsToPosix} from '../lib/pathUtils'; import getTransformCacheKey from './getTransformCacheKey'; @@ -32,6 +37,7 @@ type GetOrComputeSha1Fn = string => Promise< export default class Transformer { _config: ConfigT; _cache: Cache>; + _finalizationCache: Cache; _baseHash: string; _getSha1: GetOrComputeSha1Fn; _workerFarm: WorkerFarm; @@ -44,6 +50,8 @@ export default class Transformer { this._config.watchFolders.forEach(verifyRootExists); this._cache = new Cache(config.cacheStores); + // $FlowFixMe[incompatible-type] cache stores are shared; this cache holds finalized module payloads. + this._finalizationCache = new Cache(config.cacheStores); this._getSha1 = opts.getOrComputeSha1; // Remove the transformer config params that we don't want to pass to the @@ -96,6 +104,7 @@ export default class Transformer { unstable_transformProfile, unstable_memoizeInlineRequires, unstable_nonMemoizedInlineRequires, + unstable_treeShake, ...extra } = transformerOptions; @@ -130,6 +139,7 @@ export default class Transformer { unstable_memoizeInlineRequires, unstable_nonMemoizedInlineRequires, unstable_transformProfile, + unstable_treeShake, ]); let sha1: string; @@ -198,6 +208,60 @@ export default class Transformer { }; } + async finalizeModule( + transformResultKey: string, + code: string, + moduleSyntax: ModuleSyntaxMeta, + options: FinalizeOptions, + ): Promise { + const effectiveTransformResultKey = + transformResultKey !== '' + ? transformResultKey + : crypto.createHash('sha1').update(code).digest('hex'); + const cacheKey = stableHash([ + 'finalize-module-v1', + effectiveTransformResultKey, + options.filename, + options.dependencyMapName, + options.globalPrefix, + options.minify, + options.dev, + options.minifierPath, + stableHash(options.minifierConfig).toString('hex'), + stableHash(options.parserPlugins).toString('hex'), + getUsedExportsCacheKey(options.usedExports), + getEliminatedReexportSourcesCacheKey(options.eliminatedReexportSources), + getReexportDemandCacheKey(options.reexportDemandBySource), + ]); + + try { + const cached = await this._finalizationCache.get(cacheKey); + if (cached != null) { + return cached; + } + } catch (error) { + this._config.reporter.update({ + type: 'cache_read_error', + error, + }); + } + + const finalized = await this._workerFarm.finalizeModule( + code, + moduleSyntax, + options, + ); + + this._finalizationCache.set(cacheKey, finalized).catch(error => { + this._config.reporter.update({ + type: 'cache_write_error', + error, + }); + }); + + return finalized; + } + async end(): Promise { await this._workerFarm.kill(); } @@ -207,3 +271,36 @@ function verifyRootExists(root: string): void { // Verify that the root exists. assert(fs.statSync(root).isDirectory(), 'Root has to be a valid directory'); } + +function getUsedExportsCacheKey( + usedExports: FinalizeOptions['usedExports'], +): string { + switch (usedExports.type) { + case 'all': + return 'all'; + case 'none': + return 'none'; + case 'named': + return `named:${[...usedExports.names].sort().join(',')}`; + default: + throw new Error('Unknown usedExports variant'); + } +} + +function getEliminatedReexportSourcesCacheKey( + eliminatedReexportSources: FinalizeOptions['eliminatedReexportSources'], +): string { + return Object.keys(eliminatedReexportSources).sort().join(','); +} + +function getReexportDemandCacheKey( + reexportDemandBySource: FinalizeOptions['reexportDemandBySource'], +): string { + const pieces = []; + for (const source of Object.keys(reexportDemandBySource).sort()) { + pieces.push( + `${source}:${[...reexportDemandBySource[source]].sort().join(',')}`, + ); + } + return pieces.join('|'); +} diff --git a/packages/metro/src/DeltaBundler/TreeShakeAnalysis.js b/packages/metro/src/DeltaBundler/TreeShakeAnalysis.js new file mode 100644 index 0000000000..3ec8520eee --- /dev/null +++ b/packages/metro/src/DeltaBundler/TreeShakeAnalysis.js @@ -0,0 +1,567 @@ +/** + * 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 + */ + +'use strict'; + +import type { + ImportBinding, + ReexportBinding, +} from '../ModuleGraph/worker/collectDependencies'; +import type {Module, ReadOnlyGraph, ResolvedDependency} from './types'; + +import {isResolvedDependency} from '../lib/isResolvedDependency'; + +export type UsedExports = + | {type: 'all'} + | {type: 'named', names: Set} + | {type: 'none'}; + +export type UsedExportsMap = Map; +export type ReexportDemandMap = Map< + string, + {[sourceLiteral: string]: ReadonlyArray}, +>; + +/** + * Compute the set of used exports per module and the set of modules that can + * be safely eliminated from the bundle. + * + * Uses a two-level fixpoint: + * - Inner: monotone propagation of used-export demands over the live set. + * - Outer: shrinks the live set by eliminating provably dead modules, then + * repeats until stable. + * + * Terminates because the live set only shrinks and is finite. + */ +export function analyzeAndEliminate( + graph: ReadOnlyGraph<>, + hasSideEffectsFn: (modulePath: string) => boolean, +): {usedExports: UsedExportsMap, eliminable: Set} { + const liveModules: Set = new Set(graph.dependencies.keys()); + const allEliminable: Set = new Set(); + let result: ?{usedExports: UsedExportsMap, eliminable: Set} = null; + + while (result == null) { + const usedExports = computeUsedExports(graph, liveModules); + const newlyEliminable = findEliminable( + graph, + usedExports, + liveModules, + hasSideEffectsFn, + ); + + if (newlyEliminable.size === 0) { + result = {usedExports, eliminable: allEliminable}; + break; + } + + for (const modulePath of newlyEliminable) { + liveModules.delete(modulePath); + allEliminable.add(modulePath); + } + } + + if (result == null) { + throw new Error('Expected analyzeAndEliminate result'); + } + + return result; +} + +/** + * Build the `eliminatedReexportSources` map for a single module. + * + * Maps source literal strings (as they appear in `export { x } from 'HERE'`) + * to `true` if every resolved target for that literal was eliminated. + * + * Keyed by `dep.data.name` (the AST source literal) because that is what + * `stripUnusedExports` sees in `path.node.source.value`. + */ +export function getEliminatedReexportSources( + module: Module<>, + eliminable: Set, +): {[sourceLiteral: string]: true} { + const result: {[sourceLiteral: string]: true} = {}; + const seenNames: Map = new Map(); + + for (const [, dep] of module.dependencies) { + if (!dep.data.data.reexportBindings?.length) { + continue; + } + const name = dep.data.name; + const isEliminated = + isResolvedDependency(dep) && eliminable.has(dep.absolutePath); + const existing = seenNames.get(name); + if (existing != null) { + existing.allEliminated = existing.allEliminated && isEliminated; + } else { + seenNames.set(name, {allEliminated: isEliminated}); + } + } + + for (const [name, info] of seenNames) { + if (info.allEliminated) { + // $FlowFixMe[prop-missing] + result[name] = true; + } + } + return result; +} + +export function computeReexportDemand( + graph: ReadOnlyGraph<>, + usedExports: UsedExportsMap, +): ReexportDemandMap { + const demandByModule: ReexportDemandMap = new Map(); + + for (const [modulePath, moduleUsed] of usedExports) { + if (moduleUsed.type !== 'named') { + continue; + } + const module = graph.dependencies.get(modulePath); + const moduleSyntax = module?.moduleSyntax; + if ( + module == null || + moduleSyntax == null || + moduleSyntax.isESModule !== true + ) { + continue; + } + + const starSources = moduleSyntax.exports + .filter(binding => binding.type === 'reExportAll') + .map(binding => binding.source); + if (starSources.length === 0) { + continue; + } + + const explicitlyExportedNames = new Set(moduleSyntax.directExportNames); + for (const binding of moduleSyntax.exports) { + if (binding.type === 'reExportNamed') { + explicitlyExportedNames.add(binding.as); + } + } + + const pendingNames = [...moduleUsed.names].filter( + name => name !== 'default' && !explicitlyExportedNames.has(name), + ); + if (pendingNames.length === 0) { + continue; + } + + const localDemand: Map> = new Map(); + for (const name of pendingNames) { + const providerSource = findUnambiguousStarProvider( + graph, + module, + starSources, + name, + ); + if (providerSource != null) { + const existing = localDemand.get(providerSource); + if (existing != null) { + existing.add(name); + } else { + localDemand.set(providerSource, new Set([name])); + } + } + } + + if (localDemand.size > 0) { + const objectDemand: {[sourceLiteral: string]: ReadonlyArray} = {}; + for (const [source, names] of localDemand) { + objectDemand[source] = [...names].sort(); + } + demandByModule.set(modulePath, objectDemand); + } + } + + return demandByModule; +} + +function findUnambiguousStarProvider( + graph: ReadOnlyGraph<>, + module: Module<>, + starSources: ReadonlyArray, + exportName: string, +): ?string { + const providers: Array = []; + for (const sourceLiteral of starSources) { + const dep = getResolvedReexportAllDependency(module, sourceLiteral); + if (dep == null) { + continue; + } + if ( + canModuleDefinitelyProvideName( + graph, + dep.absolutePath, + exportName, + new Set(), + ) + ) { + providers.push(sourceLiteral); + if (providers.length > 1) { + return null; + } + } + } + return providers.length === 1 ? providers[0] : null; +} + +function getResolvedReexportAllDependency( + module: Module<>, + sourceLiteral: string, +): ?ResolvedDependency { + for (const [, dep] of module.dependencies) { + if (!isResolvedDependency(dep)) { + continue; + } + if (dep.data.name !== sourceLiteral) { + continue; + } + if (!dep.data.data.reexportBindings?.some(b => b.type === 'reExportAll')) { + continue; + } + return dep; + } + return null; +} + +function computeUsedExports( + graph: ReadOnlyGraph<>, + liveModules: Set, +): UsedExportsMap { + const usedExports: UsedExportsMap = new Map(); + + for (const modulePath of liveModules) { + usedExports.set(modulePath, {type: 'none'}); + } + + let changed = true; + while (changed) { + changed = false; + + for (const modulePath of liveModules) { + const module = graph.dependencies.get(modulePath); + if (module == null) { + continue; + } + const moduleUsed = usedExports.get(modulePath); + + for (const [, dependency] of module.dependencies) { + if (!isResolvedDependency(dependency)) { + continue; + } + const depPath = dependency.absolutePath; + if (!liveModules.has(depPath)) { + continue; + } + + const depImportBindings = dependency.data.data.importBindings; + const depReexportBindings = dependency.data.data.reexportBindings; + + if (depImportBindings == null && depReexportBindings == null) { + if (!dependency.data.data.isESMImport) { + const depModule = graph.dependencies.get(depPath); + if (depModule?.moduleSyntax?.isESModule === true) { + if (usedExports.get(depPath)?.type !== 'all') { + usedExports.set(depPath, {type: 'all'}); + changed = true; + } + } + } else { + if (usedExports.get(depPath)?.type !== 'all') { + usedExports.set(depPath, {type: 'all'}); + changed = true; + } + } + continue; + } + + const currentUsed: UsedExports = usedExports.get(depPath) ?? { + type: 'none', + }; + if (currentUsed.type === 'all') { + continue; + } + + let newUsed: UsedExports = currentUsed; + + if (depImportBindings != null) { + newUsed = propagateImportBindings(newUsed, depImportBindings); + } + + if ( + depReexportBindings != null && + moduleUsed != null && + moduleUsed.type !== 'none' + ) { + newUsed = propagateReexportBindings( + newUsed, + depReexportBindings, + moduleUsed, + ); + } + + if (newUsed.type === 'all' || !usedExportsEqual(currentUsed, newUsed)) { + usedExports.set(depPath, newUsed); + changed = true; + } + } + } + } + + return usedExports; +} + +function findEliminable( + graph: ReadOnlyGraph<>, + usedExports: UsedExportsMap, + liveModules: Set, + hasSideEffectsFn: (modulePath: string) => boolean, +): Set { + const eliminable: Set = new Set(); + + for (const modulePath of liveModules) { + const module = graph.dependencies.get(modulePath); + if (module == null) { + continue; + } + + if (module.moduleSyntax?.isESModule !== true) { + continue; + } + + if (graph.entryPoints.has(modulePath)) { + continue; + } + + const used = usedExports.get(modulePath); + if (used == null || used.type !== 'none') { + continue; + } + + if (hasSideEffectsFn(modulePath)) { + continue; + } + + let hasSideEffectImport = false; + for (const consumerPath of module.inverseDependencies) { + if (!liveModules.has(consumerPath)) { + continue; + } + const consumer = graph.dependencies.get(consumerPath); + if (consumer == null) { + continue; + } + for (const [, dep] of consumer.dependencies) { + if ( + isResolvedDependency(dep) && + dep.absolutePath === modulePath && + dep.data.data.importBindings?.some(b => b.type === 'sideEffectOnly') + ) { + hasSideEffectImport = true; + break; + } + } + if (hasSideEffectImport) { + break; + } + } + if (hasSideEffectImport) { + continue; + } + + eliminable.add(modulePath); + } + + return eliminable; +} + +function propagateImportBindings( + current: UsedExports, + bindings: ReadonlyArray, +): UsedExports { + let result = current; + for (const binding of bindings) { + switch (binding.type) { + case 'namespace': + // import * as ns from 'x' → all exports used (Invariant #2) + return {type: 'all'}; + case 'default': + result = addUsedName(result, 'default'); + break; + case 'named': + result = addUsedName(result, binding.name); + break; + case 'sideEffectOnly': + // import 'x' — don't mark any export names, retention handled elsewhere + break; + default: + throw new Error('Unknown import binding type'); + } + } + return result; +} + +function propagateReexportBindings( + current: UsedExports, + bindings: ReadonlyArray, + importerUsed: UsedExports, +): UsedExports { + let result = current; + for (const binding of bindings) { + switch (binding.type) { + case 'reExportAll': + // export * from 'x' — conservative (no narrowing in v1, Invariant #9) + if (importerUsed.type === 'all') { + return {type: 'all'}; + } + if (importerUsed.type === 'named') { + for (const name of importerUsed.names) { + result = addUsedName(result, name); + } + } + break; + + case 'reExportNamespace': + // export * as ns from 'x' — if 'ns' used → all of x is used + if (importerUsed.type === 'all') { + return {type: 'all'}; + } + if ( + importerUsed.type === 'named' && + importerUsed.names.has(binding.as) + ) { + return {type: 'all'}; + } + break; + + case 'reExportNamed': + // export { foo } from 'x', export { foo as bar } from 'x', etc. + // Uniform handling: if 'as' is used from importer, 'name' is used from dep + if (importerUsed.type === 'all') { + result = addUsedName(result, binding.name); + } else if ( + importerUsed.type === 'named' && + importerUsed.names.has(binding.as) + ) { + result = addUsedName(result, binding.name); + } + break; + + default: + throw new Error('Unknown re-export binding type'); + } + } + return result; +} + +// ── Utility helpers ─────────────────────────────────────────────────────────── + +function addUsedName(current: UsedExports, name: string): UsedExports { + if (current.type === 'all') { + return current; + } + if (current.type === 'none') { + return {type: 'named', names: new Set([name])}; + } + if (current.names.has(name)) { + return current; + } + const newNames = new Set(current.names); + newNames.add(name); + return {type: 'named', names: newNames}; +} + +function usedExportsEqual(a: UsedExports, b: UsedExports): boolean { + if (a.type !== b.type) { + return false; + } + if (a.type === 'named' && b.type === 'named') { + if (a.names.size !== b.names.size) { + return false; + } + for (const name of a.names) { + if (!b.names.has(name)) { + return false; + } + } + } + return true; +} + +function canModuleDefinitelyProvideName( + graph: ReadOnlyGraph<>, + modulePath: string, + exportName: string, + visiting: Set, +): boolean { + const visitKey = `${modulePath}\0${exportName}`; + if (visiting.has(visitKey)) { + return false; + } + visiting.add(visitKey); + + const module = graph.dependencies.get(modulePath); + const moduleSyntax = module?.moduleSyntax; + if ( + module == null || + moduleSyntax == null || + moduleSyntax.isESModule !== true + ) { + visiting.delete(visitKey); + return false; + } + + if (moduleSyntax.directExportNames.has(exportName)) { + visiting.delete(visitKey); + return true; + } + for (const binding of moduleSyntax.exports) { + if (binding.type === 'reExportNamed' && binding.as === exportName) { + visiting.delete(visitKey); + return true; + } + if (binding.type === 'reExportNamespace' && binding.as === exportName) { + visiting.delete(visitKey); + return true; + } + } + + const starSources = moduleSyntax.exports + .filter(binding => binding.type === 'reExportAll') + .map(binding => binding.source); + const providers: Array = []; + for (const sourceLiteral of starSources) { + const dep = getResolvedReexportAllDependency(module, sourceLiteral); + if (dep == null) { + continue; + } + if ( + canModuleDefinitelyProvideName( + graph, + dep.absolutePath, + exportName, + visiting, + ) + ) { + providers.push(sourceLiteral); + if (providers.length > 1) { + visiting.delete(visitKey); + return false; + } + } + } + + visiting.delete(visitKey); + return providers.length === 1; +} diff --git a/packages/metro/src/DeltaBundler/Worker.flow.js b/packages/metro/src/DeltaBundler/Worker.flow.js index 9ee67f53f6..2c8738cd13 100644 --- a/packages/metro/src/DeltaBundler/Worker.flow.js +++ b/packages/metro/src/DeltaBundler/Worker.flow.js @@ -12,8 +12,11 @@ import type {TransformResult} from './types'; import type {LogEntry} from 'metro-core/private/Logger'; import type { + FinalizedOutput, + FinalizeOptions, JsTransformerConfig, JsTransformOptions, + ModuleSyntaxMeta, } from 'metro-transform-worker'; import traverse from '@babel/traverse'; @@ -90,10 +93,31 @@ export const transform = ( ); }; +export const finalizeModule = ( + code: string, + moduleSyntax: ModuleSyntaxMeta, + options: FinalizeOptions, + transformerConfig: TransformerConfig, +): Promise => { + return finalizeModuleImpl(code, moduleSyntax, options, transformerConfig); +}; + export type Worker = { +transform: typeof transform, + +finalizeModule: typeof finalizeModule, }; +async function finalizeModuleImpl( + code: string, + moduleSyntax: ModuleSyntaxMeta, + options: FinalizeOptions, + transformerConfig: TransformerConfig, +): Promise { + // eslint-disable-next-line no-useless-call + const Transformer = require.call(null, transformerConfig.transformerPath); + return Transformer.finalizeModule(code, moduleSyntax, options); +} + async function transformFile( filename: string, data: Buffer, diff --git a/packages/metro/src/DeltaBundler/WorkerFarm.js b/packages/metro/src/DeltaBundler/WorkerFarm.js index c0502139ab..1b1b89ef80 100644 --- a/packages/metro/src/DeltaBundler/WorkerFarm.js +++ b/packages/metro/src/DeltaBundler/WorkerFarm.js @@ -12,6 +12,11 @@ import type {TransformResult} from '../DeltaBundler'; import type {TransformerConfig, TransformOptions, Worker} from './Worker'; import type {ConfigT} from 'metro-config'; +import type { + FinalizedOutput, + FinalizeOptions, + ModuleSyntaxMeta, +} from 'metro-transform-worker'; import type {Readable} from 'stream'; import {Worker as JestWorker} from 'jest-worker'; @@ -42,7 +47,7 @@ export default class WorkerFarm { if (this._config.maxWorkers > 1) { const worker = this._makeFarm( absoluteWorkerPath, - ['transform'], + ['transform', 'finalizeModule'], this._config.maxWorkers, ); @@ -102,6 +107,19 @@ export default class WorkerFarm { } } + async finalizeModule( + code: string, + moduleSyntax: ModuleSyntaxMeta, + options: FinalizeOptions, + ): Promise { + return this._worker.finalizeModule( + code, + moduleSyntax, + options, + this._transformerConfig, + ); + } + _makeFarm( absoluteWorkerPath: string, exposedMethods: ReadonlyArray, diff --git a/packages/metro/src/DeltaBundler/__tests__/Graph-test.js b/packages/metro/src/DeltaBundler/__tests__/Graph-test.js index 2eb3518210..bd76396cad 100644 --- a/packages/metro/src/DeltaBundler/__tests__/Graph-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/Graph-test.js @@ -218,6 +218,7 @@ function deferred( value: Readonly<{ dependencies: ReadonlyArray, getSource: () => Buffer, + moduleSyntax?: mixed, output: ReadonlyArray, unstable_transformResultKey?: ?string, }>, diff --git a/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js b/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js index 1a59581ac8..b971546539 100644 --- a/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js @@ -60,6 +60,7 @@ describe('Transformer', function () { fs.writeFileSync('/path/to/transformer.js', ''); require('../getTransformCacheKey').mockClear(); + require('../WorkerFarm').default.prototype.finalizeModule?.mockClear(); }); test('uses new cache layers when transforming if requested to do so', async () => { @@ -214,4 +215,129 @@ describe('Transformer', function () { expect(require('../getTransformCacheKey')).not.toBeCalled(); }); + + test('reuses finalization cache for identical finalize inputs', async () => { + const store = new Map(); + const cacheStore = { + get: key => store.get(key.toString('hex')), + set: (key, value) => { + store.set(key.toString('hex'), value); + }, + }; + const transformerInstance = new Transformer( + { + ...commonOptions, + cacheStores: [cacheStore], + watchFolders, + }, + {getOrComputeSha1}, + ); + + const finalized = {code: 'c', lineCount: 1, map: []}; + require('../WorkerFarm').default.prototype.finalizeModule.mockResolvedValue( + finalized, + ); + + const moduleSyntax = { + directExportNames: new Set(), + exports: [], + isESModule: true, + parserPlugins: [], + }; + const options = { + usedExports: {type: 'named', names: new Set(['foo'])}, + filename: '/root/file.js', + reexportDemandBySource: {'./dep': ['foo']}, + dependencyMapName: '_dependencyMap', + globalPrefix: '', + minify: false, + minifierPath: 'minifyModulePath', + minifierConfig: {output: {comments: false}}, + dev: false, + eliminatedReexportSources: {}, + parserPlugins: ['flow', 'jsx'], + }; + + const first = await transformerInstance.finalizeModule( + 'transform-key-1', + 'export * from "./dep";', + moduleSyntax, + options, + ); + const second = await transformerInstance.finalizeModule( + 'transform-key-1', + 'export * from "./dep";', + moduleSyntax, + options, + ); + + expect(first).toBe(finalized); + expect(second).toBe(finalized); + expect( + require('../WorkerFarm').default.prototype.finalizeModule, + ).toHaveBeenCalledTimes(1); + }); + + test('finalization cache key includes reexport demand', async () => { + const store = new Map(); + const cacheStore = { + get: key => store.get(key.toString('hex')), + set: (key, value) => { + store.set(key.toString('hex'), value); + }, + }; + const transformerInstance = new Transformer( + { + ...commonOptions, + cacheStores: [cacheStore], + watchFolders, + }, + {getOrComputeSha1}, + ); + + require('../WorkerFarm') + .default.prototype.finalizeModule.mockResolvedValueOnce({ + code: 'a', + lineCount: 1, + map: [], + }) + .mockResolvedValueOnce({code: 'b', lineCount: 1, map: []}); + + const moduleSyntax = { + directExportNames: new Set(), + exports: [], + isESModule: true, + parserPlugins: [], + }; + + const baseOptions = { + usedExports: {type: 'named', names: new Set(['foo'])}, + filename: '/root/file.js', + dependencyMapName: '_dependencyMap', + globalPrefix: '', + minify: false, + minifierPath: 'minifyModulePath', + minifierConfig: {output: {comments: false}}, + dev: false, + eliminatedReexportSources: {}, + parserPlugins: ['flow', 'jsx'], + }; + + await transformerInstance.finalizeModule( + 'transform-key-2', + 'export * from "./dep";', + moduleSyntax, + {...baseOptions, reexportDemandBySource: {'./dep': ['foo']}}, + ); + await transformerInstance.finalizeModule( + 'transform-key-2', + 'export * from "./dep";', + moduleSyntax, + {...baseOptions, reexportDemandBySource: {'./dep': ['bar']}}, + ); + + expect( + require('../WorkerFarm').default.prototype.finalizeModule, + ).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/metro/src/DeltaBundler/__tests__/TreeShakeAnalysis-test.js b/packages/metro/src/DeltaBundler/__tests__/TreeShakeAnalysis-test.js new file mode 100644 index 0000000000..67d5bf04c3 --- /dev/null +++ b/packages/metro/src/DeltaBundler/__tests__/TreeShakeAnalysis-test.js @@ -0,0 +1,585 @@ +/** + * 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 + */ + +'use strict'; + +import type { + ImportBinding, + ReexportBinding, +} from '../../ModuleGraph/worker/collectDependencies'; +import type {ReadonlySourceLocation} from '../../shared/types'; +import type {UsedExports, UsedExportsMap} from '../TreeShakeAnalysis'; +import type { + Dependency, + Module, + ReadOnlyGraph, + ResolvedDependency, +} from '../types'; + +import CountingSet from '../../lib/CountingSet'; +import { + analyzeAndEliminate, + computeReexportDemand, + getEliminatedReexportSources, +} from '../TreeShakeAnalysis'; + +type ExportBinding = + | {type: 'named', name: string, localName: string} + | {type: 'default', localName: ?string} + | {type: 'reExportNamed', name: string, as: string, source: string} + | {type: 'reExportAll', source: string} + | {type: 'reExportNamespace', as: string, source: string}; + +type DependencyDataForTest = { + asyncType: null, + isESMImport: boolean, + key: string, + locs: Array, + importBindings?: ReadonlyArray, + reexportBindings?: ReadonlyArray, +}; + +function usedNamed(names: Array): UsedExports { + const used: UsedExports = {type: 'named', names: new Set(names)}; + return used; +} + +function usedNone(): UsedExports { + const used: UsedExports = {type: 'none'}; + return used; +} + +function makeResolvedDependency({ + name, + absolutePath, + importBindings, + isESMImport = true, + reexportBindings, +}: { + name: string, + absolutePath: string, + importBindings?: ReadonlyArray, + isESMImport?: boolean, + reexportBindings?: ReadonlyArray, +}): ResolvedDependency { + const data: DependencyDataForTest = { + asyncType: null, + isESMImport, + key: name, + locs: [], + }; + if (importBindings != null) { + data.importBindings = importBindings; + } + if (reexportBindings != null) { + data.reexportBindings = reexportBindings; + } + + return { + absolutePath, + data: { + name, + data, + }, + }; +} + +function makeGraph({ + entryPoints, + modules, +}: { + entryPoints: Array, + modules: Array<[string, Module<>]>, +}): ReadOnlyGraph<> { + return { + dependencies: new Map(modules), + entryPoints: new Set(entryPoints), + transformOptions: { + dev: false, + minify: false, + platform: null, + type: 'module', + unstable_transformProfile: 'default', + }, + }; +} + +function makeResolvedReexportAllDependency( + name: string, + absolutePath: string, +): ResolvedDependency { + const reexportBindings: ReadonlyArray = [ + {type: 'reExportAll'}, + ]; + const data: DependencyDataForTest = { + asyncType: null, + isESMImport: true, + key: name, + locs: [], + reexportBindings, + }; + + return { + absolutePath, + data: { + name, + data, + }, + }; +} + +function makeModule( + path: string, + { + deps = [], + directExportNames = [], + exports = [], + inverseDeps = [], + isESModule = true, + }: { + deps?: Array<[string, Dependency]>, + directExportNames?: Array, + exports?: Array, + inverseDeps?: Array, + isESModule?: boolean, + } = {}, +): Module<> { + return { + dependencies: new Map(deps), + inverseDependencies: new CountingSet(inverseDeps), + output: [], + path, + getSource: () => Buffer.from(''), + moduleSyntax: { + directExportNames: new Set(directExportNames), + exports, + isESModule, + parserPlugins: ['flow'], + }, + }; +} + +describe('analyzeAndEliminate', () => { + test('marks used names from import bindings', () => { + const entryPath = '/entry.js'; + const depPath = '/dep.js'; + const graph = makeGraph({ + entryPoints: [entryPath], + modules: [ + [ + entryPath, + makeModule(entryPath, { + deps: [ + [ + 'dep', + makeResolvedDependency({ + absolutePath: depPath, + importBindings: [{name: 'foo', type: 'named'}], + name: './dep', + }), + ], + ], + }), + ], + [depPath, makeModule(depPath, {directExportNames: ['foo']})], + ], + }); + + const {eliminable, usedExports} = analyzeAndEliminate(graph, () => false); + + expect(usedExports.get(depPath)).toEqual({ + names: new Set(['foo']), + type: 'named', + }); + expect(eliminable.has(depPath)).toBe(false); + }); + + test('keeps side-effect-only imports alive', () => { + const entryPath = '/entry.js'; + const depPath = '/dep.js'; + const graph = makeGraph({ + entryPoints: [entryPath], + modules: [ + [ + entryPath, + makeModule(entryPath, { + deps: [ + [ + 'dep', + makeResolvedDependency({ + absolutePath: depPath, + importBindings: [{type: 'sideEffectOnly'}], + name: './dep', + }), + ], + ], + }), + ], + [depPath, makeModule(depPath, {inverseDeps: [entryPath]})], + ], + }); + + const {eliminable, usedExports} = analyzeAndEliminate(graph, () => false); + + expect(usedExports.get(depPath)).toEqual({type: 'none'}); + expect(eliminable.has(depPath)).toBe(false); + }); + + test('eliminates unused side-effect-free ESM modules', () => { + const entryPath = '/entry.js'; + const deadPath = '/dead.js'; + const graph = makeGraph({ + entryPoints: [entryPath], + modules: [ + [entryPath, makeModule(entryPath)], + [deadPath, makeModule(deadPath)], + ], + }); + + const {eliminable} = analyzeAndEliminate(graph, () => false); + expect(eliminable.has(deadPath)).toBe(true); + }); + + test('does not eliminate modules that have side effects', () => { + const entryPath = '/entry.js'; + const sideEffectPath = '/side-effect.js'; + const graph = makeGraph({ + entryPoints: [entryPath], + modules: [ + [entryPath, makeModule(entryPath)], + [sideEffectPath, makeModule(sideEffectPath)], + ], + }); + + const {eliminable} = analyzeAndEliminate( + graph, + modulePath => modulePath === sideEffectPath, + ); + expect(eliminable.has(sideEffectPath)).toBe(false); + }); + + test('sideEffects=true keeps an otherwise-dead module', () => { + const entryPath = '/entry.js'; + const modulePath = '/pkg/with-effects.js'; + const graph = makeGraph({ + entryPoints: [entryPath], + modules: [ + [entryPath, makeModule(entryPath)], + [modulePath, makeModule(modulePath)], + ], + }); + + const sideEffectsByPath = new Map([[modulePath, true]]); + const {eliminable} = analyzeAndEliminate( + graph, + modulePathArg => sideEffectsByPath.get(modulePathArg) ?? true, + ); + + expect(eliminable.has(modulePath)).toBe(false); + }); + + test('sideEffects=false allows eliminating an otherwise-dead module', () => { + const entryPath = '/entry.js'; + const modulePath = '/pkg/no-effects.js'; + const graph = makeGraph({ + entryPoints: [entryPath], + modules: [ + [entryPath, makeModule(entryPath)], + [modulePath, makeModule(modulePath)], + ], + }); + + const sideEffectsByPath = new Map([[modulePath, false]]); + const {eliminable} = analyzeAndEliminate( + graph, + modulePathArg => sideEffectsByPath.get(modulePathArg) ?? true, + ); + + expect(eliminable.has(modulePath)).toBe(true); + }); + + test('missing sideEffects metadata is conservative (module kept)', () => { + const entryPath = '/entry.js'; + const modulePath = '/pkg/unknown.js'; + const graph = makeGraph({ + entryPoints: [entryPath], + modules: [ + [entryPath, makeModule(entryPath)], + [modulePath, makeModule(modulePath)], + ], + }); + + const {eliminable} = analyzeAndEliminate(graph, () => true); + + expect(eliminable.has(modulePath)).toBe(false); + }); + + test('escalates CJS require without bindings to all for ESM target', () => { + const entryPath = '/entry.js'; + const depPath = '/dep.js'; + const graph = makeGraph({ + entryPoints: [entryPath], + modules: [ + [ + entryPath, + makeModule(entryPath, { + deps: [ + [ + 'dep', + makeResolvedDependency({ + absolutePath: depPath, + isESMImport: false, + name: './dep', + }), + ], + ], + }), + ], + [depPath, makeModule(depPath, {directExportNames: ['foo']})], + ], + }); + + const {usedExports} = analyzeAndEliminate(graph, () => false); + expect(usedExports.get(depPath)).toEqual({type: 'all'}); + }); + + test('propagates re-export usage through barrel modules', () => { + const entryPath = '/entry.js'; + const barrelPath = '/barrel.js'; + const sourcePath = '/source.js'; + const graph = makeGraph({ + entryPoints: [entryPath], + modules: [ + [ + entryPath, + makeModule(entryPath, { + deps: [ + [ + 'barrel', + makeResolvedDependency({ + absolutePath: barrelPath, + importBindings: [{name: 'bar', type: 'named'}], + name: './barrel', + }), + ], + ], + }), + ], + [ + barrelPath, + makeModule(barrelPath, { + deps: [ + [ + 'source', + makeResolvedDependency({ + absolutePath: sourcePath, + name: './source', + reexportBindings: [ + {as: 'bar', name: 'foo', type: 'reExportNamed'}, + ], + }), + ], + ], + }), + ], + [sourcePath, makeModule(sourcePath, {directExportNames: ['foo']})], + ], + }); + + const {usedExports} = analyzeAndEliminate(graph, () => false); + expect(usedExports.get(sourcePath)).toEqual({ + names: new Set(['foo']), + type: 'named', + }); + }); +}); + +describe('getEliminatedReexportSources', () => { + test('marks source literal only when all resolved targets are eliminated', () => { + const module = makeModule('/barrel.js', { + deps: [ + [ + 'a1', + makeResolvedDependency({ + absolutePath: '/a1.js', + name: './a', + reexportBindings: [{as: 'a', name: 'a', type: 'reExportNamed'}], + }), + ], + [ + 'a2', + makeResolvedDependency({ + absolutePath: '/a2.js', + name: './a', + reexportBindings: [{as: 'a', name: 'a', type: 'reExportNamed'}], + }), + ], + [ + 'b', + makeResolvedDependency({ + absolutePath: '/b.js', + name: './b', + reexportBindings: [{as: 'b', name: 'b', type: 'reExportNamed'}], + }), + ], + ], + }); + + expect( + getEliminatedReexportSources(module, new Set(['/a1.js', '/a2.js'])), + ).toEqual({'./a': true}); + }); +}); + +describe('computeReexportDemand', () => { + test('records demand only for unambiguous export-star providers', () => { + const barrelPath = '/barrel.js'; + const aPath = '/a.js'; + const bPath = '/b.js'; + + const graph = makeGraph({ + entryPoints: [], + modules: [ + [ + barrelPath, + makeModule(barrelPath, { + deps: [ + ['a', makeResolvedReexportAllDependency('./a', aPath)], + ['b', makeResolvedReexportAllDependency('./b', bPath)], + ], + directExportNames: ['foo'], + exports: [ + {source: './a', type: 'reExportAll'}, + {source: './b', type: 'reExportAll'}, + ], + }), + ], + [ + aPath, + makeModule(aPath, { + directExportNames: ['bar'], + exports: [{localName: 'bar', name: 'bar', type: 'named'}], + }), + ], + [ + bPath, + makeModule(bPath, { + directExportNames: ['baz'], + exports: [{localName: 'baz', name: 'baz', type: 'named'}], + }), + ], + ], + }); + + const usedExports: UsedExportsMap = new Map(); + usedExports.set(barrelPath, usedNamed(['default', 'foo', 'bar'])); + usedExports.set(aPath, usedNone()); + usedExports.set(bPath, usedNone()); + + expect(computeReexportDemand(graph, usedExports)).toEqual( + new Map([[barrelPath, {'./a': ['bar']}]]), + ); + }); + + test('skips demand when export-star attribution is ambiguous', () => { + const barrelPath = '/barrel.js'; + const aPath = '/a.js'; + const bPath = '/b.js'; + + const graph = makeGraph({ + entryPoints: [], + modules: [ + [ + barrelPath, + makeModule(barrelPath, { + deps: [ + ['a', makeResolvedReexportAllDependency('./a', aPath)], + ['b', makeResolvedReexportAllDependency('./b', bPath)], + ], + exports: [ + {source: './a', type: 'reExportAll'}, + {source: './b', type: 'reExportAll'}, + ], + }), + ], + [ + aPath, + makeModule(aPath, { + directExportNames: ['bar'], + exports: [{localName: 'bar', name: 'bar', type: 'named'}], + }), + ], + [ + bPath, + makeModule(bPath, { + directExportNames: ['bar'], + exports: [{localName: 'bar', name: 'bar', type: 'named'}], + }), + ], + ], + }); + + const usedExports: UsedExportsMap = new Map(); + usedExports.set(barrelPath, usedNamed(['bar'])); + usedExports.set(aPath, usedNone()); + usedExports.set(bPath, usedNone()); + + expect( + computeReexportDemand(graph, usedExports).get(barrelPath), + ).toBeUndefined(); + }); + + test('supports unambiguous nested export-star attribution', () => { + const barrelPath = '/barrel.js'; + const midPath = '/mid.js'; + const leafPath = '/leaf.js'; + + const graph = makeGraph({ + entryPoints: [], + modules: [ + [ + barrelPath, + makeModule(barrelPath, { + deps: [ + ['mid', makeResolvedReexportAllDependency('./mid', midPath)], + ], + exports: [{source: './mid', type: 'reExportAll'}], + }), + ], + [ + midPath, + makeModule(midPath, { + deps: [ + ['leaf', makeResolvedReexportAllDependency('./leaf', leafPath)], + ], + exports: [{source: './leaf', type: 'reExportAll'}], + }), + ], + [ + leafPath, + makeModule(leafPath, { + directExportNames: ['foo'], + exports: [{localName: 'foo', name: 'foo', type: 'named'}], + }), + ], + ], + }); + + const usedExports: UsedExportsMap = new Map(); + usedExports.set(barrelPath, usedNamed(['foo'])); + usedExports.set(midPath, usedNone()); + usedExports.set(leafPath, usedNone()); + + expect(computeReexportDemand(graph, usedExports)).toEqual( + new Map([[barrelPath, {'./mid': ['foo']}]]), + ); + }); +}); diff --git a/packages/metro/src/DeltaBundler/buildSubgraph.js b/packages/metro/src/DeltaBundler/buildSubgraph.js index 210a631b48..06bfe79b16 100644 --- a/packages/metro/src/DeltaBundler/buildSubgraph.js +++ b/packages/metro/src/DeltaBundler/buildSubgraph.js @@ -69,10 +69,23 @@ function resolveDependencies( }; } else { try { - maybeResolvedDep = { - absolutePath: resolve(parentPath, dep).filePath, + const resolution = resolve(parentPath, dep); + const resolvedDep: { + absolutePath: string, + data: TransformResultDependency, + sideEffects?: boolean | ReadonlyArray, + sideEffectsRoot?: string, + } = { + absolutePath: resolution.filePath, data: dep, }; + if (resolution.sideEffects != null) { + resolvedDep.sideEffects = resolution.sideEffects; + } + if (resolution.sideEffectsRoot != null) { + resolvedDep.sideEffectsRoot = resolution.sideEffectsRoot; + } + maybeResolvedDep = resolvedDep; } catch (error) { // Ignore unavailable optional dependencies. They are guarded // with a try-catch block and will be handled during runtime. @@ -114,6 +127,8 @@ export async function buildSubgraph( async function visit( absolutePath: string, requireContext: ?RequireContext, + sideEffects?: boolean | ReadonlyArray, + sideEffectsRoot?: string, ): Promise { if (visitedPaths.has(absolutePath)) { return; @@ -129,10 +144,24 @@ export async function buildSubgraph( resolve, ); - moduleData.set(absolutePath, { + let moduleRecord: ModuleData = { ...transformResult, ...resolutionResult, - }); + }; + if (sideEffects != null) { + moduleRecord = { + ...moduleRecord, + sideEffects, + }; + } + if (sideEffectsRoot != null) { + moduleRecord = { + ...moduleRecord, + sideEffectsRoot, + }; + } + + moduleData.set(absolutePath, moduleRecord); await Promise.all( [...resolutionResult.dependencies.values()] @@ -144,6 +173,8 @@ export async function buildSubgraph( visit( dependency.absolutePath, resolutionResult.resolvedContexts.get(dependency.data.data.key), + dependency.sideEffects, + dependency.sideEffectsRoot, ).catch(error => errors.set(dependency.absolutePath, error)), ), ); diff --git a/packages/metro/src/DeltaBundler/types.js b/packages/metro/src/DeltaBundler/types.js index d819ce149c..1aaa043742 100644 --- a/packages/metro/src/DeltaBundler/types.js +++ b/packages/metro/src/DeltaBundler/types.js @@ -10,13 +10,22 @@ */ import type {RequireContext} from '../lib/contextModule'; -import type {RequireContextParams} from '../ModuleGraph/worker/collectDependencies'; +import type { + ImportBinding, + ReexportBinding, + RequireContextParams, +} from '../ModuleGraph/worker/collectDependencies'; import type {ReadonlySourceLocation} from '../shared/types'; import type {Graph} from './Graph'; -import type {JsTransformOptions} from 'metro-transform-worker'; +import type { + JsTransformOptions, + ModuleSyntaxMeta, +} from 'metro-transform-worker'; import CountingSet from '../lib/CountingSet'; +export type {ImportBinding, ReexportBinding, ModuleSyntaxMeta}; + export type MixedOutput = { +data: unknown, +type: string, @@ -57,12 +66,18 @@ export type TransformResultDependency = Readonly<{ /** Context for requiring a collection of modules. */ contextParams?: RequireContextParams, + /** Import bindings from ImportDeclaration (tree shaking metadata). */ + importBindings?: ReadonlyArray, + /** Re-export bindings from export-from declarations (tree shaking metadata). */ + reexportBindings?: ReadonlyArray, }>, }>; export type ResolvedDependency = Readonly<{ absolutePath: string, data: TransformResultDependency, + sideEffects?: boolean | ReadonlyArray, + sideEffectsRoot?: string, }>; export type Dependency = @@ -78,6 +93,9 @@ export type Module = Readonly<{ path: string, getSource: () => Buffer, unstable_transformResultKey?: ?string, + moduleSyntax?: ModuleSyntaxMeta, + sideEffects?: boolean | ReadonlyArray, + sideEffectsRoot?: string, }>; export type ModuleData = Readonly<{ @@ -86,6 +104,9 @@ export type ModuleData = Readonly<{ output: ReadonlyArray, getSource: () => Buffer, unstable_transformResultKey?: ?string, + moduleSyntax?: ModuleSyntaxMeta, + sideEffects?: boolean | ReadonlyArray, + sideEffectsRoot?: string, }>; export type Dependencies = Map>; @@ -118,6 +139,7 @@ export type TransformResult = Readonly<{ dependencies: ReadonlyArray, output: ReadonlyArray, unstable_transformResultKey?: ?string, + moduleSyntax?: ModuleSyntaxMeta, }>; export type TransformResultWithSource = Readonly<{ @@ -145,6 +167,8 @@ export type AllowOptionalDependencies = export type BundlerResolution = Readonly<{ type: 'sourceFile', filePath: string, + sideEffects?: boolean | ReadonlyArray, + sideEffectsRoot?: string, }>; export type Options = Readonly<{ diff --git a/packages/metro/src/ModuleGraph/worker/__tests__/collectDependencies-test.js b/packages/metro/src/ModuleGraph/worker/__tests__/collectDependencies-test.js index a04439a5b3..7082c1098c 100644 --- a/packages/metro/src/ModuleGraph/worker/__tests__/collectDependencies-test.js +++ b/packages/metro/src/ModuleGraph/worker/__tests__/collectDependencies-test.js @@ -1177,9 +1177,20 @@ test('collects imports', () => { const {dependencies} = collectDependencies(ast, opts); expect(dependencies).toEqual([ - {name: 'b/lib/a', data: objectContaining({asyncType: null})}, - {name: 'do', data: objectContaining({asyncType: null})}, - {name: 'setup/something', data: objectContaining({asyncType: null})}, + { + name: 'b/lib/a', + data: objectContaining({ + asyncType: null, + importBindings: [{type: 'default'}], + }), + }, + { + name: 'do', + data: objectContaining({ + asyncType: null, + importBindings: [{type: 'namespace'}], + }), + }, ]); }); @@ -1192,12 +1203,145 @@ test('collects export from', () => { const {dependencies} = collectDependencies(ast, opts); expect(dependencies).toEqual([ - {name: 'Apple', data: objectContaining({asyncType: null})}, - {name: 'Banana', data: objectContaining({asyncType: null})}, - {name: 'Kiwi', data: objectContaining({asyncType: null})}, + { + name: 'Banana', + data: objectContaining({ + asyncType: null, + reexportBindings: [ + {type: 'reExportNamed', name: 'Banana', as: 'Banana'}, + ], + }), + }, + { + name: 'Kiwi', + data: objectContaining({ + asyncType: null, + reexportBindings: [{type: 'reExportAll'}], + }), + }, ]); }); +describe('tree-shaking binding extraction', () => { + test('extracts import binding variants and skips type-only imports', () => { + const ast = astFromCode(` + import foo from 'def'; + import * as ns from 'ns'; + import {a} from 'named'; + import {a as b} from 'aliased'; + import {default as d} from 'def-as-named'; + import 'side'; + import type {T} from 'type-only'; + import {type U, val} from 'mixed'; + `); + + const {dependencies} = collectDependencies(ast, opts); + expect(dependencies).toEqual([ + { + name: 'def', + data: objectContaining({importBindings: [{type: 'default'}]}), + }, + { + name: 'ns', + data: objectContaining({importBindings: [{type: 'namespace'}]}), + }, + { + name: 'named', + data: objectContaining({importBindings: [{type: 'named', name: 'a'}]}), + }, + { + name: 'aliased', + data: objectContaining({ + importBindings: [{type: 'named', name: 'a', as: 'b'}], + }), + }, + { + name: 'def-as-named', + data: objectContaining({importBindings: [{type: 'default'}]}), + }, + { + name: 'side', + data: objectContaining({importBindings: [{type: 'sideEffectOnly'}]}), + }, + { + name: 'mixed', + data: objectContaining({ + importBindings: [{type: 'named', name: 'val'}], + }), + }, + ]); + expect(dependencies.some(dep => dep.name === 'type-only')).toBe(false); + }); + + test('extracts export-from variants and skips type-only export-from', () => { + const ast = astFromCode(` + export {foo as foo1} from 'foo'; + export {foo as bar} from 'foo-as-bar'; + export {default as baz} from 'default-as-baz'; + export * from 'star'; + export * as ns from 'star-ns'; + export type {T} from 'type-export'; + export {} from 'empty'; + `); + + const {dependencies} = collectDependencies(ast, opts); + expect(dependencies).toEqual([ + { + name: 'foo', + data: objectContaining({ + reexportBindings: [{type: 'reExportNamed', name: 'foo', as: 'foo1'}], + }), + }, + { + name: 'foo-as-bar', + data: objectContaining({ + reexportBindings: [{type: 'reExportNamed', name: 'foo', as: 'bar'}], + }), + }, + { + name: 'default-as-baz', + data: objectContaining({ + reexportBindings: [ + {type: 'reExportNamed', name: 'default', as: 'baz'}, + ], + }), + }, + { + name: 'star', + data: objectContaining({reexportBindings: [{type: 'reExportAll'}]}), + }, + { + name: 'star-ns', + data: objectContaining({ + reexportBindings: [{type: 'reExportNamespace', as: 'ns'}], + }), + }, + { + name: 'empty', + data: objectContaining({importBindings: [{type: 'sideEffectOnly'}]}), + }, + ]); + expect(dependencies.some(dep => dep.name === 'type-export')).toBe(false); + }); + + test('merges import and re-export metadata for same module', () => { + const ast = astFromCode(` + import {x} from 'B'; + export {y} from 'B'; + `); + + const {dependencies} = collectDependencies(ast, opts); + expect(dependencies).toHaveLength(1); + expect(dependencies[0]).toEqual({ + name: 'B', + data: objectContaining({ + importBindings: [{type: 'named', name: 'x'}], + reexportBindings: [{type: 'reExportNamed', name: 'y', as: 'y'}], + }), + }); + }); +}); + test('records locations of dependencies', () => { const code = dedent` import b from 'b/lib/a'; @@ -1230,20 +1374,18 @@ test('records locations of dependencies', () => { | ^^^^^^^^^^^^^^^^^^^^^^^^ dep #0 (b/lib/a) > 2 | import * as d from 'do'; | ^^^^^^^^^^^^^^^^^^^^^^^^ dep #1 (do) - > 3 | import type {s} from 'setup/something'; - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ dep #2 (setup/something) > 4 | import('some/async/module').then(foo => {}); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ dep #3 (some/async/module) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ dep #2 (some/async/module) > 4 | import('some/async/module').then(foo => {}); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ dep #4 (asyncRequire) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ dep #3 (asyncRequire) > 8 | require('foo'); __prefetchImport('baz'); - | ^^^^^^^^^^^^^^^^^^^^^^^^ dep #4 (asyncRequire) + | ^^^^^^^^^^^^^^^^^^^^^^^^ dep #3 (asyncRequire) > 8 | require('foo'); __prefetchImport('baz'); - | ^^^^^^^^^^^^^^ dep #5 (foo) + | ^^^^^^^^^^^^^^ dep #4 (foo) > 8 | require('foo'); __prefetchImport('baz'); - | ^^^^^^^^^^^^^^^^^^^^^^^ dep #6 (baz) + | ^^^^^^^^^^^^^^^^^^^^^^^ dep #5 (baz) > 9 | interopRequireDefault(require('quux')); // Simulated Babel output - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ dep #7 (quux)" + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ dep #6 (quux)" `); }); diff --git a/packages/metro/src/ModuleGraph/worker/collectDependencies.js b/packages/metro/src/ModuleGraph/worker/collectDependencies.js index 2cf744949f..f3c71d7db5 100644 --- a/packages/metro/src/ModuleGraph/worker/collectDependencies.js +++ b/packages/metro/src/ModuleGraph/worker/collectDependencies.js @@ -54,6 +54,17 @@ export type RequireContextParams = Readonly<{ mode: ContextMode, }>; +export type ImportBinding = + | {type: 'default'} + | {type: 'named', name: string, as?: string} + | {type: 'namespace'} + | {type: 'sideEffectOnly'}; + +export type ReexportBinding = + | {type: 'reExportNamed', name: string, as: string} + | {type: 'reExportAll'} + | {type: 'reExportNamespace', as: string}; + type DependencyData = Readonly<{ // A locally unique key for this dependency within the current module. key: string, @@ -69,11 +80,17 @@ type DependencyData = Readonly<{ locs: ReadonlyArray, /** Context for requiring a collection of modules. */ contextParams?: RequireContextParams, + /** Import bindings from ImportDeclaration (tree shaking). */ + importBindings?: ReadonlyArray, + /** Re-export bindings from export-from declarations (tree shaking). */ + reexportBindings?: ReadonlyArray, }>; export type MutableInternalDependency = { ...DependencyData, locs: Array, + importBindings: Array, + reexportBindings: Array, index: number, name: string, }; @@ -296,10 +313,33 @@ export default function collectDependencies( // Compute the list of dependencies. const dependencies = new Array(collectedDependencies.length); - for (const {index, name, ...dependencyData} of collectedDependencies) { + for (const { + index, + name, + importBindings, + reexportBindings, + ...dependencyData + } of collectedDependencies) { + const data: { + key: string, + asyncType: AsyncDependencyType | null, + isESMImport: boolean, + isOptional?: boolean, + locs: ReadonlyArray, + contextParams?: RequireContextParams, + importBindings?: ReadonlyArray, + reexportBindings?: ReadonlyArray, + } = {...dependencyData}; + // Only attach binding arrays when non-empty (tree-shaking metadata) + if (importBindings.length > 0) { + data.importBindings = importBindings; + } + if (reexportBindings.length > 0) { + data.reexportBindings = reexportBindings; + } dependencies[index] = { name, - data: dependencyData, + data, }; } @@ -473,13 +513,112 @@ function collectImports(path: NodePath<>, state: State): void { See: https://github.com/facebook/metro/pull/1343`, ); + const name = path.node.source.value; + const importBindings: Array = []; + const reexportBindings: Array = []; + + if (path.isImportDeclaration()) { + const specifierNodes = path.node.specifiers ?? []; + // Skip type-only imports (Flow/TypeScript) — no runtime dependency + if ( + path.node.importKind === 'type' || + path.node.importKind === 'typeof' + ) { + return; + } + // Filter out inline type specifiers: import { type T, value } from 'x' + const specifiers = specifierNodes.filter( + spec => + spec.type !== 'ImportSpecifier' || + (spec.importKind !== 'type' && spec.importKind !== 'typeof'), + ); + // If ALL specifiers were type-only, skip the entire dependency + if (specifierNodes.length > 0 && specifiers.length === 0) { + return; + } + if (specifiers.length === 0) { + importBindings.push({type: 'sideEffectOnly'}); + } + for (const spec of specifiers) { + if (spec.type === 'ImportDefaultSpecifier') { + importBindings.push({type: 'default'}); + } else if (spec.type === 'ImportNamespaceSpecifier') { + importBindings.push({type: 'namespace'}); + } else if (spec.type === 'ImportSpecifier') { + const imported = + spec.imported.type === 'StringLiteral' + ? spec.imported.value + : spec.imported.name; + if (imported === 'default') { + importBindings.push({type: 'default'}); + } else if (imported !== spec.local.name) { + importBindings.push({ + type: 'named', + name: imported, + as: spec.local.name, + }); + } else { + importBindings.push({type: 'named', name: imported}); + } + } + } + } else if (path.isExportAllDeclaration()) { + // Skip type-only: `export type * from 'x'` (TypeScript 5.0+) + if (path.node.exportKind === 'type') { + return; + } + if (path.node.exported != null) { + // export * as ns from 'x' (ES2020) + const exportedName = + path.node.exported.type === 'StringLiteral' + ? path.node.exported.value + : path.node.exported.name; + reexportBindings.push({type: 'reExportNamespace', as: exportedName}); + } else { + reexportBindings.push({type: 'reExportAll'}); + } + } else if (path.isExportNamedDeclaration()) { + const specifiers = path.node.specifiers ?? []; + // Skip type-only re-exports + if (path.node.exportKind === 'type') { + return; + } + // `export {} from 'x'` — side-effect import (forces module evaluation) + if (specifiers.length === 0) { + importBindings.push({type: 'sideEffectOnly'}); + } + for (const spec of specifiers) { + if (spec.type === 'ExportNamespaceSpecifier') { + const as = spec.exported.name; + reexportBindings.push({type: 'reExportNamespace', as}); + continue; + } + if (spec.type !== 'ExportSpecifier') { + continue; + } + const exported = + spec.exported.type === 'StringLiteral' + ? spec.exported.value + : spec.exported.name; + const local = spec.local.name; + reexportBindings.push({ + type: 'reExportNamed', + name: local, + as: exported, + }); + } + } + registerDependency( state, { - name: path.node.source.value, + name, asyncType: null, isESMImport: true, optional: false, + importBindings: importBindings.length > 0 ? importBindings : undefined, + reexportBindings: + reexportBindings.length > 0 ? reexportBindings : undefined, }, path, ); @@ -594,6 +733,8 @@ export type ImportQualifier = Readonly<{ isESMImport: boolean, optional: boolean, contextParams?: RequireContextParams, + importBindings?: ReadonlyArray, + reexportBindings?: ReadonlyArray, }>; function registerDependency( @@ -901,6 +1042,12 @@ class DependencyRegistry { asyncType: qualifier.asyncType, isESMImport: qualifier.isESMImport, locs: [], + importBindings: qualifier.importBindings + ? [...qualifier.importBindings] + : [], + reexportBindings: qualifier.reexportBindings + ? [...qualifier.reexportBindings] + : [], index: this._dependencies.size, key: crypto.createHash('sha1').update(key).digest('base64'), }; @@ -914,14 +1061,26 @@ class DependencyRegistry { dependency = newDependency; } else { + // Merge bindings when the same module is imported multiple times + // (e.g., `import { x } from 'B'` and `export { y } from 'B'`) + const mergedImportBindings = qualifier.importBindings?.length + ? [...dependency.importBindings, ...qualifier.importBindings] + : dependency.importBindings; + const mergedReexportBindings = qualifier.reexportBindings?.length + ? [...dependency.reexportBindings, ...qualifier.reexportBindings] + : dependency.reexportBindings; + + let updated: MutableInternalDependency = { + ...dependency, + importBindings: mergedImportBindings, + reexportBindings: mergedReexportBindings, + }; if (dependency.isOptional && !qualifier.optional) { // A previously optionally required dependency was required non-optionally. // Mark it non optional for the whole module - dependency = { - ...dependency, - isOptional: false, - }; + updated = {...updated, isOptional: false}; } + dependency = updated; } this._dependencies.set(key, dependency); diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index e2b030b499..9752b4fa1c 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -10,8 +10,10 @@ */ import type {AssetData} from './Assets'; +import type {TreeShakeOptions} from './DeltaBundler/Serializers/baseJSBundle'; import type {ExplodedSourceMap} from './DeltaBundler/Serializers/getExplodedSourceMap'; import type {RamBundleInfo} from './DeltaBundler/Serializers/getRamBundleInfo'; +import type {UsedExports} from './DeltaBundler/TreeShakeAnalysis'; import type { MixedOutput, Module, @@ -50,12 +52,18 @@ import getAssets from './DeltaBundler/Serializers/getAssets'; import {getExplodedSourceMap} from './DeltaBundler/Serializers/getExplodedSourceMap'; import getRamBundleInfo from './DeltaBundler/Serializers/getRamBundleInfo'; import {sourceMapStringNonBlocking} from './DeltaBundler/Serializers/sourceMapString'; +import { + analyzeAndEliminate, + computeReexportDemand, + getEliminatedReexportSources, +} from './DeltaBundler/TreeShakeAnalysis'; import IncrementalBundler from './IncrementalBundler'; import ResourceNotFoundError from './IncrementalBundler/ResourceNotFoundError'; import {calculateBundleProgressRatio} from './lib/bundleProgressUtils'; import bundleToString from './lib/bundleToString'; import formatBundlingError from './lib/formatBundlingError'; import getGraphId from './lib/getGraphId'; +import hasSideEffects, {buildHasSideEffectsFn} from './lib/hasSideEffects'; import parseBundleOptionsFromBundleRequestUrl from './lib/parseBundleOptionsFromBundleRequestUrl'; import parseJsonBody from './lib/parseJsonBody'; import splitBundleOptions from './lib/splitBundleOptions'; @@ -138,6 +146,42 @@ type FetchTiming = { isPrefetch: boolean, }; +function buildFilteredGraph( + graph: ReadOnlyGraph<>, + treeShakeOptions: TreeShakeOptions, +): ReadOnlyGraph<> { + const {eliminable, finalizedModules} = treeShakeOptions; + const dependencies = new Map(); + for (const [modulePath, module] of graph.dependencies) { + if (eliminable.has(modulePath)) { + continue; + } + const finalized = finalizedModules.get(modulePath); + if (finalized == null) { + dependencies.set(modulePath, module); + continue; + } + dependencies.set(modulePath, { + ...module, + output: module.output.map(out => { + if (!out.type.startsWith('js/')) { + return out; + } + return { + ...out, + data: { + ...out.data, + code: finalized.code, + map: finalized.map, + lineCount: finalized.lineCount, + }, + }; + }), + }); + } + return {...graph, dependencies}; +} + export default class Server { _bundler: IncrementalBundler; _config: ConfigT; @@ -216,6 +260,94 @@ export default class Server { return this._createModuleId; } + _canApplyTreeShaking(transformOptions: TransformInputOptions): boolean { + return ( + this._config.transformer.unstable_treeShake && !transformOptions.dev + ); + } + + async _applyTreeShaking( + graph: ReadOnlyGraph<>, + transformOptions: TransformInputOptions, + ): Promise { + const fallbackHasSideEffectsFn = buildHasSideEffectsFn(); + const hasSideEffectsFn = (modulePath: string): boolean => { + const module = graph.dependencies.get(modulePath); + if (module?.sideEffects != null && module.sideEffectsRoot != null) { + return hasSideEffects( + modulePath, + module.sideEffects, + module.sideEffectsRoot, + ); + } + return fallbackHasSideEffectsFn(modulePath); + }; + const {usedExports, eliminable} = analyzeAndEliminate( + graph, + hasSideEffectsFn, + ); + const reexportDemand = computeReexportDemand(graph, usedExports); + + const bundler = this._bundler.getBundler(); + const finalizedModules: Map< + string, + {code: string, map: $FlowFixMe, lineCount: number}, + > = new Map(); + + const modulesToFinalize = [...graph.dependencies.values()].filter( + module => + module.moduleSyntax?.isESModule === true && + !eliminable.has(module.path), + ); + + await Promise.all( + modulesToFinalize.map(async module => { + const esmOutput = module.output.find(o => o.type.startsWith('js/')); + if (esmOutput == null || module.moduleSyntax == null) { + return; + } + const outputData = esmOutput.data; + if (typeof outputData !== 'object' || outputData == null) { + return; + } + const moduleSyntax = module.moduleSyntax; + const used: UsedExports = usedExports.get(module.path) ?? { + type: 'none', + }; + const eliminatedReexportSources = getEliminatedReexportSources( + module, + eliminable, + ); + if (typeof outputData.code !== 'string') { + return; + } + const finalized = await bundler.finalizeModule( + module.unstable_transformResultKey ?? '', + outputData.code, + moduleSyntax, + { + usedExports: used, + filename: module.path, + eliminatedReexportSources, + reexportDemandBySource: reexportDemand.get(module.path) ?? {}, + dependencyMapName: + this._config.transformer.unstable_dependencyMapReservedName ?? + '_dependencyMap', + globalPrefix: this._config.transformer.globalPrefix, + minify: transformOptions.minify, + minifierPath: this._config.transformer.minifierPath, + minifierConfig: this._config.transformer.minifierConfig, + dev: transformOptions.dev, + parserPlugins: moduleSyntax.parserPlugins, + }, + ); + finalizedModules.set(module.path, finalized); + }), + ); + + return {eliminable, finalizedModules}; + } + async _serializeGraph({ splitOptions, prepend, @@ -267,13 +399,22 @@ export default class Server { getSourceUrl: (module: Module<>) => this._getModuleSourceUrl(module, serializerOptions.sourcePaths), }; + const treeShakeOptions = this._canApplyTreeShaking(transformOptions) + ? await this._applyTreeShaking(graph, transformOptions) + : null; + + const graphForSerialization: ReadOnlyGraph<> = + treeShakeOptions != null + ? buildFilteredGraph(graph, treeShakeOptions) + : graph; + let bundleCode = null; let bundleMap = null; if (this._config.serializer.customSerializer) { const bundle = await this._config.serializer.customSerializer( entryPoint, prepend, - graph, + graphForSerialization, bundleOptions, ); if (typeof bundle === 'string') { @@ -284,15 +425,61 @@ export default class Server { } } else { bundleCode = bundleToString( - baseJSBundle(entryPoint, prepend, graph, bundleOptions), + baseJSBundle( + entryPoint, + prepend, + graph, + bundleOptions, + treeShakeOptions ?? undefined, + ), ).code; } if (!bundleMap) { + const sourceMapModules = (() => { + if (treeShakeOptions == null) { + return this._getSortedModules(graph); + } + const {eliminable, finalizedModules} = treeShakeOptions; + return this._getSortedModules(graph) + .filter(module => !eliminable.has(module.path)) + .map((module: Module<>) => { + const finalized = finalizedModules.get(module.path); + if (finalized == null) { + return module; + } + return { + ...module, + output: module.output.map(out => { + if (!out.type.startsWith('js/')) { + return out; + } + return { + ...out, + data: { + ...out.data, + code: finalized.code, + map: finalized.map, + lineCount: finalized.lineCount, + }, + }; + }), + }; + }); + })(); + + let processModuleFilter = this._config.serializer.processModuleFilter; + if (treeShakeOptions != null) { + const eliminable = treeShakeOptions.eliminable; + processModuleFilter = module => + !eliminable.has(module.path) && + this._config.serializer.processModuleFilter(module); + } + bundleMap = await sourceMapStringNonBlocking( - [...prepend, ...this._getSortedModules(graph)], + [...prepend, ...sourceMapModules], { excludeSource: serializerOptions.excludeSource, - processModuleFilter: this._config.serializer.processModuleFilter, + processModuleFilter, shouldAddToIgnoreList: bundleOptions.shouldAddToIgnoreList, getSourceUrl: (module: Module<>) => this._getModuleSourceUrl(module, serializerOptions.sourcePaths), diff --git a/packages/metro/src/Server/__tests__/Server-test.js b/packages/metro/src/Server/__tests__/Server-test.js index 31eb5e7fe0..6d9d9eea68 100644 --- a/packages/metro/src/Server/__tests__/Server-test.js +++ b/packages/metro/src/Server/__tests__/Server-test.js @@ -15,8 +15,13 @@ import type { Module, Options, ReadOnlyGraph, + ResolvedDependency, TransformResultDependency, } from '../../DeltaBundler/types'; +import type { + ImportBinding, + ReexportBinding, +} from '../../ModuleGraph/worker/collectDependencies'; import type {InputConfigT} from 'metro-config'; import ResourceNotFoundError from '../../IncrementalBundler/ResourceNotFoundError'; @@ -350,6 +355,204 @@ describe('processRequest', () => { }, ); + test('tree-shaking eliminates unused re-export chain modules in bundle output', async () => { + const treeShakeConfig = mergeConfig(config, { + transformer: {unstable_treeShake: true}, + }); + + type TestExportBinding = + | {type: 'named', name: string, localName: string} + | {type: 'default', localName: ?string} + | {type: 'reExportNamed', name: string, as: string, source: string} + | {type: 'reExportAll', source: string} + | {type: 'reExportNamespace', as: string, source: string}; + + const makeResolvedDependency = ({ + absolutePath, + importBindings, + name, + reexportBindings, + }: { + absolutePath: string, + importBindings?: ReadonlyArray, + name: string, + reexportBindings?: ReadonlyArray, + }): ResolvedDependency => ({ + absolutePath, + data: { + data: { + asyncType: null, + importBindings, + isESMImport: true, + key: name, + locs: [], + reexportBindings, + }, + name, + }, + }); + + const makeEsmModule = ({ + code, + deps, + directExportNames, + exports, + inverseDependencies, + path, + }: { + code: string, + deps: ReadonlyArray<[string, Dependency]>, + directExportNames: ReadonlyArray, + exports: ReadonlyArray, + inverseDependencies: ReadonlyArray, + path: string, + }): Module<> => ({ + dependencies: new Map(deps), + getSource: () => Buffer.from(code), + inverseDependencies: new CountingSet(inverseDependencies), + moduleSyntax: { + directExportNames: new Set(directExportNames) as ReadonlySet, + exports, + isESModule: true, + parserPlugins: ['flow'] as ReadonlyArray, + }, + output: [ + { + type: 'js/module', + data: { + code, + lineCount: 1, + map: [], + }, + }, + ], + path, + sideEffects: false, + sideEffectsRoot: '/root', + }); + + buildGraph.mockImplementation( + async ( + entryPoints: ReadonlyArray, + options: Options<>, + resolverOptions: unknown, + otherOptions: unknown, + ) => { + dependencies = new Map>([ + [ + '/root/mybundle.js', + makeEsmModule({ + code: 'import {x} from "./barrel"; console.log(x);', + deps: [ + [ + './barrel', + makeResolvedDependency({ + absolutePath: '/root/barrel.js', + importBindings: [{name: 'x', type: 'named'}], + name: './barrel', + }), + ], + ], + directExportNames: [], + exports: [], + inverseDependencies: [], + path: '/root/mybundle.js', + }), + ], + [ + '/root/barrel.js', + makeEsmModule({ + code: 'export {x} from "./leaf"; export {y} from "./dead";', + deps: [ + [ + './leaf', + makeResolvedDependency({ + absolutePath: '/root/leaf.js', + name: './leaf', + reexportBindings: [ + {as: 'x', name: 'x', type: 'reExportNamed'}, + ], + }), + ], + [ + './dead', + makeResolvedDependency({ + absolutePath: '/root/dead.js', + name: './dead', + reexportBindings: [ + {as: 'y', name: 'y', type: 'reExportNamed'}, + ], + }), + ], + ], + directExportNames: [], + exports: [ + {as: 'x', name: 'x', source: './leaf', type: 'reExportNamed'}, + {as: 'y', name: 'y', source: './dead', type: 'reExportNamed'}, + ], + inverseDependencies: ['/root/mybundle.js'], + path: '/root/barrel.js', + }), + ], + [ + '/root/leaf.js', + makeEsmModule({ + code: 'export const x = 1;', + deps: [], + directExportNames: ['x'], + exports: [{localName: 'x', name: 'x', type: 'named'}], + inverseDependencies: ['/root/barrel.js'], + path: '/root/leaf.js', + }), + ], + [ + '/root/dead.js', + makeEsmModule({ + code: 'export const y = 2;', + deps: [], + directExportNames: ['y'], + exports: [{localName: 'y', name: 'y', type: 'named'}], + inverseDependencies: ['/root/barrel.js'], + path: '/root/dead.js', + }), + ], + ]); + + return { + entryPoints: new Set(['/root/mybundle.js']), + dependencies, + transformOptions: options.transformOptions, + }; + }, + ); + + const finalizeModuleSpy = jest + .spyOn(Bundler.prototype, 'finalizeModule') + .mockImplementation( + async (transformResultKey, moduleCode, moduleSyntax, options) => ({ + code: `__d(function(){/* finalized:${options.filename} */});`, + lineCount: 1, + map: [], + }), + ); + + server = new Server(treeShakeConfig); + + const {code: bundle} = await server.build({ + ...Server.DEFAULT_BUNDLE_OPTIONS, + dev: false, + entryFile: 'mybundle.js', + minify: false, + platform: null, + }); + + expect(bundle).toContain('finalized:/root/mybundle.js'); + expect(bundle).toContain('finalized:/root/barrel.js'); + expect(bundle).toContain('finalized:/root/leaf.js'); + expect(bundle).not.toContain('finalized:/root/dead.js'); + expect(finalizeModuleSpy).toHaveBeenCalled(); + }); + test('returns JS bundle without the initial require() call', async () => { const response = await makeRequest('mybundle.bundle?runModule=false', null); diff --git a/packages/metro/src/lib/__tests__/hasSideEffects-test.js b/packages/metro/src/lib/__tests__/hasSideEffects-test.js new file mode 100644 index 0000000000..0d685a1590 --- /dev/null +++ b/packages/metro/src/lib/__tests__/hasSideEffects-test.js @@ -0,0 +1,62 @@ +/** + * 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 + */ + +'use strict'; + +import hasSideEffects from '../hasSideEffects'; + +describe('hasSideEffects', () => { + const packageRoot = '/repo/pkg'; + + test('returns true for undefined sideEffects (conservative default)', () => { + expect( + hasSideEffects('/repo/pkg/src/index.js', undefined, packageRoot), + ).toBe(true); + }); + + test('returns true for sideEffects=true', () => { + expect(hasSideEffects('/repo/pkg/src/index.js', true, packageRoot)).toBe( + true, + ); + }); + + test('returns false for sideEffects=false', () => { + expect(hasSideEffects('/repo/pkg/src/index.js', false, packageRoot)).toBe( + false, + ); + }); + + test('matches glob arrays (e.g. *.css)', () => { + expect( + hasSideEffects('/repo/pkg/src/styles.css', ['**/*.css'], packageRoot), + ).toBe(true); + expect( + hasSideEffects('/repo/pkg/src/utils.js', ['**/*.css'], packageRoot), + ).toBe(false); + }); + + test('matches patterns with and without ./ prefix', () => { + expect( + hasSideEffects( + '/repo/pkg/src/polyfill.js', + ['./src/polyfill.js'], + packageRoot, + ), + ).toBe(true); + expect( + hasSideEffects( + '/repo/pkg/src/polyfill.js', + ['src/polyfill.js'], + packageRoot, + ), + ).toBe(true); + }); +}); diff --git a/packages/metro/src/lib/hasSideEffects.js b/packages/metro/src/lib/hasSideEffects.js new file mode 100644 index 0000000000..4db288c21c --- /dev/null +++ b/packages/metro/src/lib/hasSideEffects.js @@ -0,0 +1,114 @@ +/** + * 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 + */ + +'use strict'; + +import fs from 'fs'; +// $FlowFixMe[untyped-import] micromatch is used for glob matching in sideEffects patterns. +import micromatch from 'micromatch'; +import path from 'path'; + +/** + * Build a `(modulePath) => boolean` function that reads sideEffects from + * each module's nearest package.json (with an in-process cache). + */ +export function buildHasSideEffectsFn(): (modulePath: string) => boolean { + const dirToInfo: Map = + new Map(); + const pkgSideEffects: Map = new Map(); + + function lookupDir(dir: string): {sideEffects: mixed, root: string} | null { + if (dirToInfo.has(dir)) { + return dirToInfo.get(dir) ?? null; + } + const pkgJsonPath = path.join(dir, 'package.json'); + if (fs.existsSync(pkgJsonPath)) { + let sideEffects: mixed = undefined; + if (!pkgSideEffects.has(pkgJsonPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + sideEffects = pkg.sideEffects; + } catch {} + pkgSideEffects.set(pkgJsonPath, sideEffects); + } else { + sideEffects = pkgSideEffects.get(pkgJsonPath); + } + const info = {sideEffects, root: dir}; + dirToInfo.set(dir, info); + return info; + } + const parent = path.dirname(dir); + if (parent === dir) { + dirToInfo.set(dir, null); + return null; + } + const result = lookupDir(parent); + dirToInfo.set(dir, result); + return result; + } + + return (modulePath: string): boolean => { + const info = lookupDir(path.dirname(modulePath)); + if (info == null) { + return true; // no package.json found → assume side effects + } + const {sideEffects} = info; + if (sideEffects == null || sideEffects === true || sideEffects === false) { + const normalizedSideEffects: boolean | void = + sideEffects == null ? undefined : sideEffects; + return hasSideEffects(modulePath, normalizedSideEffects, info.root); + } + if (Array.isArray(sideEffects)) { + const patterns: Array = []; + for (const item of sideEffects) { + if (typeof item !== 'string') { + return true; + } + patterns.push(item); + } + return hasSideEffects(modulePath, patterns, info.root); + } + return true; + }; +} + +/** + * Determines whether a module has side effects based on the `sideEffects` + * field from its package.json. + * + * - `undefined` / `true` → assume side effects (conservative default) + * - `false` → entire package is side-effect-free + * - `Array` → glob patterns of files that have side effects + * + * @param modulePath Absolute path to the module file. + * @param sideEffects The value of the `sideEffects` field (from package.json). + * @param packageRoot Absolute path to the directory containing package.json. + */ +export default function hasSideEffects( + modulePath: string, + sideEffects: boolean | ReadonlyArray | void, + packageRoot: string, +): boolean { + if (sideEffects == null || sideEffects === true) { + return true; + } + if (sideEffects === false) { + return false; + } + const relativePath = path + .relative(packageRoot, modulePath) + .split(path.sep) + .join('/'); + return ( + micromatch.isMatch(relativePath, sideEffects) || + micromatch.isMatch('./' + relativePath, sideEffects) + ); +} diff --git a/packages/metro/src/lib/transformHelpers.js b/packages/metro/src/lib/transformHelpers.js index 9ad1643257..195edc18cf 100644 --- a/packages/metro/src/lib/transformHelpers.js +++ b/packages/metro/src/lib/transformHelpers.js @@ -112,7 +112,10 @@ async function calcTransformerOptions( return { ...baseOptions, - experimentalImportSupport: transform?.experimentalImportSupport || false, + experimentalImportSupport: + (config.transformer.unstable_treeShake && !options.dev) || + transform?.experimentalImportSupport || + false, inlineRequires: transform?.inlineRequires || false, nonInlinedRequires: transform?.nonInlinedRequires || baseIgnoredInlineRequires, @@ -121,6 +124,7 @@ async function calcTransformerOptions( transform?.unstable_memoizeInlineRequires || false, unstable_nonMemoizedInlineRequires: transform?.unstable_nonMemoizedInlineRequires || [], + unstable_treeShake: config.transformer.unstable_treeShake && !options.dev, }; } diff --git a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js index 43bcbd0f68..08c54685f8 100644 --- a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js +++ b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js @@ -275,14 +275,46 @@ export class ModuleResolver { */ _getFileResolvedModule(resolution: Resolution): BundlerResolution { switch (resolution.type) { - case 'sourceFile': - return resolution; + case 'sourceFile': { + const packageForModule = this._getPackageForModule(resolution.filePath); + const sideEffects = packageForModule?.packageJson.sideEffects; + const sideEffectsFields: { + sideEffects?: boolean | ReadonlyArray, + sideEffectsRoot?: string, + } = {}; + if (sideEffects != null) { + sideEffectsFields.sideEffects = sideEffects; + if (packageForModule?.rootPath != null) { + sideEffectsFields.sideEffectsRoot = packageForModule.rootPath; + } + } + return { + ...resolution, + ...sideEffectsFields, + }; + } case 'assetFiles': // FIXME: we should forward ALL the paths/metadata, // not just an arbitrary item! const arbitrary = getArrayLowestItem(resolution.filePaths); invariant(arbitrary != null, 'invalid asset resolution'); - return {filePath: arbitrary, type: 'sourceFile'}; + const packageForModule = this._getPackageForModule(arbitrary); + const sideEffects = packageForModule?.packageJson.sideEffects; + const sideEffectsFields: { + sideEffects?: boolean | ReadonlyArray, + sideEffectsRoot?: string, + } = {}; + if (sideEffects != null) { + sideEffectsFields.sideEffects = sideEffects; + if (packageForModule?.rootPath != null) { + sideEffectsFields.sideEffectsRoot = packageForModule.rootPath; + } + } + return { + filePath: arbitrary, + type: 'sourceFile', + ...sideEffectsFields, + }; case 'empty': return this._getEmptyModule(); default: