From 2eedbbe318764a63b50b4a8cdf2b660deef4d510 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Sat, 26 Sep 2026 09:14:25 +0100 Subject: [PATCH] composeSourceMaps: Encode the composed map directly `composeSourceMaps` built its output with `source-map`'s `SourceMapGenerator`, which allocates an object per mapping (on top of the three `composeSourceMaps` passed to each `addMapping`), validates each one, and serialises by concatenating a small string per VLQ field. On a large map that was most of the remaining time. This collects the composed mappings into parallel arrays, assigning source and name indices as they're added, and encodes them with Metro's own `B64Builder`. The output is unchanged: sources and names are indexed in order of first appearance, a mapping identical to the one before it is dropped, mappings that arrive out of order are sorted with the same comparator, and an invalid mapping throws the same error. `composeSourceMaps` with a bundle map and its Hermes map is 20.6% faster than the previous diff (95% CI 19.7-21.2%) and 3.25x faster than `main` (3.23-3.26x; 805ms -> 646ms on our benchmark app*), with byte-identical output and no change in memory (0MB, 95% CI -4 to 3MB*). | | 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 | 805ms (802 to 811) | | -61.4% (-61.6 to -61.1) | 911MB (910 to 912) | | -14.9% (-15.0 to -14.7) | 1,505MB | | This diff | 646ms (641 to 650) | -20.6% (-21.2 to -19.7) | -69.2% (-69.3 to -69.0) | 914MB (907 to 915) | 0.0% (-0.4 to +0.3) | -14.8% (-15.2 to -14.6) | 1,508MB | ## Changelog ``` - **[Performance]**: `composeSourceMaps` encodes its output directly rather than through `source-map` ``` ## Test plan Compared against the previous diff on 40,000 randomly generated compositions of one to three maps, including repeated and out-of-order mappings, names, unmapped positions and invalid mappings. Every output and every error message is identical. ``` yarn jest 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). --- .../metro-source-map/src/composeSourceMaps.js | 230 +++++++++++++++--- 1 file changed, 194 insertions(+), 36 deletions(-) diff --git a/packages/metro-source-map/src/composeSourceMaps.js b/packages/metro-source-map/src/composeSourceMaps.js index c512b8d844..a0e8967c5c 100644 --- a/packages/metro-source-map/src/composeSourceMaps.js +++ b/packages/metro-source-map/src/composeSourceMaps.js @@ -10,11 +10,17 @@ */ import type {SourcePosition} from './Consumer/types'; -import type {IConsumer, MixedSourceMap} from './source-map'; +import type { + FBSourcesArray, + HermesFunctionOffsets, + IConsumer, + MixedSourceMap, +} from './source-map'; import type {Number0, Number1} from 'ob1'; +import B64Builder from './B64Builder'; import Consumer from './Consumer'; -import {SourceMapGenerator} from 'source-map'; +import {get0, get1} from 'ob1'; // TODO(t67648443): Bypass the `sort-requires` rule for this file because of a dependency cycle. Consumer; @@ -41,45 +47,189 @@ export default function composeSourceMaps( }) .reverse(); - const generator = new SourceMapGenerator({ - file: consumers[0].file, - }); + const sources: Array = []; + const names: Array = []; + const sourceIndices: Map = new Map(); + const nameIndices: Map = new Map(); + + // The composed mappings as parallel arrays, in the order the last map + // yields them. An unmapped position has source index -1 and original + // line and column 0. + const generatedLines: Array = []; + const generatedColumns: Array = []; + const mappingSources: Array = []; + const originalLines: Array = []; + const originalColumns: Array = []; + const mappingNames: Array = []; + let sorted = true; const earlierConsumers = consumers.slice(1); consumers[0].eachMapping(mapping => { - 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, - column: mapping.generatedColumn, - }, - original: - original.line != null - ? { - line: original.line, - column: original.column, - } - : null, - source: original.source, - name: original.name, - }); + let originalLine: ?number = null; + let originalColumn: ?number = null; + let source: ?string = null; + let name: ?string = null; + const mappingOriginalLine = mapping.originalLine; + const mappingOriginalColumn = mapping.originalColumn; + if (mappingOriginalLine != null && mappingOriginalColumn != null) { + if (earlierConsumers.length === 0) { + originalLine = get1(mappingOriginalLine); + originalColumn = get0(mappingOriginalColumn); + source = mapping.source; + name = mapping.name; + } else { + const original = findOriginalPosition( + earlierConsumers, + mappingOriginalLine, + mappingOriginalColumn, + ); + originalLine = original.line; + originalColumn = original.column; + source = original.source; + name = original.name; + } + } + const generatedLine = get1(mapping.generatedLine); + const generatedColumn = get0(mapping.generatedColumn); + const hasOriginal = originalLine != null; + if (!( + generatedLine > 0 && + generatedColumn >= 0 && + (originalLine != null + ? originalLine > 0 && (originalColumn ?? 0) >= 0 && source + : !source && !name) + )) { + throw new Error( + 'Invalid mapping: ' + + JSON.stringify({ + generated: {line: generatedLine, column: generatedColumn}, + source, + original: hasOriginal + ? {line: originalLine, column: originalColumn} + : null, + name, + }), + ); + } + + const count = generatedLines.length; + if ( + count > 0 && + (generatedLine < generatedLines[count - 1] || + (generatedLine === generatedLines[count - 1] && + generatedColumn < generatedColumns[count - 1])) + ) { + sorted = false; + } + generatedLines.push(generatedLine); + generatedColumns.push(generatedColumn); + if (originalLine != null && source != null) { + let sourceIndex = sourceIndices.get(source); + if (sourceIndex == null) { + sourceIndex = sources.length; + sources.push(source); + sourceIndices.set(source, sourceIndex); + } + mappingSources.push(sourceIndex); + originalLines.push(originalLine); + originalColumns.push(originalColumn ?? 0); + } else { + mappingSources.push(-1); + originalLines.push(0); + originalColumns.push(0); + } + if (name != null) { + let nameIndex = nameIndices.get(name); + if (nameIndex == null) { + nameIndex = names.length; + names.push(name); + nameIndices.set(name, nameIndex); + } + mappingNames.push(nameIndex); + } else { + mappingNames.push(-1); + } }); - const composedMap = generator.toJSON(); + // Orders two composed mappings by generated position, then source, + // original position and name. + const compare = (a: number, b: number): number => + generatedLines[a] - generatedLines[b] || + generatedColumns[a] - generatedColumns[b] || + compareStrings( + mappingSources[a] === -1 ? null : sources[mappingSources[a]], + mappingSources[b] === -1 ? null : sources[mappingSources[b]], + ) || + originalLines[a] - originalLines[b] || + originalColumns[a] - originalColumns[b] || + compareStrings( + mappingNames[a] === -1 ? null : names[mappingNames[a]], + mappingNames[b] === -1 ? null : names[mappingNames[b]], + ); + + const count = generatedLines.length; + let order: ?Array = null; + if (!sorted) { + order = Array.from({length: count}, (_, i) => i); + order.sort(compare); + } + + // Encode the mappings, dropping any identical to the one before it. + const builder = new B64Builder(); + let previousIndex = -1; + let previousGeneratedLine = 1; + let previousGeneratedColumn = 0; + let previousSource = 0; + let previousOriginalLine = 0; + let previousOriginalColumn = 0; + let previousName = 0; + for (let position = 0; position < count; position++) { + const index = order == null ? position : order[position]; + const generatedLine = generatedLines[index]; + if (generatedLine !== previousGeneratedLine) { + builder.markLines(generatedLine - previousGeneratedLine); + previousGeneratedLine = generatedLine; + previousGeneratedColumn = 0; + } else if (previousIndex !== -1 && compare(index, previousIndex) === 0) { + previousIndex = index; + continue; + } + previousIndex = index; + + builder.startSegment(generatedColumns[index] - previousGeneratedColumn); + previousGeneratedColumn = generatedColumns[index]; + const sourceIndex = mappingSources[index]; + if (sourceIndex !== -1) { + builder.append(sourceIndex - previousSource); + previousSource = sourceIndex; + // Original lines are 0-based in the encoding. + builder.append(originalLines[index] - 1 - previousOriginalLine); + previousOriginalLine = originalLines[index] - 1; + builder.append(originalColumns[index] - previousOriginalColumn); + previousOriginalColumn = originalColumns[index]; + const nameIndex = mappingNames[index]; + if (nameIndex !== -1) { + builder.append(nameIndex - previousName); + previousName = nameIndex; + } + } + } + + const composedMap: { + version: number, + sources: Array, + names: Array, + mappings: string, + file?: string, + sourcesContent?: Array, + x_facebook_sources?: FBSourcesArray, + x_hermes_function_offsets?: HermesFunctionOffsets, + x_google_ignoreList?: Array, + } = {version: 3, sources, names, mappings: builder.toString()}; + const {file} = consumers[0]; + if (file != null) { + composedMap.file = file; + } composedMap.sourcesContent = composedMap.sources.map(source => consumers[consumers.length - 1].sourceContentFor(source, true), @@ -103,6 +253,14 @@ export default function composeSourceMaps( return composedMap; } +function compareStrings(a: ?string, b: ?string): number { + if (a === b) { + return 0; + } + // $FlowFixMe[invalid-compare] Matches `source-map`'s ordering, null included + return a > b ? 1 : -1; +} + function findOriginalPosition( consumers: ReadonlyArray, generatedLine: Number1,