From a8e111d3cb1b07c75bb382e5121fa6e5d4e164a2 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Sat, 26 Sep 2026 08:53:47 +0100 Subject: [PATCH] composeSourceMaps: Keep mappings that share a generated column `composeSourceMaps` iterates the mappings of the last map, and looked each one's generated position up again in that same map before tracing it back through the earlier maps. That second lookup is redundant, except where the last map has more than one mapping at the same generated column - which `hermesc` emits (15,689 in our benchmark app's Hermes map*). There, every mapping at the column resolved to the last one, and the generator collapsed the duplicates. This traces each mapping from its own original position instead, so the composed map keeps all of them. Lookups through Metro's `Consumer` are unchanged, because it resolves a column to its last mapping. Consumers that take the first mapping at a column, like `@jridgewell/trace-mapping`, now get the first mapping's own position rather than the last's. `composeSourceMaps` with a bundle map and its Hermes map is 8.1% faster than the previous diff (95% CI 7.6-8.7%) and 2.59x faster than `main` (2.57-2.61x; 878ms -> 805ms on our benchmark app*). The output gains 13,867 mappings (+0.8%) at repeated columns and is otherwise identical. Composition's memory is within 0.4% of the previous diff (+2MB*). | | 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 | 878ms (872 to 883) | | -58.0% (-58.1 to -57.8) | 908MB (908 to 909) | | -15.1% (-15.2 to -15.0) | 1,502MB | | This diff | 805ms (802 to 811) | -8.1% (-8.7 to -7.6) | -61.4% (-61.6 to -61.1) | 911MB (910 to 912) | +0.2% (+0.1 to +0.4) | -14.9% (-15.0 to -14.7) | 1,505MB | ## Changelog ``` - **[Fix]**: `composeSourceMaps` keeps every mapping at a generated column that the last map maps more than once ``` ## Test plan The new test fails on the previous diff (only the last position survives) and passes on this one. ``` yarn jest packages/metro-source-map packages/metro-symbolicate yarn flow check yarn lint yarn jest ``` \* 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/__tests__/composeSourceMaps-test.js | 17 +++++++++++++++ .../metro-source-map/src/composeSourceMaps.js | 21 ++++++++++++++----- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/metro-source-map/src/__tests__/composeSourceMaps-test.js b/packages/metro-source-map/src/__tests__/composeSourceMaps-test.js index 7a0dab92f4..58c72f10e0 100644 --- a/packages/metro-source-map/src/__tests__/composeSourceMaps-test.js +++ b/packages/metro-source-map/src/__tests__/composeSourceMaps-test.js @@ -294,6 +294,23 @@ describe('composeSourceMaps', () => { expect(mergedMap).toEqual(fixtures['merged_1_2.json']); }); + test('keeps each mapping at a generated column mapped more than once', () => { + const mergedMap = composeSourceMaps([ + {version: 3, names: [], sources: ['a.js'], mappings: 'AAAA,KAAK'}, + {version: 3, names: [], sources: ['b.js'], mappings: 'AAAA,AAAK'}, + ]); + expect( + [...new Consumer(mergedMap).generatedMappings()].map(mapping => [ + mapping.generatedColumn, + mapping.originalLine, + mapping.originalColumn, + ]), + ).toEqual([ + [0, 1, 0], + [0, 1, 5], + ]); + }); + test('merges two maps preserving unmapped regions in the first one', () => { const mergedMap = composeSourceMaps([ fixtures['ignore_1.json'], diff --git a/packages/metro-source-map/src/composeSourceMaps.js b/packages/metro-source-map/src/composeSourceMaps.js index 812d52bcce..c512b8d844 100644 --- a/packages/metro-source-map/src/composeSourceMaps.js +++ b/packages/metro-source-map/src/composeSourceMaps.js @@ -45,12 +45,23 @@ export default function composeSourceMaps( file: consumers[0].file, }); + const earlierConsumers = consumers.slice(1); consumers[0].eachMapping(mapping => { - const original = findOriginalPosition( - consumers, - mapping.generatedLine, - mapping.generatedColumn, - ); + const original = + mapping.originalLine == null || mapping.originalColumn == null + ? {line: null, column: null, source: null, name: null} + : earlierConsumers.length === 0 + ? { + line: mapping.originalLine, + column: mapping.originalColumn, + source: mapping.source, + name: mapping.name, + } + : findOriginalPosition( + earlierConsumers, + mapping.originalLine, + mapping.originalColumn, + ); generator.addMapping({ generated: { line: mapping.generatedLine,