From 5d3015bdb6ca765c5615d38585d84959c92b1905 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Sat, 26 Sep 2026 11:20:13 +0100 Subject: [PATCH] Minify: Use the minifier's decoded source map where available When minifying, the transform worker converted Babel's decoded map to tuples and encoded it for the minifier, then decoded the minifier's output map with `source-map` (`toBabelSegments`), converted it back to tuples and encoded it again as the stored VLQ map. Terser already builds that decoded map, and only encodes it when `result.map` is read. This diff: - Adds an optional `decodedMap` to `MinifierResult`. When a minifier provides it, the worker encodes the stored map straight from it with `vlqMapFromBabelDecodedMap`, as it already does for unminified modules. Otherwise it decodes `map` with `@jridgewell/sourcemap-codec`, which is already installed via `@babel/generator`. - Has `metro-minify-terser` return Terser's `decoded_map` as `decodedMap`, and encode `map` only if it's read. - Passes Babel's own encoded `result.map` to the minifier, rather than re-encoding it from tuples. - Deprecates `MinifierResult.map` in favour of `decodedMap`, and `toBabelSegments`, `toSegmentTuple` and `tuplesFromBabelDecodedMap` in `metro-source-map`, which Metro no longer uses. The next diff removes them. Output is unchanged - across every module of our benchmark app*, the minified code, line count and stored map are byte-identical. Custom minifiers that only return `map` keep working as before. Minifying the whole app is 10.4% faster (95% CI 7.4-11.4%; 21.3s -> 19.2s of CPU on our benchmark app*), from no longer converting the map to and from tuples, and from Terser no longer encoding its map at all: | | Minify step (median) | vs `main` | |---|---|---| | `main` | 21,286ms (20,844 to 21,561) | | | This diff | 19,228ms (18,886 to 19,773) | -10.4% (-11.4 to -7.4) | ## Changelog ``` - **[Performance]**: Faster source map handling when minifying, using the minifier's decoded map if it provides one (`MinifierResult.decodedMap`) - **[Deprecated]**: Custom minifiers should return their source map as `decodedMap` - `MinifierResult.map` is deprecated - **[Deprecated]**: `metro-source-map`: Deprecate `toBabelSegments`, `toSegmentTuple` and `tuplesFromBabelDecodedMap` ``` ## Test plan New tests cover the worker using `decodedMap` without reading `map`, and `metro-minify-terser` returning Terser's decoded map and encoding `map` only on request. ``` yarn jest yarn flow check yarn lint yarn verify-api-snapshots ``` \* Benchmark: Mattermost Mobile 2.45.0 (React Native 0.83.9), its release bundle split back into its 7,082 modules (5.13M mapping segments), each minified with `metro-minify-terser` from its code and map as the worker does. Times are single-threaded totals across all modules - medians of 15 rounds interleaving `main` and both diffs in this stack, Node 22.13.1 on an M5 Pro, with 95% bootstrap confidence intervals. Output was checked byte-identical in every run. --- flow-typed/sourcemap-codec.js | 21 ++++ flow-typed/uglify.js | 18 ++- .../src/__tests__/minify-test.js | 22 ++++ packages/metro-minify-terser/src/minifier.js | 34 ++++-- packages/metro-source-map/API.md | 3 + packages/metro-source-map/src/source-map.js | 8 ++ packages/metro-transform-worker/API.md | 6 +- packages/metro-transform-worker/package.json | 1 + .../src/__tests__/index-test.js | 48 ++++++-- packages/metro-transform-worker/src/index.js | 103 +++++++++--------- 10 files changed, 188 insertions(+), 76 deletions(-) create mode 100644 flow-typed/sourcemap-codec.js diff --git a/flow-typed/sourcemap-codec.js b/flow-typed/sourcemap-codec.js new file mode 100644 index 0000000000..db1445e06b --- /dev/null +++ b/flow-typed/sourcemap-codec.js @@ -0,0 +1,21 @@ +/** + * 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 + * @format + * @oncall react_native + */ + +declare module '@jridgewell/sourcemap-codec' { + declare export type SourceMapSegment = + | [number] + | [number, number, number, number] + | [number, number, number, number, number]; + + declare export function decode( + mappings: string, + ): Array>; +} diff --git a/flow-typed/uglify.js b/flow-typed/uglify.js index d128e883f3..161791718c 100644 --- a/flow-typed/uglify.js +++ b/flow-typed/uglify.js @@ -110,5 +110,21 @@ declare module 'terser' { declare function minify( code: _Input, options: {..._Options, sourceMap: _SourceMapOptions, ...}, - ): _Error | {..._Result, map: string}; + ): + | _Error + | { + ..._Result, + map: string, + decoded_map?: ?{ + mappings: Array< + Array< + | [number] + | [number, number, number, number] + | [number, number, number, number, number], + >, + >, + names: Array, + ... + }, + }; } diff --git a/packages/metro-minify-terser/src/__tests__/minify-test.js b/packages/metro-minify-terser/src/__tests__/minify-test.js index 54f9176d90..af1225a37e 100644 --- a/packages/metro-minify-terser/src/__tests__/minify-test.js +++ b/packages/metro-minify-terser/src/__tests__/minify-test.js @@ -98,4 +98,26 @@ describe('Minification:', () => { const result = await minify({...baseOptions, filename}); expect(result.map).toEqual({...map, sources: [filename]}); }); + + test('returns the decoded source map provided by terser, and encodes `map` only when read', async () => { + const decodedMap = {...map, mappings: [[[0, 0, 0, 0]]]}; + let encodeCount = 0; + /* $FlowFixMe[incompatible-type] The mocked `minify` result isn't typed as + * Terser's. */ + terser.minify.mockResolvedValue({ + code: '', + decoded_map: decodedMap, + // flowlint-next-line unsafe-getters-setters:off + get map() { + encodeCount++; + return JSON.stringify(map); + }, + }); + const result = await minify({...baseOptions, filename}); + expect(result.decodedMap).toEqual(decodedMap); + expect(encodeCount).toBe(0); + expect(result.map).toEqual({...map, sources: [filename]}); + expect(result.map).toBe(result.map); + expect(encodeCount).toBe(1); + }); }); diff --git a/packages/metro-minify-terser/src/minifier.js b/packages/metro-minify-terser/src/minifier.js index 76be599629..5680964b32 100644 --- a/packages/metro-minify-terser/src/minifier.js +++ b/packages/metro-minify-terser/src/minifier.js @@ -9,7 +9,7 @@ * @oncall react_native */ -import type {BasicSourceMap} from 'metro-source-map'; +import type {BabelDecodedMap, BasicSourceMap} from 'metro-source-map'; import type {MinifierOptions, MinifierResult} from 'metro-transform-worker'; import terser from 'terser'; @@ -18,22 +18,33 @@ export default async function minifier( options: MinifierOptions, ): Promise { const result = await minify(options); + const {getMap} = result; - if (!options.map || result.map == null) { + if (!options.map || getMap == null) { return {code: result.code}; } - const map: BasicSourceMap = JSON.parse(result.map); + let map: ?BasicSourceMap; - return {code: result.code, map: {...map, sources: [options.filename]}}; + return { + code: result.code, + // Terser encodes its map only when `result.map` is read. + // flowlint-next-line unsafe-getters-setters:off + get map(): BasicSourceMap { + if (map == null) { + map = {...JSON.parse(getMap()), sources: [options.filename]}; + } + return map; + }, + decodedMap: result.decodedMap, + }; } -async function minify({ - code, - map, - reserved, - config, -}: MinifierOptions): Promise<{code: string, map: ?string}> { +async function minify({code, map, reserved, config}: MinifierOptions): Promise<{ + code: string, + getMap: ?() => string, + decodedMap: ?BabelDecodedMap, +}> { const options = { ...config, output: { @@ -65,6 +76,7 @@ async function minify({ return { code: result.code, - map: result.map, + getMap: options.sourceMap ? () => result.map : null, + decodedMap: result.decoded_map, }; } diff --git a/packages/metro-source-map/API.md b/packages/metro-source-map/API.md index b9eba61720..ea21597762 100644 --- a/packages/metro-source-map/API.md +++ b/packages/metro-source-map/API.md @@ -125,10 +125,13 @@ export interface SourceMapGenerator { toString(file?: string, options?: {excludeSource?: boolean | undefined}): string; } +// @deprecated export function toBabelSegments(sourceMap: BasicSourceMap): Array; +// @deprecated export function toSegmentTuple(mapping: BabelSourceMapSegment): MetroSourceMapSegmentTuple; +// @deprecated export function tuplesFromBabelDecodedMap(decodedMap: BabelDecodedMap): Array; export type VlqMap = { diff --git a/packages/metro-source-map/src/source-map.js b/packages/metro-source-map/src/source-map.js index 9cb442e60e..19a354df50 100644 --- a/packages/metro-source-map/src/source-map.js +++ b/packages/metro-source-map/src/source-map.js @@ -346,6 +346,9 @@ function toIndexMapSection(module: RawMappingsModule): BasicSourceMap { /** * Transforms a standard source map object into a Raw Mappings object, to be * used across the bundler. + * + * @deprecated Unused by Metro, and will be removed. `Consumer`'s + * `generatedMappings()` decodes a source map. */ function toBabelSegments( sourceMap: BasicSourceMap, @@ -383,6 +386,9 @@ function toBabelSegments( return rawMappings; } +/** + * @deprecated Unused by Metro, and will be removed. + */ function toSegmentTuple( mapping: BabelSourceMapSegment, ): MetroSourceMapSegmentTuple { @@ -409,6 +415,8 @@ function toSegmentTuple( * during generation, whereas accessing `rawMappings` triggers a second decode * (`allMappings`) that allocates ~4-5 objects per segment. No terminating * mapping is appended (callers that need one use `countLinesAndTerminateMap`). + * + * @deprecated Unused by Metro, and will be removed. */ function tuplesFromBabelDecodedMap( decodedMap: BabelDecodedMap, diff --git a/packages/metro-transform-worker/API.md b/packages/metro-transform-worker/API.md index ff078cbedf..5406a8bc66 100644 --- a/packages/metro-transform-worker/API.md +++ b/packages/metro-transform-worker/API.md @@ -72,7 +72,11 @@ export type MinifierOptions = { config: MinifierConfig; }; -export type MinifierResult = {code: string; map?: BasicSourceMap | undefined}; +export type MinifierResult = { + code: string; + map?: BasicSourceMap | undefined; + decodedMap?: null | undefined | BabelDecodedMap; +}; export const transform: (config: JsTransformerConfig, projectRoot: string, projectRelativePath: string, data: Buffer, options: JsTransformOptions) => Promise; diff --git a/packages/metro-transform-worker/package.json b/packages/metro-transform-worker/package.json index 7cad09abdd..8d164771fc 100644 --- a/packages/metro-transform-worker/package.json +++ b/packages/metro-transform-worker/package.json @@ -23,6 +23,7 @@ "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.9", "@babel/types": "^7.29.8", + "@jridgewell/sourcemap-codec": "^1.5.0", "flow-enums-runtime": "^0.0.6", "metro": "0.87.1", "metro-babel-transformer": "0.87.1", diff --git a/packages/metro-transform-worker/src/__tests__/index-test.js b/packages/metro-transform-worker/src/__tests__/index-test.js index 2387b786ba..54b33997ec 100644 --- a/packages/metro-transform-worker/src/__tests__/index-test.js +++ b/packages/metro-transform-worker/src/__tests__/index-test.js @@ -12,14 +12,17 @@ 'use strict'; jest - .mock('../utils/getMinifier', () => () => ({code, map, config}) => { - const trimmed = config.output.comments - ? code - : code.replace('/*#__PURE__*/', ''); - return { - code: trimmed.replace('arbitrary(code)', 'minified(code)'), - map, - }; + .mock('../utils/getMinifier', () => { + const minifier = jest.fn(({code, map, config}) => { + const trimmed = config.output.comments + ? code + : code.replace('/*#__PURE__*/', ''); + return { + code: trimmed.replace('arbitrary(code)', 'minified(code)'), + map, + }; + }); + return () => minifier; }) .mock('metro-transform-plugins', () => ({ ...jest.requireActual('metro-transform-plugins'), @@ -32,6 +35,7 @@ import type {JsTransformerConfig, JsTransformOptions} from '../index'; import typeof * as TransformerType from '../index'; import typeof FSType from 'node:fs'; +const {vlqMapFromBabelDecodedMap} = require('metro-source-map'); const {Buffer} = require('node:buffer'); const path = require('node:path'); @@ -433,7 +437,7 @@ test('emits a compact VlqMap for both the non-minified and minified paths', asyn source, {...baseTransformOptions, experimentalImportSupport: true}, ); - // Minified path re-encodes the minifier's tuple output to VLQ. + // Minified path encodes VLQ from the minifier's decoded map. const minifiedResult = await Transformer.transform( baseConfig, '/root', @@ -455,6 +459,32 @@ test('emits a compact VlqMap for both the non-minified and minified paths', asyn } }); +test("uses the minifier's decoded map, if it has one, without reading `map`", async () => { + const decodedMap = {mappings: [[[0, 0, 0, 0, 0]]], names: ['arbitrary']}; + jest + .requireMock('../utils/getMinifier')() + .mockImplementationOnce(() => ({ + code: 'minified(code);', + decodedMap, + // flowlint-next-line unsafe-getters-setters:off + get map() { + throw new Error('Expected the decoded map to be used'); + }, + })); + + const result = await Transformer.transform( + baseConfig, + '/root', + 'local/file.js', + Buffer.from('arbitrary(code);', 'utf8'), + {...baseTransformOptions, dev: false, minify: true}, + ); + + expect(result.output[0].data.map).toEqual( + vlqMapFromBabelDecodedMap(decodedMap, [1, 'minified(code);'.length]), + ); +}); + test('throws if the reserved dependency map name appears in the input', async () => { await expect( Transformer.transform( diff --git a/packages/metro-transform-worker/src/index.js b/packages/metro-transform-worker/src/index.js index 0758f034d7..a8710d9079 100644 --- a/packages/metro-transform-worker/src/index.js +++ b/packages/metro-transform-worker/src/index.js @@ -18,6 +18,7 @@ import type { TransformProfile, } from 'metro-babel-transformer'; import type { + BabelDecodedMap, BasicSourceMap, FBSourceFunctionMap, MetroSourceMapSegmentTuple, @@ -41,14 +42,12 @@ import {transformFromAstSync} from '@babel/core'; import generate from '@babel/generator'; import * as babylon from '@babel/parser'; import * as types from '@babel/types'; +// $FlowFixMe[cannot-resolve-module] - No typings at Meta (OSS uses a libdef) +import {decode as decodeMappings} from '@jridgewell/sourcemap-codec'; import {stableHash} from 'metro-cache'; import {getCacheKey as metroGetCacheKey} from 'metro-cache-key'; import { - fromRawMappings, functionMapBabelPlugin, - toBabelSegments, - toSegmentTuple, - tuplesFromBabelDecodedMap, vlqMapFromBabelDecodedMap, vlqMapFromTuples, } from 'metro-source-map'; @@ -78,7 +77,13 @@ export type MinifierOptions = { export type MinifierResult = { code: string, + /** + * @deprecated Return `decodedMap` instead. Only read if `decodedMap` is + * missing, and will be removed. + */ map?: BasicSourceMap, + // The minified code's source map, decoded, composed with the input `map`. + decodedMap?: ?BabelDecodedMap, ... }; @@ -203,30 +208,16 @@ function getDynamicDepsBehavior( const minifyCode = async ( config: JsTransformerConfig, - projectRoot: string, filename: string, code: string, - source: string, - map: Array, + map: ?BasicSourceMap, reserved?: ReadonlyArray = [], ): Promise<{ code: string, - map: Array, + lineCount: number, + map: VlqMap, ... }> => { - const sourceMap = fromRawMappings([ - { - code, - // functionMap is overridden by the serializer - functionMap: null, - // isIgnored is overridden by the serializer - isIgnored: false, - map, - path: filename, - source, - }, - ]).toMap(undefined, {}); - const minify = getMinifier(config.minifierPath); try { @@ -234,15 +225,18 @@ const minifyCode = async ( code, config: config.minifierConfig, filename, - map: sourceMap, + map, reserved, }); + const {lineCount, lastLineColumn} = countLines(minified.code); return { code: minified.code, - map: minified.map - ? toBabelSegments(minified.map).map(toSegmentTuple) - : [], + lineCount, + map: vlqMapFromBabelDecodedMap( + getDecodedMap(minified) ?? {mappings: [], names: []}, + [lineCount, lastLineColumn], + ), }; } catch (error) { if (error.constructor.name === 'JS_Parse_Error') { @@ -255,6 +249,18 @@ const minifyCode = async ( } }; +// A minifier may return its map already decoded, which saves decoding it here. +function getDecodedMap(minified: MinifierResult): ?BabelDecodedMap { + if (minified.decodedMap != null) { + return minified.decodedMap; + } + const {map} = minified; + if (map != null) { + return {mappings: decodeMappings(map.mappings), names: map.names}; + } + return null; +} + const disabledDependencyTransformer: DependencyTransformer = { transformIllegalDynamicRequire: () => void 0, transformImportCall: () => void 0, @@ -476,25 +482,13 @@ async function transformJS( let lineCount: number; if (minify) { - // The minifier returns its own map (not Babel's `decodedMap`), so we derive - // tuples from Babel's eagerly-computed decoded map, hand them to the - // minifier, then re-encode the resulting tuples to a compact VLQ map. - let tuples = result.decodedMap - ? tuplesFromBabelDecodedMap(result.decodedMap) - : []; - - ({map: tuples, code} = await minifyCode( + ({code, lineCount, map} = await minifyCode( config, - projectRoot, file.filename, result.code, - file.code, - tuples, + result.map, reserved, )); - - ({lineCount, map: tuples} = countLinesAndTerminateMap(code, tuples)); - map = vlqMapFromTuples(tuples); } else { // Dominant path (e.g. Hermes, which doesn't minify): encode the compact VLQ // map straight from Babel's eagerly-computed decoded map, never @@ -603,7 +597,8 @@ async function transformJSON( ?.unstable_staticHermesOptimizedRequire, ), ); - let map: Array = []; + let map: VlqMap; + let lineCount: number; // TODO: When we can reuse transformJS for JSON, we should not derive `minify` separately. const minify = @@ -612,14 +607,19 @@ async function transformJSON( options.unstable_transformProfile !== 'hermes-stable'; if (minify) { - ({map, code} = await minifyCode( - config, - projectRoot, - file.filename, - code, - file.code, - map, - )); + // The wrapped JSON has no mappings of its own, so the minified code maps + // to nothing. + ({map, code, lineCount} = await minifyCode(config, file.filename, code, { + version: 3, + sources: [file.filename], + sourcesContent: [file.code], + names: [], + mappings: '', + })); + } else { + let tuples: Array; + ({lineCount, map: tuples} = countLinesAndTerminateMap(code, [])); + map = vlqMapFromTuples(tuples); } let jsType: JSFileType; @@ -632,14 +632,9 @@ async function transformJSON( jsType = 'js/module'; } - let lineCount; - ({lineCount, map} = countLinesAndTerminateMap(code, map)); - // The JSON path builds tuples directly (no Babel `decodedMap`), so re-encode - // the finished tuples to a compact VLQ map. - const outputMap = vlqMapFromTuples(map); const output: Array = [ { - data: {code, functionMap: null, lineCount, map: outputMap}, + data: {code, functionMap: null, lineCount, map}, type: jsType, }, ];