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, }, ];