From 566d9cb316a4402af3a0605e3aed8be50b1fcdff Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Sat, 26 Sep 2026 08:53:02 +0100 Subject: [PATCH] Consumer: Search only the target line in originalPositionFor `MappingsConsumer#originalPositionFor` binary-searched every mapping in the map, comparing line and then column through a closure. This records the index of the first mapping on each generated line while decoding, and searches only the target line's mappings by column. Results are unchanged, including for a column before the first mapping on its line (unmapped) and for several mappings at the same generated column (the last wins). `composeSourceMaps` with a bundle map and its Hermes map is 20.5% faster than the previous diff (95% CI 20.1-21.1%) and 2.38x faster than `main` (2.37-2.39x; 1,107ms -> 878ms on our benchmark app*), with byte-identical output. Composition's memory rises by 1.2% (1.0-1.3%, +11MB*), about 9MB of which is the per-line index (1.18M generated lines in our benchmark app's bundle map). | | 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,107ms (1,099 to 1,111) | | -47.1% (-47.4 to -46.7) | 898MB (897 to 899) | | -15.9% (-16.1 to -15.6) | 1,492MB | | This diff | 878ms (872 to 883) | -20.5% (-21.1 to -20.1) | -58.0% (-58.1 to -57.8) | 908MB (908 to 909) | +1.2% (+1.0 to +1.3) | -15.1% (-15.2 to -15.0) | 1,502MB | ## Changelog ``` - **[Performance]**: Faster position lookups in `metro-source-map`'s `Consumer` ``` ## Test plan New tests cover a column before a line's first mapping and repeated generated columns. 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). --- .../src/Consumer/MappingsConsumer.js | 46 ++++++++++++------- .../src/__tests__/Consumer-test.js | 27 +++++++++++ 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/packages/metro-source-map/src/Consumer/MappingsConsumer.js b/packages/metro-source-map/src/Consumer/MappingsConsumer.js index f9c9b7cc08..de04febce6 100644 --- a/packages/metro-source-map/src/Consumer/MappingsConsumer.js +++ b/packages/metro-source-map/src/Consumer/MappingsConsumer.js @@ -27,9 +27,8 @@ import { lookupBiasToString, } from './constants'; import normalizeSourcePath from './normalizeSourcePath'; -import {greatestLowerBound} from './search'; import invariant from 'invariant'; -import {add, add0, get0, inc, sub} from 'ob1'; +import {add, add0, get0, get1, inc} from 'ob1'; /* eslint-disable no-bitwise */ @@ -58,12 +57,16 @@ export default class MappingsConsumer { _sourceMap: BasicSourceMap; _decodedMappings: ?ReadonlyArray; + // The index in `_decodedMappings` of the first mapping on each generated + // line (0-based), so a lookup only searches the mappings on its line. + _lineStarts: ?ReadonlyArray; _normalizedSources: ?ReadonlyArray; constructor(sourceMap: BasicSourceMap) { super(sourceMap); this._sourceMap = sourceMap; this._decodedMappings = null; + this._lineStarts = null; this._normalizedSources = null; } @@ -84,21 +87,27 @@ export default class MappingsConsumer ); } const mappings = this._decodeAndCacheMappings(); - const index = greatestLowerBound( - mappings, - {line, column}, - (position, mapping) => { - if (position.line === mapping.generatedLine) { - return get0(sub(position.column, mapping.generatedColumn)); - } - return get0(sub(position.line, mapping.generatedLine)); - }, - ); - if ( - index != null && - mappings[index].generatedLine === generatedPosition.line - ) { - const mapping = mappings[index]; + const lineStarts = this._lineStarts; + invariant(lineStarts != null, 'Expected line starts to be decoded'); + const line0 = get1(line) - 1; + if (line0 < 0 || line0 >= lineStarts.length) { + return {...EMPTY_POSITION}; + } + // The last mapping on the line at or before `column`. + let low = lineStarts[line0]; + let high = + line0 + 1 < lineStarts.length ? lineStarts[line0 + 1] : mappings.length; + const lineStart = low; + while (low < high) { + const mid = (low + high) >>> 1; + if (get0(mappings[mid].generatedColumn) <= get0(column)) { + low = mid + 1; + } else { + high = mid; + } + } + if (low > lineStart) { + const mapping = mappings[low - 1]; return { source: mapping.source, name: mapping.name, @@ -113,6 +122,7 @@ export default class MappingsConsumer const normalizedSources = this._normalizeAndCacheSources(); const {mappings: mappingsRaw, names} = this._sourceMap; const result: Array = []; + const lineStarts = [0]; let generatedLine = FIRST_LINE; let generatedColumn = FIRST_COLUMN; @@ -170,6 +180,7 @@ export default class MappingsConsumer fieldCount = 0; } if (charCode === SEMICOLON && i < length) { + lineStarts.push(result.length); generatedLine = inc(generatedLine); generatedColumn = FIRST_COLUMN; } @@ -191,6 +202,7 @@ export default class MappingsConsumer shift = 0; } } + this._lineStarts = lineStarts; return result; } diff --git a/packages/metro-source-map/src/__tests__/Consumer-test.js b/packages/metro-source-map/src/__tests__/Consumer-test.js index bb9aa64b78..cbd235e2d6 100644 --- a/packages/metro-source-map/src/__tests__/Consumer-test.js +++ b/packages/metro-source-map/src/__tests__/Consumer-test.js @@ -133,6 +133,33 @@ describe('basic maps', () => { } `); }); + + test('a column before the first mapping on its line is unmapped', () => { + const consumer = new Consumer({ + version: 3, + mappings: 'AAAA;KAAC', + names: [], + sources: ['source0'], + }); + expect( + consumer.originalPositionFor({line: add1(1), column: add0(4)}), + ).toEqual({source: null, name: null, line: null, column: null}); + expect( + consumer.originalPositionFor({line: add1(1), column: add0(5)}), + ).toEqual({source: 'source0', name: null, line: 1, column: 1}); + }); + + test('mappings at the same generated column resolve to the last', () => { + const consumer = new Consumer({ + version: 3, + mappings: 'AAAA,AACA', + names: [], + sources: ['source0'], + }); + expect( + consumer.originalPositionFor({line: add1(0), column: add0(0)}), + ).toEqual({source: 'source0', name: null, line: 2, column: 0}); + }); }); describe('generatedMappings()', () => {