From 88016215159cfcb81c017ed9043972cf2497d09e Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Thu, 25 Jun 2026 08:02:02 -0700 Subject: [PATCH] Optionally store source maps as VLQ-encoded (1/2): Type widening, consumer support (#1742) Summary: ## This stack Decoded tuple arrays are the single largest contributor to Metro's dev-server heap on large bundles (~10 million retained small arrays on FBiOS entry bundle, for example). Storing the same data as a compact VLQ string instead removes most of that footprint. This reduces source map memory by ~51% on the heap and ~48% RSS for that ~16K module bundle. The emitted whole-bundle source map is unchanged. When a module's map is stored as VLQ, `fromRawMappings` decodes it back to tuples just-in-time, with request-scoped caching. The trade-off is therefore decode + re-encode CPU when a `.map` is actually requested or `/symbolicate` request is made. A plain `string` is used for `mappings` for now, since VLQ is ASCII by design. A `UInt8Array` would be marginally more efficient and potentially transferrable to/from worker threads, but would require more invasive changes to cache (de)serialisation. I did some benchmarking with this and it doesn't justify the complexity right now. ## This diff Adds a `VlqMap` type (`{mappings: string, names: ReadonlyArray}`) as an alternative to the current `Array` for storing per-module source maps in `Module` graph nodes (and transform results, and cache artifacts). Adds the ability to store, thread, decode and (flat-)emit VLQ maps - **nothing actually produces them yet**, so these code paths are unused except by tests. The opt-in producer flag lands in the next diff. ## Follow up After this mini-stack, we'll add an opt-in for emitting index source maps, directly re-using per-module VLQ and eliminating the trade-off mentioned above. Reviewed By: huntie, javache Differential Revision: D107973884 --- .../src/__tests__/source-map-test.js | 179 +++++++++++++++++- packages/metro-source-map/src/source-map.js | 111 +++++++---- .../metro-source-map/types/source-map.d.ts | 51 +++-- .../Serializers/getExplodedSourceMap.js | 3 +- .../Serializers/helpers/getSourceMapInfo.js | 3 +- .../src/Server/__tests__/symbolicate-test.js | 101 ++++++++++ packages/metro/src/Server/symbolicate.js | 55 ++++-- .../Serializers/getExplodedSourceMap.d.ts | 5 +- .../Serializers/helpers/getSourceMapInfo.d.ts | 5 +- 9 files changed, 429 insertions(+), 84 deletions(-) create mode 100644 packages/metro/src/Server/__tests__/symbolicate-test.js diff --git a/packages/metro-source-map/src/__tests__/source-map-test.js b/packages/metro-source-map/src/__tests__/source-map-test.js index 664163966c..9497eb249a 100644 --- a/packages/metro-source-map/src/__tests__/source-map-test.js +++ b/packages/metro-source-map/src/__tests__/source-map-test.js @@ -9,8 +9,16 @@ * @oncall react_native */ +import type {MetroSourceMapSegmentTuple} from '../source-map'; + import Generator from '../Generator'; -import {fromRawMappings, toBabelSegments, toSegmentTuple} from '../source-map'; +import { + fromRawMappings, + isVlqMap, + toBabelSegments, + toSegmentTuple, + vlqMapFromTuples, +} from '../source-map'; describe('flattening mappings / compacting', () => { test('flattens simple mappings', () => { @@ -167,3 +175,172 @@ describe('build map from raw mappings', () => { }); const lines = (n: number) => Array(n).join('\n'); + +function makeVlqMap( + mappings: string, + names: ReadonlyArray, +): {readonly mappings: string, readonly names: ReadonlyArray} { + return { + mappings, + names, + }; +} + +describe('isVlqMap', () => { + test('returns false for null', () => { + expect(isVlqMap(null)).toBe(false); + }); + + test('returns false for tuple array', () => { + expect(isVlqMap([[1, 2, 3, 4]])).toBe(false); + }); + + test('returns true for VlqMap', () => { + expect(isVlqMap(makeVlqMap('AAAA', []))).toBe(true); + }); + + test('returns false for plain object without string mappings', () => { + // $FlowFixMe[incompatible-type] Testing runtime behavior with invalid type + expect(isVlqMap({mappings: 123, names: []})).toBe(false); + }); +}); + +describe('fromRawMappings with VlqMap', () => { + // Shared tuple definitions. We build two parallel module lists from these — + // one storing decoded tuples, one storing the equivalent VLQ — and assert the + // serialized flat map is byte-identical, i.e. VLQ storage is transparent. + const tuples0: Array = [ + [1, 2], + [3, 4, 5, 6, 'apples'], + [7, 8, 9, 10], + [11, 12, 13, 14, 'pears'], + ]; + const tuples1: Array = [ + [1, 2], + [3, 4, 15, 16, 'bananas'], + ]; + + const tupleModules = [ + { + code: lines(11), + functionMap: {names: [''], mappings: 'AAA'}, + map: tuples0, + source: 'code1', + path: 'path1', + isIgnored: false, + }, + { + code: lines(3), + functionMap: null, + map: tuples1, + source: 'code2', + path: 'path2', + isIgnored: true, + }, + ]; + + const vlqModules = [ + {...tupleModules[0], map: vlqMapFromTuples(tuples0)}, + {...tupleModules[1], map: vlqMapFromTuples(tuples1)}, + ]; + + test('produces a flat (non-indexed) map for VlqMap inputs', () => { + const map = fromRawMappings(vlqModules).toMap(); + expect(typeof map.mappings).toBe('string'); + expect(map.sources).toEqual(['path1', 'path2']); + expect(map.version).toBe(3); + }); + + test('VlqMap input serializes byte-identically to tuple input', () => { + expect(fromRawMappings(vlqModules).toString()).toBe( + fromRawMappings(tupleModules).toString(), + ); + expect(fromRawMappings(vlqModules).toMap()).toEqual( + fromRawMappings(tupleModules).toMap(), + ); + }); + + test('preserves functionMap and ignoreList from VlqMap modules', () => { + const map = fromRawMappings(vlqModules).toMap(); + expect(map.x_facebook_sources).toEqual([ + [{names: [''], mappings: 'AAA'}], + null, + ]); + expect(map.x_google_ignoreList).toEqual([1]); + }); + + test('handles mixed tuple and VlqMap modules identically to all-tuple', () => { + const mixed = [tupleModules[0], vlqModules[1]]; + expect(fromRawMappings(mixed).toString()).toBe( + fromRawMappings(tupleModules).toString(), + ); + }); + + test('applies offsetLines identically for VlqMap and tuple inputs', () => { + expect(fromRawMappings(vlqModules, 8).toString()).toBe( + fromRawMappings(tupleModules, 8).toString(), + ); + }); + + test('excludeSource option omits sourcesContent', () => { + const map = fromRawMappings(vlqModules).toMap(undefined, { + excludeSource: true, + }); + expect(map.sourcesContent).toBeUndefined(); + }); +}); + +describe('vlqMapFromTuples', () => { + // Decode via Metro's existing string->tuples path, the inverse of + // vlqMapFromTuples. + const decode = (vlqMap: { + readonly mappings: string, + readonly names: ReadonlyArray, + }) => + toBabelSegments({ + version: 3, + sources: [''], + names: [...vlqMap.names], + mappings: vlqMap.mappings, + }).map(toSegmentTuple); + + test('encodes tuples into a VlqMap', () => { + const vlqMap = vlqMapFromTuples([ + [1, 2], + [3, 4, 5, 6, 'apples'], + [7, 8, 9, 10], + [11, 12, 13, 14, 'pears'], + ]); + expect(isVlqMap(vlqMap)).toBe(true); + expect(typeof vlqMap.mappings).toBe('string'); + expect(vlqMap.names).toEqual(['apples', 'pears']); + }); + + test('round-trips via toBabelSegments + toSegmentTuple', () => { + const tuples = [ + [1, 2], + [3, 4, 5, 6, 'apples'], + [7, 8, 9, 10], + [11, 12, 13, 14, 'pears'], + [11, 20, 30, 40], + ]; + expect(decode(vlqMapFromTuples(tuples))).toEqual(tuples); + }); + + test('round-trips multi-line, multi-segment maps', () => { + const tuples = [ + [1, 0, 1, 0], + [1, 8, 1, 4, 'foo'], + [2, 0, 2, 0], + [3, 4, 3, 2, 'bar'], + [5, 0], + ]; + expect(decode(vlqMapFromTuples(tuples))).toEqual(tuples); + }); + + test('encodes an empty map', () => { + const vlqMap = vlqMapFromTuples([]); + expect(vlqMap.mappings).toBe(''); + expect(decode(vlqMap)).toEqual([]); + }); +}); diff --git a/packages/metro-source-map/src/source-map.js b/packages/metro-source-map/src/source-map.js index b0f98553e9..f9175a4458 100644 --- a/packages/metro-source-map/src/source-map.js +++ b/packages/metro-source-map/src/source-map.js @@ -21,6 +21,7 @@ import { generateFunctionMap, } from './generateFunctionMap'; import Generator from './Generator'; +import nullthrows from 'nullthrows'; // $FlowFixMe[untyped-import] - source-map import SourceMap from 'source-map'; @@ -53,6 +54,11 @@ export type BabelDecodedMap = { ... }; +export type VlqMap = { + readonly mappings: string, + readonly names: ReadonlyArray, +}; + export type HermesFunctionOffsets = {[number]: ReadonlyArray, ...}; export type FBSourcesArray = ReadonlyArray; @@ -123,18 +129,26 @@ type SourceMapConsumerMapping = { name: ?string, }; +export type RawMappingsModule = { + readonly map: ?ReadonlyArray | VlqMap, + readonly functionMap: ?FBSourceFunctionMap, + readonly path: string, + readonly source: string, + readonly code: string, + readonly isIgnored: boolean, + readonly lineCount?: number, +}; + +function isVlqMap( + map: ?ReadonlyArray | VlqMap, +): implies map is VlqMap { + return map != null && !Array.isArray(map) && typeof map.mappings === 'string'; +} + function fromRawMappingsImpl( isBlocking: boolean, onDone: Generator => void, - modules: ReadonlyArray<{ - readonly map: ?ReadonlyArray, - readonly functionMap: ?FBSourceFunctionMap, - readonly path: string, - readonly source: string, - readonly code: string, - readonly isIgnored: boolean, - readonly lineCount?: number, - }>, + modules: ReadonlyArray, offsetLines: number, ): void { const modulesToProcess = modules.slice(); @@ -146,15 +160,18 @@ function fromRawMappingsImpl( return true; } - const mod = modulesToProcess.shift(); - // $FlowFixMe[incompatible-use] + const mod = nullthrows(modulesToProcess.shift()); const {code, map} = mod; - if (Array.isArray(map)) { - // $FlowFixMe[incompatible-type] + if (isVlqMap(map)) { + // Modules may store their map compactly as VLQ. Decode it back to tuples + // just-in-time so it can be folded into the flat Generator like any other + // module. Decoding one module at a time keeps the transient tuple arrays + // short-lived, preserving the memory win of VLQ storage. + addMappingsForFile(generator, decodeVlqMap(map), mod, carryOver); + } else if (Array.isArray(map)) { addMappingsForFile(generator, map, mod, carryOver); } else if (map != null) { throw new Error( - // $FlowFixMe[incompatible-use] `Unexpected module with full source map found: ${mod.path}`, ); } @@ -197,15 +214,7 @@ function fromRawMappingsImpl( * the resulting bundle, e.g. by some prefix code. */ function fromRawMappings( - modules: ReadonlyArray<{ - readonly map: ?ReadonlyArray, - readonly functionMap: ?FBSourceFunctionMap, - readonly path: string, - readonly source: string, - readonly code: string, - readonly isIgnored: boolean, - readonly lineCount?: number, - }>, + modules: ReadonlyArray, offsetLines: number = 0, ): Generator { let generator: void | Generator; @@ -224,15 +233,7 @@ function fromRawMappings( } async function fromRawMappingsNonBlocking( - modules: ReadonlyArray<{ - readonly map: ?ReadonlyArray, - readonly functionMap: ?FBSourceFunctionMap, - readonly path: string, - readonly source: string, - readonly code: string, - readonly isIgnored: boolean, - readonly lineCount?: number, - }>, + modules: ReadonlyArray, offsetLines: number = 0, ): Promise { return new Promise(resolve => { @@ -344,16 +345,8 @@ function tuplesFromBabelDecodedMap( function addMappingsForFile( generator: Generator, - mappings: Array, - module: { - readonly code: string, - readonly functionMap: ?FBSourceFunctionMap, - readonly map: ?Array, - readonly path: string, - readonly source: string, - readonly isIgnored: boolean, - readonly lineCount?: number, - }, + mappings: ReadonlyArray, + module: RawMappingsModule, carryOver: number, ) { generator.startFile(module.path, module.source, module.functionMap, { @@ -400,6 +393,38 @@ const newline = /\r\n?|\n|\u2028|\u2029/g; const countLines = (string: string): number => (string.match(newline) || []).length + 1; +/** + * Decodes a compact VLQ map back into raw mapping tuples — the inverse of + * `vlqMapFromTuples`, reusing Metro's existing source-map consumer. + */ +function decodeVlqMap(vlqMap: VlqMap): Array { + return toBabelSegments({ + version: 3, + sources: [''], + names: [...vlqMap.names], + mappings: vlqMap.mappings, + }).map(toSegmentTuple); +} + +/** + * Encodes raw mapping tuples into a compact VLQ `mappings` string + `names` + * table. Decode the inverse via `decodeVlqMap` (or `toBabelSegments` + + * `toSegmentTuple`). Storing maps in this form uses far less memory than the + * equivalent decoded tuple arrays. + */ +function vlqMapFromTuples( + mappings: ReadonlyArray, +): VlqMap { + const generator = new Generator(); + generator.startFile('', '', null); + for (const mapping of mappings) { + addMapping(generator, mapping, 0); + } + generator.endFile(); + const map = generator.toMap(); + return {mappings: map.mappings, names: map.names}; +} + export { BundleBuilder, composeSourceMaps, @@ -409,10 +434,12 @@ export { fromRawMappings, fromRawMappingsNonBlocking, functionMapBabelPlugin, + isVlqMap, normalizeSourcePath, toBabelSegments, toSegmentTuple, tuplesFromBabelDecodedMap, + vlqMapFromTuples, }; /** diff --git a/packages/metro-source-map/types/source-map.d.ts b/packages/metro-source-map/types/source-map.d.ts index e7f58e0c6c..c45df1a20b 100644 --- a/packages/metro-source-map/types/source-map.d.ts +++ b/packages/metro-source-map/types/source-map.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<> + * @generated SignedSource<<313a3bbbf29c3ac69821b3124678d4e0>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-source-map/src/source-map.js @@ -43,6 +43,10 @@ export type BabelDecodedMap = { readonly mappings: ReadonlyArray>; readonly names: ReadonlyArray; }; +export type VlqMap = { + readonly mappings: string; + readonly names: ReadonlyArray; +}; export type HermesFunctionOffsets = { [$$Key$$: number]: ReadonlyArray; }; @@ -92,6 +96,20 @@ export type IndexMap = { readonly x_google_ignoreList?: void; }; export type MixedSourceMap = IndexMap | BasicSourceMap; +export type RawMappingsModule = { + readonly map: + | (null | undefined | ReadonlyArray) + | VlqMap; + readonly functionMap: null | undefined | FBSourceFunctionMap; + readonly path: string; + readonly source: string; + readonly code: string; + readonly isIgnored: boolean; + readonly lineCount?: number; +}; +declare function isVlqMap( + map: (null | undefined | ReadonlyArray) | VlqMap, +): map is VlqMap; /** * Creates a source map from modules with "raw mappings", i.e. an array of * tuples with either 2, 4, or 5 elements: @@ -100,27 +118,11 @@ export type MixedSourceMap = IndexMap | BasicSourceMap; * the resulting bundle, e.g. by some prefix code. */ declare function fromRawMappings( - modules: ReadonlyArray<{ - readonly map: null | undefined | ReadonlyArray; - readonly functionMap: null | undefined | FBSourceFunctionMap; - readonly path: string; - readonly source: string; - readonly code: string; - readonly isIgnored: boolean; - readonly lineCount?: number; - }>, + modules: ReadonlyArray, offsetLines?: number, ): Generator; declare function fromRawMappingsNonBlocking( - modules: ReadonlyArray<{ - readonly map: null | undefined | ReadonlyArray; - readonly functionMap: null | undefined | FBSourceFunctionMap; - readonly path: string; - readonly source: string; - readonly code: string; - readonly isIgnored: boolean; - readonly lineCount?: number; - }>, + modules: ReadonlyArray, offsetLines?: number, ): Promise; /** @@ -146,6 +148,15 @@ declare function toSegmentTuple( declare function tuplesFromBabelDecodedMap( decodedMap: BabelDecodedMap, ): Array; +/** + * Encodes raw mapping tuples into a compact VLQ `mappings` string + `names` + * table. Decode the inverse via `decodeVlqMap` (or `toBabelSegments` + + * `toSegmentTuple`). Storing maps in this form uses far less memory than the + * equivalent decoded tuple arrays. + */ +declare function vlqMapFromTuples( + mappings: ReadonlyArray, +): VlqMap; export { BundleBuilder, composeSourceMaps, @@ -155,10 +166,12 @@ export { fromRawMappings, fromRawMappingsNonBlocking, functionMapBabelPlugin, + isVlqMap, normalizeSourcePath, toBabelSegments, toSegmentTuple, tuplesFromBabelDecodedMap, + vlqMapFromTuples, }; /** * Backwards-compatibility with CommonJS consumers using interopRequireDefault. diff --git a/packages/metro/src/DeltaBundler/Serializers/getExplodedSourceMap.js b/packages/metro/src/DeltaBundler/Serializers/getExplodedSourceMap.js index 45c88a83a8..b1d301f3c2 100644 --- a/packages/metro/src/DeltaBundler/Serializers/getExplodedSourceMap.js +++ b/packages/metro/src/DeltaBundler/Serializers/getExplodedSourceMap.js @@ -13,12 +13,13 @@ import type {Module} from '../types'; import type { FBSourceFunctionMap, MetroSourceMapSegmentTuple, + VlqMap, } from 'metro-source-map'; import {getJsOutput, isJsModule} from './helpers/js'; export type ExplodedSourceMap = ReadonlyArray<{ - readonly map: Array, + readonly map: Array | VlqMap, readonly firstLine1Based: number, readonly functionMap: ?FBSourceFunctionMap, readonly path: string, diff --git a/packages/metro/src/DeltaBundler/Serializers/helpers/getSourceMapInfo.js b/packages/metro/src/DeltaBundler/Serializers/helpers/getSourceMapInfo.js index d70aa79ec1..8513ee4e7b 100644 --- a/packages/metro/src/DeltaBundler/Serializers/helpers/getSourceMapInfo.js +++ b/packages/metro/src/DeltaBundler/Serializers/helpers/getSourceMapInfo.js @@ -13,6 +13,7 @@ import type {Module} from '../../types'; import type { FBSourceFunctionMap, MetroSourceMapSegmentTuple, + VlqMap, } from 'metro-source-map'; import {getJsOutput} from './js'; @@ -25,7 +26,7 @@ export default function getSourceMapInfo( getSourceUrl: ?(module: Module<>) => string, }, ): { - readonly map: Array, + readonly map: Array | VlqMap, readonly functionMap: ?FBSourceFunctionMap, readonly code: string, readonly path: string, diff --git a/packages/metro/src/Server/__tests__/symbolicate-test.js b/packages/metro/src/Server/__tests__/symbolicate-test.js new file mode 100644 index 0000000000..38e26e3892 --- /dev/null +++ b/packages/metro/src/Server/__tests__/symbolicate-test.js @@ -0,0 +1,101 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {ExplodedSourceMap} from '../../DeltaBundler/Serializers/getExplodedSourceMap'; +import type {InputConfigT} from 'metro-config'; +import type {MetroSourceMapSegmentTuple, VlqMap} from 'metro-source-map'; + +import symbolicate from '../symbolicate'; +import {getDefaultConfig, mergeConfig} from 'metro-config'; +import {vlqMapFromTuples} from 'metro-source-map'; + +// symbolicate() only reads `config.symbolicator`. Stub metro-config so the test +// stays independent of the full config pipeline (and the Node version it needs). +jest.mock('metro-config', () => ({ + getDefaultConfig: {getDefaultValues: () => ({})}, + mergeConfig: (base, override) => ({...base, ...override}), +})); + +const config = mergeConfig(getDefaultConfig.getDefaultValues('/'), { + symbolicator: { + customizeFrame: () => null, + customizeStack: stack => stack, + }, +} as InputConfigT); + +// genLine1Based, genCol0Based, srcLine1Based, srcCol0Based[, name] +const TUPLES: Array = [ + [1, 0, 10, 4], + [1, 8, 10, 12, 'greet'], + [2, 0, 11, 0], +]; + +function makeMap( + map: Array | VlqMap, +): ExplodedSourceMap { + return [ + { + firstLine1Based: 1, + functionMap: null, + map, + path: 'foo.js', + }, + ]; +} + +test('symbolicates a frame against a decoded tuple map', async () => { + const [frame] = await symbolicate( + [{file: 'bundle.js', lineNumber: 1, column: 8, methodName: null}], + [['bundle.js', makeMap(TUPLES)]], + config, + null, + ); + expect(frame).toMatchObject({file: 'foo.js', lineNumber: 10, column: 12}); +}); + +test('VLQ map symbolicates identically to its decoded tuples', async () => { + const frame = [ + {file: 'bundle.js', lineNumber: 1, column: 8, methodName: null}, + ]; + + const [fromTuples] = await symbolicate( + frame, + [['bundle.js', makeMap(TUPLES)]], + config, + null, + ); + const [fromVlq] = await symbolicate( + frame, + [['bundle.js', makeMap(vlqMapFromTuples(TUPLES))]], + config, + null, + ); + + expect(fromVlq).toEqual(fromTuples); + expect(fromVlq).toMatchObject({file: 'foo.js', lineNumber: 10, column: 12}); +}); + +test('reuses a single VLQ map across multiple frames in the same module', async () => { + const explodedMap = makeMap(vlqMapFromTuples(TUPLES)); + + const out = await symbolicate( + [ + {file: 'bundle.js', lineNumber: 1, column: 0, methodName: null}, + {file: 'bundle.js', lineNumber: 1, column: 8, methodName: null}, + {file: 'bundle.js', lineNumber: 2, column: 0, methodName: null}, + ], + [['bundle.js', explodedMap]], + config, + null, + ); + + expect(out.map(f => f.lineNumber)).toEqual([10, 10, 11]); +}); diff --git a/packages/metro/src/Server/symbolicate.js b/packages/metro/src/Server/symbolicate.js index b219d39661..9452367dca 100644 --- a/packages/metro/src/Server/symbolicate.js +++ b/packages/metro/src/Server/symbolicate.js @@ -12,10 +12,12 @@ import type { FBSourceFunctionMap, MetroSourceMapSegmentTuple, + VlqMap, } from '../../../metro-source-map/src/source-map'; import type {ExplodedSourceMap} from '../DeltaBundler/Serializers/getExplodedSourceMap'; import type {ConfigT} from 'metro-config'; +import {toBabelSegments, toSegmentTuple} from 'metro-source-map'; import {greatestLowerBound} from 'metro-source-map/private/Consumer/search'; import {SourceMetadataMapConsumer} from 'metro-symbolicate/private/Symbolication'; @@ -35,6 +37,26 @@ export type StackFrameOutput = Readonly; type ExplodedSourceMapModule = ExplodedSourceMap[number]; type Position = {readonly line1Based: number, column0Based: number}; +function ensureDecodedMap( + map: Array | VlqMap, + decodedMapCache: Map>, +): Array { + if (Array.isArray(map)) { + return map; + } + let decoded = decodedMapCache.get(map); + if (decoded == null) { + decoded = toBabelSegments({ + version: 3, + sources: [''], + names: [...map.names], + mappings: map.mappings, + }).map(toSegmentTuple); + decodedMapCache.set(map, decoded); + } + return decoded; +} + function createFunctionNameGetter( module: ExplodedSourceMapModule, ): Position => ?string { @@ -70,12 +92,19 @@ export default async function symbolicate( { readonly firstLine1Based: number, readonly functionMap: ?FBSourceFunctionMap, - readonly map: Array, + readonly map: Array | VlqMap, readonly path: string, }, (Position) => ?string, >(); + // Decoded VLQ maps are cached only for the duration of this request, then + // discarded. The cache dedupes decoding across frames that resolve to the + // same module, while keeping the (large) decoded tuples short-lived — the + // VlqMaps themselves are retained by the long-lived module graph, so caching + // beyond request scope would defeat the memory savings of storing them as VLQ. + const decodedMapCache = new Map>(); + function findModule(frame: StackFrameInput): ?ExplodedSourceMapModule { const map = mapsByUrl.get(frame.file); if (!map || frame.lineNumber == null) { @@ -96,19 +125,18 @@ export default async function symbolicate( frame: StackFrameInput, module: ExplodedSourceMapModule, ): ?Position { - if ( - module.map == null || - frame.lineNumber == null || - frame.column == null - ) { + const lineNumber = frame.lineNumber; + const column = frame.column; + if (module.map == null || lineNumber == null || column == null) { return null; } + const decodedMap = ensureDecodedMap(module.map, decodedMapCache); const generatedPosInModule = { - line1Based: frame.lineNumber - module.firstLine1Based + 1, - column0Based: frame.column, + line1Based: lineNumber - module.firstLine1Based + 1, + column0Based: column, }; const mappingIndex = greatestLowerBound( - module.map, + decodedMap, generatedPosInModule, (target, candidate) => { if (target.line1Based === candidate[0]) { @@ -120,7 +148,7 @@ export default async function symbolicate( if (mappingIndex == null) { return null; } - const mapping = module.map[mappingIndex]; + const mapping = decodedMap[mappingIndex]; if ( mapping[0] !== generatedPosInModule.line1Based || mapping.length < 4 /* no source line/column info */ @@ -140,7 +168,7 @@ export default async function symbolicate( module: { readonly firstLine1Based: number, readonly functionMap: ?FBSourceFunctionMap, - readonly map: Array, + readonly map: Array | VlqMap, readonly path: string, }, ): ?string { @@ -160,11 +188,6 @@ export default async function symbolicate( if (!module) { return {...frame}; } - if (!Array.isArray(module.map)) { - throw new Error( - `Unexpected module with serialized source map found: ${module.path}`, - ); - } const originalPos = findOriginalPos(frame, module); if (!originalPos) { return {...frame}; diff --git a/packages/metro/types/DeltaBundler/Serializers/getExplodedSourceMap.d.ts b/packages/metro/types/DeltaBundler/Serializers/getExplodedSourceMap.d.ts index a9d62b738d..5f4804a75b 100644 --- a/packages/metro/types/DeltaBundler/Serializers/getExplodedSourceMap.d.ts +++ b/packages/metro/types/DeltaBundler/Serializers/getExplodedSourceMap.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<623892927b76c4f68802bb69f19d9974>> + * @generated SignedSource<<2f0ab0435f64798986366df74674d02a>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro/src/DeltaBundler/Serializers/getExplodedSourceMap.js @@ -19,10 +19,11 @@ import type {Module} from '../types'; import type { FBSourceFunctionMap, MetroSourceMapSegmentTuple, + VlqMap, } from 'metro-source-map'; export type ExplodedSourceMap = ReadonlyArray<{ - readonly map: Array; + readonly map: Array | VlqMap; readonly firstLine1Based: number; readonly functionMap: null | undefined | FBSourceFunctionMap; readonly path: string; diff --git a/packages/metro/types/DeltaBundler/Serializers/helpers/getSourceMapInfo.d.ts b/packages/metro/types/DeltaBundler/Serializers/helpers/getSourceMapInfo.d.ts index e154274d74..b30c4224f8 100644 --- a/packages/metro/types/DeltaBundler/Serializers/helpers/getSourceMapInfo.d.ts +++ b/packages/metro/types/DeltaBundler/Serializers/helpers/getSourceMapInfo.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<> + * @generated SignedSource<> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro/src/DeltaBundler/Serializers/helpers/getSourceMapInfo.js @@ -19,6 +19,7 @@ import type {Module} from '../../types'; import type { FBSourceFunctionMap, MetroSourceMapSegmentTuple, + VlqMap, } from 'metro-source-map'; declare function getSourceMapInfo( @@ -29,7 +30,7 @@ declare function getSourceMapInfo( getSourceUrl: null | undefined | ((module: Module) => string); }, ): { - readonly map: Array; + readonly map: Array | VlqMap; readonly functionMap: null | undefined | FBSourceFunctionMap; readonly code: string; readonly path: string;