Skip to content

Commit 94d95ac

Browse files
robhoganfacebook-github-bot
authored andcommitted
Optionally store source maps as VLQ-encoded (1/2): Type widening, consumer support
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<string>}`) as an alternative to the current `Array<MetroSourceMapSegmentTuple>` 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
1 parent 72c909c commit 94d95ac

6 files changed

Lines changed: 391 additions & 61 deletions

File tree

packages/metro-source-map/src/__tests__/source-map-test.js

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,16 @@
99
* @oncall react_native
1010
*/
1111

12+
import type {MetroSourceMapSegmentTuple} from '../source-map';
13+
1214
import Generator from '../Generator';
13-
import {fromRawMappings, toBabelSegments, toSegmentTuple} from '../source-map';
15+
import {
16+
fromRawMappings,
17+
isVlqMap,
18+
toBabelSegments,
19+
toSegmentTuple,
20+
vlqMapFromTuples,
21+
} from '../source-map';
1422

1523
describe('flattening mappings / compacting', () => {
1624
test('flattens simple mappings', () => {
@@ -167,3 +175,172 @@ describe('build map from raw mappings', () => {
167175
});
168176

169177
const lines = (n: number) => Array(n).join('\n');
178+
179+
function makeVlqMap(
180+
mappings: string,
181+
names: ReadonlyArray<string>,
182+
): {readonly mappings: string, readonly names: ReadonlyArray<string>} {
183+
return {
184+
mappings,
185+
names,
186+
};
187+
}
188+
189+
describe('isVlqMap', () => {
190+
test('returns false for null', () => {
191+
expect(isVlqMap(null)).toBe(false);
192+
});
193+
194+
test('returns false for tuple array', () => {
195+
expect(isVlqMap([[1, 2, 3, 4]])).toBe(false);
196+
});
197+
198+
test('returns true for VlqMap', () => {
199+
expect(isVlqMap(makeVlqMap('AAAA', []))).toBe(true);
200+
});
201+
202+
test('returns false for plain object without string mappings', () => {
203+
// $FlowFixMe[incompatible-type] Testing runtime behavior with invalid type
204+
expect(isVlqMap({mappings: 123, names: []})).toBe(false);
205+
});
206+
});
207+
208+
describe('fromRawMappings with VlqMap', () => {
209+
// Shared tuple definitions. We build two parallel module lists from these —
210+
// one storing decoded tuples, one storing the equivalent VLQ — and assert the
211+
// serialized flat map is byte-identical, i.e. VLQ storage is transparent.
212+
const tuples0: Array<MetroSourceMapSegmentTuple> = [
213+
[1, 2],
214+
[3, 4, 5, 6, 'apples'],
215+
[7, 8, 9, 10],
216+
[11, 12, 13, 14, 'pears'],
217+
];
218+
const tuples1: Array<MetroSourceMapSegmentTuple> = [
219+
[1, 2],
220+
[3, 4, 15, 16, 'bananas'],
221+
];
222+
223+
const tupleModules = [
224+
{
225+
code: lines(11),
226+
functionMap: {names: ['<global>'], mappings: 'AAA'},
227+
map: tuples0,
228+
source: 'code1',
229+
path: 'path1',
230+
isIgnored: false,
231+
},
232+
{
233+
code: lines(3),
234+
functionMap: null,
235+
map: tuples1,
236+
source: 'code2',
237+
path: 'path2',
238+
isIgnored: true,
239+
},
240+
];
241+
242+
const vlqModules = [
243+
{...tupleModules[0], map: vlqMapFromTuples(tuples0)},
244+
{...tupleModules[1], map: vlqMapFromTuples(tuples1)},
245+
];
246+
247+
test('produces a flat (non-indexed) map for VlqMap inputs', () => {
248+
const map = fromRawMappings(vlqModules).toMap();
249+
expect(typeof map.mappings).toBe('string');
250+
expect(map.sources).toEqual(['path1', 'path2']);
251+
expect(map.version).toBe(3);
252+
});
253+
254+
test('VlqMap input serializes byte-identically to tuple input', () => {
255+
expect(fromRawMappings(vlqModules).toString()).toBe(
256+
fromRawMappings(tupleModules).toString(),
257+
);
258+
expect(fromRawMappings(vlqModules).toMap()).toEqual(
259+
fromRawMappings(tupleModules).toMap(),
260+
);
261+
});
262+
263+
test('preserves functionMap and ignoreList from VlqMap modules', () => {
264+
const map = fromRawMappings(vlqModules).toMap();
265+
expect(map.x_facebook_sources).toEqual([
266+
[{names: ['<global>'], mappings: 'AAA'}],
267+
null,
268+
]);
269+
expect(map.x_google_ignoreList).toEqual([1]);
270+
});
271+
272+
test('handles mixed tuple and VlqMap modules identically to all-tuple', () => {
273+
const mixed = [tupleModules[0], vlqModules[1]];
274+
expect(fromRawMappings(mixed).toString()).toBe(
275+
fromRawMappings(tupleModules).toString(),
276+
);
277+
});
278+
279+
test('applies offsetLines identically for VlqMap and tuple inputs', () => {
280+
expect(fromRawMappings(vlqModules, 8).toString()).toBe(
281+
fromRawMappings(tupleModules, 8).toString(),
282+
);
283+
});
284+
285+
test('excludeSource option omits sourcesContent', () => {
286+
const map = fromRawMappings(vlqModules).toMap(undefined, {
287+
excludeSource: true,
288+
});
289+
expect(map.sourcesContent).toBeUndefined();
290+
});
291+
});
292+
293+
describe('vlqMapFromTuples', () => {
294+
// Decode via Metro's existing string->tuples path, the inverse of
295+
// vlqMapFromTuples.
296+
const decode = (vlqMap: {
297+
readonly mappings: string,
298+
readonly names: ReadonlyArray<string>,
299+
}) =>
300+
toBabelSegments({
301+
version: 3,
302+
sources: [''],
303+
names: [...vlqMap.names],
304+
mappings: vlqMap.mappings,
305+
}).map(toSegmentTuple);
306+
307+
test('encodes tuples into a VlqMap', () => {
308+
const vlqMap = vlqMapFromTuples([
309+
[1, 2],
310+
[3, 4, 5, 6, 'apples'],
311+
[7, 8, 9, 10],
312+
[11, 12, 13, 14, 'pears'],
313+
]);
314+
expect(isVlqMap(vlqMap)).toBe(true);
315+
expect(typeof vlqMap.mappings).toBe('string');
316+
expect(vlqMap.names).toEqual(['apples', 'pears']);
317+
});
318+
319+
test('round-trips via toBabelSegments + toSegmentTuple', () => {
320+
const tuples = [
321+
[1, 2],
322+
[3, 4, 5, 6, 'apples'],
323+
[7, 8, 9, 10],
324+
[11, 12, 13, 14, 'pears'],
325+
[11, 20, 30, 40],
326+
];
327+
expect(decode(vlqMapFromTuples(tuples))).toEqual(tuples);
328+
});
329+
330+
test('round-trips multi-line, multi-segment maps', () => {
331+
const tuples = [
332+
[1, 0, 1, 0],
333+
[1, 8, 1, 4, 'foo'],
334+
[2, 0, 2, 0],
335+
[3, 4, 3, 2, 'bar'],
336+
[5, 0],
337+
];
338+
expect(decode(vlqMapFromTuples(tuples))).toEqual(tuples);
339+
});
340+
341+
test('encodes an empty map', () => {
342+
const vlqMap = vlqMapFromTuples([]);
343+
expect(vlqMap.mappings).toBe('');
344+
expect(decode(vlqMap)).toEqual([]);
345+
});
346+
});

packages/metro-source-map/src/source-map.js

Lines changed: 69 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
generateFunctionMap,
2222
} from './generateFunctionMap';
2323
import Generator from './Generator';
24+
import nullthrows from 'nullthrows';
2425
// $FlowFixMe[untyped-import] - source-map
2526
import SourceMap from 'source-map';
2627

@@ -53,6 +54,11 @@ export type BabelDecodedMap = {
5354
...
5455
};
5556

57+
export type VlqMap = {
58+
readonly mappings: string,
59+
readonly names: ReadonlyArray<string>,
60+
};
61+
5662
export type HermesFunctionOffsets = {[number]: ReadonlyArray<number>, ...};
5763

5864
export type FBSourcesArray = ReadonlyArray<?FBSourceMetadata>;
@@ -123,18 +129,26 @@ type SourceMapConsumerMapping = {
123129
name: ?string,
124130
};
125131

132+
export type RawMappingsModule = {
133+
readonly map: ?ReadonlyArray<MetroSourceMapSegmentTuple> | VlqMap,
134+
readonly functionMap: ?FBSourceFunctionMap,
135+
readonly path: string,
136+
readonly source: string,
137+
readonly code: string,
138+
readonly isIgnored: boolean,
139+
readonly lineCount?: number,
140+
};
141+
142+
function isVlqMap(
143+
map: ?ReadonlyArray<MetroSourceMapSegmentTuple> | VlqMap,
144+
): implies map is VlqMap {
145+
return map != null && !Array.isArray(map) && typeof map.mappings === 'string';
146+
}
147+
126148
function fromRawMappingsImpl(
127149
isBlocking: boolean,
128150
onDone: Generator => void,
129-
modules: ReadonlyArray<{
130-
readonly map: ?ReadonlyArray<MetroSourceMapSegmentTuple>,
131-
readonly functionMap: ?FBSourceFunctionMap,
132-
readonly path: string,
133-
readonly source: string,
134-
readonly code: string,
135-
readonly isIgnored: boolean,
136-
readonly lineCount?: number,
137-
}>,
151+
modules: ReadonlyArray<RawMappingsModule>,
138152
offsetLines: number,
139153
): void {
140154
const modulesToProcess = modules.slice();
@@ -146,15 +160,18 @@ function fromRawMappingsImpl(
146160
return true;
147161
}
148162

149-
const mod = modulesToProcess.shift();
150-
// $FlowFixMe[incompatible-use]
163+
const mod = nullthrows(modulesToProcess.shift());
151164
const {code, map} = mod;
152-
if (Array.isArray(map)) {
153-
// $FlowFixMe[incompatible-type]
165+
if (isVlqMap(map)) {
166+
// Modules may store their map compactly as VLQ. Decode it back to tuples
167+
// just-in-time so it can be folded into the flat Generator like any other
168+
// module. Decoding one module at a time keeps the transient tuple arrays
169+
// short-lived, preserving the memory win of VLQ storage.
170+
addMappingsForFile(generator, decodeVlqMap(map), mod, carryOver);
171+
} else if (Array.isArray(map)) {
154172
addMappingsForFile(generator, map, mod, carryOver);
155173
} else if (map != null) {
156174
throw new Error(
157-
// $FlowFixMe[incompatible-use]
158175
`Unexpected module with full source map found: ${mod.path}`,
159176
);
160177
}
@@ -197,15 +214,7 @@ function fromRawMappingsImpl(
197214
* the resulting bundle, e.g. by some prefix code.
198215
*/
199216
function fromRawMappings(
200-
modules: ReadonlyArray<{
201-
readonly map: ?ReadonlyArray<MetroSourceMapSegmentTuple>,
202-
readonly functionMap: ?FBSourceFunctionMap,
203-
readonly path: string,
204-
readonly source: string,
205-
readonly code: string,
206-
readonly isIgnored: boolean,
207-
readonly lineCount?: number,
208-
}>,
217+
modules: ReadonlyArray<RawMappingsModule>,
209218
offsetLines: number = 0,
210219
): Generator {
211220
let generator: void | Generator;
@@ -224,15 +233,7 @@ function fromRawMappings(
224233
}
225234

226235
async function fromRawMappingsNonBlocking(
227-
modules: ReadonlyArray<{
228-
readonly map: ?ReadonlyArray<MetroSourceMapSegmentTuple>,
229-
readonly functionMap: ?FBSourceFunctionMap,
230-
readonly path: string,
231-
readonly source: string,
232-
readonly code: string,
233-
readonly isIgnored: boolean,
234-
readonly lineCount?: number,
235-
}>,
236+
modules: ReadonlyArray<RawMappingsModule>,
236237
offsetLines: number = 0,
237238
): Promise<Generator> {
238239
return new Promise(resolve => {
@@ -344,16 +345,8 @@ function tuplesFromBabelDecodedMap(
344345

345346
function addMappingsForFile(
346347
generator: Generator,
347-
mappings: Array<MetroSourceMapSegmentTuple>,
348-
module: {
349-
readonly code: string,
350-
readonly functionMap: ?FBSourceFunctionMap,
351-
readonly map: ?Array<MetroSourceMapSegmentTuple>,
352-
readonly path: string,
353-
readonly source: string,
354-
readonly isIgnored: boolean,
355-
readonly lineCount?: number,
356-
},
348+
mappings: ReadonlyArray<MetroSourceMapSegmentTuple>,
349+
module: RawMappingsModule,
357350
carryOver: number,
358351
) {
359352
generator.startFile(module.path, module.source, module.functionMap, {
@@ -400,6 +393,38 @@ const newline = /\r\n?|\n|\u2028|\u2029/g;
400393
const countLines = (string: string): number =>
401394
(string.match(newline) || []).length + 1;
402395

396+
/**
397+
* Decodes a compact VLQ map back into raw mapping tuples — the inverse of
398+
* `vlqMapFromTuples`, reusing Metro's existing source-map consumer.
399+
*/
400+
function decodeVlqMap(vlqMap: VlqMap): Array<MetroSourceMapSegmentTuple> {
401+
return toBabelSegments({
402+
version: 3,
403+
sources: [''],
404+
names: [...vlqMap.names],
405+
mappings: vlqMap.mappings,
406+
}).map(toSegmentTuple);
407+
}
408+
409+
/**
410+
* Encodes raw mapping tuples into a compact VLQ `mappings` string + `names`
411+
* table. Decode the inverse via `decodeVlqMap` (or `toBabelSegments` +
412+
* `toSegmentTuple`). Storing maps in this form uses far less memory than the
413+
* equivalent decoded tuple arrays.
414+
*/
415+
function vlqMapFromTuples(
416+
mappings: ReadonlyArray<MetroSourceMapSegmentTuple>,
417+
): VlqMap {
418+
const generator = new Generator();
419+
generator.startFile('', '', null);
420+
for (const mapping of mappings) {
421+
addMapping(generator, mapping, 0);
422+
}
423+
generator.endFile();
424+
const map = generator.toMap();
425+
return {mappings: map.mappings, names: map.names};
426+
}
427+
403428
export {
404429
BundleBuilder,
405430
composeSourceMaps,
@@ -409,10 +434,12 @@ export {
409434
fromRawMappings,
410435
fromRawMappingsNonBlocking,
411436
functionMapBabelPlugin,
437+
isVlqMap,
412438
normalizeSourcePath,
413439
toBabelSegments,
414440
toSegmentTuple,
415441
tuplesFromBabelDecodedMap,
442+
vlqMapFromTuples,
416443
};
417444

418445
/**

packages/metro/src/DeltaBundler/Serializers/getExplodedSourceMap.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@ import type {Module} from '../types';
1313
import type {
1414
FBSourceFunctionMap,
1515
MetroSourceMapSegmentTuple,
16+
VlqMap,
1617
} from 'metro-source-map';
1718

1819
import {getJsOutput, isJsModule} from './helpers/js';
1920

2021
export type ExplodedSourceMap = ReadonlyArray<{
21-
readonly map: Array<MetroSourceMapSegmentTuple>,
22+
readonly map: Array<MetroSourceMapSegmentTuple> | VlqMap,
2223
readonly firstLine1Based: number,
2324
readonly functionMap: ?FBSourceFunctionMap,
2425
readonly path: string,

0 commit comments

Comments
 (0)