From de75d076d426c3bd3da164a66bfda30ec38b420d Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Sat, 26 Sep 2026 08:52:41 +0100 Subject: [PATCH 1/2] Consumer: Decode VLQ mappings in a single pass `MappingsConsumer` decoded a map by slicing each segment out as a string, decoding it with `vlq` through a `Map` cache, and yielding it from a generator that was then spread into an array. This decodes base64 VLQ in place from character codes in one pass, straight into the array. Output is unchanged, including the errors thrown for segments with two or three fields. The only difference is the message for a character that isn't base64. `metro-source-map` no longer uses `vlq`, so it's removed from its dependencies (it's still a dependency of `metro-symbolicate`). `composeSourceMaps` with a bundle map and its Hermes map is 1.63x faster than the previous diff (95% CI 1.62-1.64x) and 1.89x faster than `main` (1.88-1.90x; 1,801ms -> 1,107ms on our benchmark app*). Composition's memory falls by 16.0% (15.8-16.1%; 1,069MB -> 898MB*). The output is byte-identical. | | Time (median) | vs previous | vs `main` | Composition memory* | vs previous | vs `main` | Peak RSS | |---|---|---|---|---|---|---|---| | `main` | 2,088ms (2,082 to 2,094) | | | 1,070MB (1,070 to 1,070) | | | 1,664MB | | Previous diff | 1,801ms (1,794 to 1,806) | | -13.6% (-13.8 to -13.3) | 1,069MB (1,069 to 1,069) | | -0.1% (-0.2 to -0.1) | 1,663MB | | This diff | 1,107ms (1,099 to 1,111) | -38.6% (-38.9 to -38.3) | -47.1% (-47.4 to -46.7) | 898MB (897 to 899) | -16.0% (-16.1 to -15.8) | -15.9% (-16.1 to -15.6) | 1,492MB | ## Changelog ``` - **[Performance]**: Decode source maps in one pass in `metro-source-map`'s `Consumer` ``` ## Test plan New tests cover multi-digit and negative deltas, empty segments and lines, and malformed segments. They pass on `main` and on this diff. ``` yarn jest packages/metro-source-map packages/metro-symbolicate yarn flow check yarn lint ``` \* Benchmark: Mattermost Mobile 2.45.0 (React Native 0.83.9, Metro 0.83.7), iOS release bundle, unminified: 7,086 sources, 52.6MB bundle, 82.4MB flat source map. Composed with its `hermesc -O -output-source-map` map (hermes-compiler 0.14.1; 10.7MB, 1.84M segments). Timings are `composeSourceMaps` alone, excluding JSON parsing, over 105 rounds on Node 22.13.1 on an M5 Pro; each round runs `main`, every diff in this stack and a memory baseline, in a shuffled order. Figures are medians with 95% bootstrap confidence intervals - for changes, of the per-round paired ratio. Composition memory is peak RSS less that of the same process parsing both maps without composing them (594MB). --- packages/metro-source-map/package.json | 3 +- .../src/Consumer/MappingsConsumer.js | 152 ++++++++++-------- .../src/__tests__/Consumer-test.js | 58 +++++++ 3 files changed, 145 insertions(+), 68 deletions(-) diff --git a/packages/metro-source-map/package.json b/packages/metro-source-map/package.json index 7f72e7002b..c9807f9bbc 100644 --- a/packages/metro-source-map/package.json +++ b/packages/metro-source-map/package.json @@ -25,8 +25,7 @@ "metro-symbolicate": "0.87.1", "nullthrows": "^1.1.1", "ob1": "0.87.1", - "source-map": "^0.5.6", - "vlq": "^1.0.0" + "source-map": "^0.5.6" }, "license": "MIT", "devDependencies": { diff --git a/packages/metro-source-map/src/Consumer/MappingsConsumer.js b/packages/metro-source-map/src/Consumer/MappingsConsumer.js index 54777ef14a..9aac849aca 100644 --- a/packages/metro-source-map/src/Consumer/MappingsConsumer.js +++ b/packages/metro-source-map/src/Consumer/MappingsConsumer.js @@ -30,7 +30,23 @@ import normalizeSourcePath from './normalizeSourcePath'; import {greatestLowerBound} from './search'; import invariant from 'invariant'; import {add, add0, get0, inc, sub} from 'ob1'; -import {decode as decodeVlq} from 'vlq'; + +/* eslint-disable no-bitwise */ + +const COMMA = 44; // ',' +const SEMICOLON = 59; // ';' +const VLQ_BASE_SHIFT = 5; +const VLQ_BASE_MASK = (1 << VLQ_BASE_SHIFT) - 1; +const VLQ_CONTINUATION_BIT = 1 << VLQ_BASE_SHIFT; +const BASE64_DECODE: Int8Array = (() => { + const table = new Int8Array(128).fill(-1); + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + for (let i = 0; i < chars.length; i++) { + table[chars.charCodeAt(i)] = i; + } + return table; +})(); /** * A source map consumer that supports "basic" source maps (that have a @@ -93,7 +109,11 @@ export default class MappingsConsumer return {...EMPTY_POSITION}; } - *_decodeMappings(): Generator { + _decodeMappings(): Array { + const normalizedSources = this._normalizeAndCacheSources(); + const {mappings: mappingsRaw, names} = this._sourceMap; + const result: Array = []; + let generatedLine = FIRST_LINE; let generatedColumn = FIRST_COLUMN; let originalLine = FIRST_LINE; @@ -101,77 +121,77 @@ export default class MappingsConsumer let nameIndex = add0(0); let sourceIndex = add0(0); - const normalizedSources = this._normalizeAndCacheSources(); - - const {mappings: mappingsRaw, names} = this._sourceMap; - let next; - const vlqCache = new Map(); - for (let i = 0; i < mappingsRaw.length; i = next) { - switch (mappingsRaw[i]) { - case ';': + // The VLQ fields of the segment being decoded, decoded in place from + // character codes rather than by slicing out each segment. + const fields = [0, 0, 0, 0, 0]; + let fieldCount = 0; + let value = 0; + let shift = 0; + + const length = mappingsRaw.length; + for (let i = 0; i <= length; i++) { + const charCode = i < length ? mappingsRaw.charCodeAt(i) : SEMICOLON; + if (charCode === COMMA || charCode === SEMICOLON) { + // A trailing field cut off mid-VLQ is dropped, as `vlq` does. + invariant( + fieldCount > 0 || shift === 0, + 'Invalid generated column delta', + ); + value = 0; + shift = 0; + if (fieldCount > 0) { + invariant(fieldCount !== 2, 'Invalid original line delta'); + invariant(fieldCount !== 3, 'Invalid original column delta'); + generatedColumn = add(generatedColumn, fields[0]); + let source = null; + let name = null; + let mappingOriginalLine = null; + let mappingOriginalColumn = null; + if (fieldCount >= 4) { + sourceIndex = add(sourceIndex, fields[1]); + source = normalizedSources[get0(sourceIndex)]; + originalLine = add(originalLine, fields[2]); + originalColumn = add(originalColumn, fields[3]); + mappingOriginalLine = originalLine; + mappingOriginalColumn = originalColumn; + if (fieldCount >= 5) { + nameIndex = add(nameIndex, fields[4]); + name = names[get0(nameIndex)]; + } + } + result.push({ + generatedLine, + generatedColumn, + source, + name, + originalLine: mappingOriginalLine, + originalColumn: mappingOriginalColumn, + }); + fieldCount = 0; + } + if (charCode === SEMICOLON && i < length) { generatedLine = inc(generatedLine); generatedColumn = FIRST_COLUMN; - /* falls through */ - case ',': - next = i + 1; - continue; - } - findNext: for (next = i + 1; next < mappingsRaw.length; ++next) { - switch (mappingsRaw[next]) { - case ';': - /* falls through */ - case ',': - break findNext; } + continue; } - const mappingRaw = mappingsRaw.slice(i, next); - let decodedVlqValues; - if (vlqCache.has(mappingRaw)) { - decodedVlqValues = vlqCache.get(mappingRaw); + const digit = charCode < 128 ? BASE64_DECODE[charCode] : -1; + invariant(digit !== -1, 'Invalid character in source map mappings'); + value = value + ((digit & VLQ_BASE_MASK) << shift); + if (digit & VLQ_CONTINUATION_BIT) { + shift = shift + VLQ_BASE_SHIFT; } else { - decodedVlqValues = decodeVlq(mappingRaw); - vlqCache.set(mappingRaw, decodedVlqValues); - } - invariant(Array.isArray(decodedVlqValues), 'Decoding VLQ tuple failed'); - const [ - generatedColumnDelta, - sourceIndexDelta, - originalLineDelta, - originalColumnDelta, - nameIndexDelta, - ] = decodedVlqValues; - invariant(generatedColumnDelta != null, 'Invalid generated column delta'); - generatedColumn = add(generatedColumn, generatedColumnDelta); - const mapping: {...Mapping, ...} = { - generatedLine, - generatedColumn, - source: null, - name: null, - originalLine: null, - originalColumn: null, - }; - - if (sourceIndexDelta != null) { - sourceIndex = add(sourceIndex, sourceIndexDelta); - mapping.source = normalizedSources[get0(sourceIndex)]; - - invariant(originalLineDelta != null, 'Invalid original line delta'); - invariant(originalColumnDelta != null, 'Invalid original column delta'); - - originalLine = add(originalLine, originalLineDelta); - originalColumn = add(originalColumn, originalColumnDelta); - - mapping.originalLine = originalLine; - mapping.originalColumn = originalColumn; - - if (nameIndexDelta != null) { - nameIndex = add(nameIndex, nameIndexDelta); - mapping.name = names[get0(nameIndex)]; + const negate = value & 1; + value = value >> 1; + if (fieldCount < 5) { + fields[fieldCount] = negate ? -value : value; } + fieldCount++; + value = 0; + shift = 0; } - - yield mapping; } + return result; } _normalizeAndCacheSources(): ReadonlyArray { @@ -185,7 +205,7 @@ export default class MappingsConsumer _decodeAndCacheMappings(): ReadonlyArray { if (!this._decodedMappings) { - this._decodedMappings = [...this._decodeMappings()]; + this._decodedMappings = this._decodeMappings(); } return this._decodedMappings; } diff --git a/packages/metro-source-map/src/__tests__/Consumer-test.js b/packages/metro-source-map/src/__tests__/Consumer-test.js index 9a5e2baf8c..bb9aa64b78 100644 --- a/packages/metro-source-map/src/__tests__/Consumer-test.js +++ b/packages/metro-source-map/src/__tests__/Consumer-test.js @@ -217,6 +217,64 @@ describe('basic maps', () => { ] `); }); + + test('multi-digit and negative deltas, empty segments and lines', () => { + expect([ + ...new Consumer({ + version: 3, + mappings: 'AAAU,gBCoBHC,,w+B;;KDjBg9D', + names: ['name0', 'name1'], + sources: ['source0', 'source1'], + }).generatedMappings(), + ]).toEqual([ + { + generatedLine: 1, + generatedColumn: 0, + source: 'source0', + name: null, + originalLine: 1, + originalColumn: 10, + }, + { + generatedLine: 1, + generatedColumn: 16, + source: 'source1', + name: 'name1', + originalLine: 21, + originalColumn: 7, + }, + { + generatedLine: 1, + generatedColumn: 1016, + source: null, + name: null, + originalLine: null, + originalColumn: null, + }, + { + generatedLine: 3, + generatedColumn: 5, + source: 'source0', + name: null, + originalLine: 4, + originalColumn: 2007, + }, + ]); + }); + + test.each([ + ['AA', 'Invalid original line delta'], + ['AAAA,AAA', 'Invalid original column delta'], + ['AAAA,A!AA', undefined], + ])('throws on malformed mappings %s', (mappings, message) => { + const consumer = new Consumer({ + version: 3, + mappings, + names: [], + sources: ['source0'], + }); + expect(() => [...consumer.generatedMappings()]).toThrow(message); + }); }); describe('sourceContentFor', () => { From bd8a725a803e5a09e3c656892aaedd60e82a7620 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:14:54 +0100 Subject: [PATCH 2/2] Change right shift operator to zero-fill right shift Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/metro-source-map/src/Consumer/MappingsConsumer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/metro-source-map/src/Consumer/MappingsConsumer.js b/packages/metro-source-map/src/Consumer/MappingsConsumer.js index 9aac849aca..f9c9b7cc08 100644 --- a/packages/metro-source-map/src/Consumer/MappingsConsumer.js +++ b/packages/metro-source-map/src/Consumer/MappingsConsumer.js @@ -182,7 +182,7 @@ export default class MappingsConsumer shift = shift + VLQ_BASE_SHIFT; } else { const negate = value & 1; - value = value >> 1; + value = value >>> 1; if (fieldCount < 5) { fields[fieldCount] = negate ? -value : value; }