From 1eacda85bf9e52b17e3371d3fd6925a78d1d646f Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Wed, 24 Jun 2026 09:01:28 -0700 Subject: [PATCH 1/3] Memory profiling harness and baseline investigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Scripts and findings for profiling Metro's memory and CPU during bundling, and an end-to-end benchmark of the compact VLQ source-map work stacked on top. **Methodology:** - Start Metro with `NODE_ARGS="--expose-gc --inspect=9230" DEV=1 js1 run --prefetch=false` - WildeBundle URL: `GET http://localhost:8081/xplat/js/RKJSModules/EntryPoints/WildeBundle.bundle?platform=ios&dev=true&app=com.facebook.Wilde` - RSS profiling via /proc, heap snapshots via Chrome DevTools Protocol - Graph freed via DELETE to the bundle URL (same as fill-http-cache) **Scripts added:** - `fb-metro-cli/memory-investigation/heap-profile.js` — Automated CDP-based profiler: captures 3 heap snapshots (baseline, post-build, post-delete) and compares them - `fb-metro-cli/memory-investigation/heap-compare.js` — Standalone snapshot comparator with streaming parser for multi-GB .heapsnapshot files - `fb-metro-cli/memory-investigation/heap-injector.js` — Optional in-process module exposing /memory, /gc, /snapshot HTTP endpoints - `metro/scripts/profile-memory.sh` — Quick RSS-only profiling via /proc - `fb-metro-cli/memory-investigation/compact-bench-measure.js` — One measurement cycle: builds WildeBundle, then requests WildeBundle.map, recording memory (RSS/heap) + build CPU + .map serialize CPU via CDP - `fb-metro-cli/memory-investigation/run-compact-bench.sh` — Orchestrator: fresh Metro per repeat across three configs (base / compact_flat / compact_indexed), cold or warm cache - `fb-metro-cli/memory-investigation/compact-bench-stats.js` — Welch t-test analysis between any two configs - `fb-metro-cli/memory-investigation/README.md`, `compact-sourcemaps-benchmark-results.md` — Full writeup of methodology and results **Baseline results (WildeBundle, June 2025):** - Startup: 819 MB RSS / 426 MB heap used - Post-build: 2,338 MB RSS / 1,549 MB heap used (+1,122 MB heap) - Post-delete: 507 MB heap used (DELETE frees 93% of build growth) - Arrays dominate: 10M Array objects + backing stores = 858 MB (77% of growth) - Source maps stored as decoded number-tuple arrays are the primary consumer: ~678 MB, 60% of build growth (9,866,476 tuples across 16,562 modules) **Compact source maps — end-to-end benchmark (n=3, WildeBundle):** Three configs: `base` (decoded tuples), `compact_flat` (VLQ storage, flat .map), `compact_indexed` (VLQ storage, indexed passthrough .map). - Memory (both compact configs): heap −51% cold / −53% warm; RSS −48% (1654→810 MB heap cold; all Welch p < 1e-5). - Build CPU: unchanged cold; ~20% faster warm with compact storage. - Serialize CPU (`.map` request): `compact_flat` +18% vs base (decode + re-encode), `compact_indexed` −49% vs base (passthrough). Flat .map is byte-identical to base; indexed .map is +3.4% larger. Bundle output byte-identical across all configs. Full tables in `compact-sourcemaps-benchmark-results.md`. Differential Revision: D107879392 --- scripts/profile-memory.sh | 223 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100755 scripts/profile-memory.sh diff --git a/scripts/profile-memory.sh b/scripts/profile-memory.sh new file mode 100755 index 0000000000..f89d7111c2 --- /dev/null +++ b/scripts/profile-memory.sh @@ -0,0 +1,223 @@ +#!/bin/bash +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +# Metro Memory Profiling Harness +# +# Measures RSS before, during, and after building a bundle. +# +# Usage: +# 1. Start Metro in another terminal: +# NODE_ARGS="--expose-gc" DEV=1 js1 run --prefetch=false +# +# 2. Run this script (default: WildeBundle on port 8081): +# ./profile-memory.sh +# +# Options: +# --port=PORT Metro port (default: 8081) +# --bundle=PATH Bundle path (default: WildeBundle) +# --platform=PLAT Platform (default: ios) +# --app=APP App identifier (default: com.facebook.Wilde) +# --no-delete Skip the DELETE request (keep graph in memory) +# --repeat=N Build N times to measure steady-state (default: 1) + +set -euo pipefail + +PORT=8081 +BUNDLE_PATH="xplat/js/RKJSModules/EntryPoints/WildeBundle.bundle" +PLATFORM="ios" +APP="com.facebook.Wilde" +DO_DELETE=true +REPEAT=1 + +for arg in "$@"; do + case $arg in + --port=*) PORT="${arg#*=}" ;; + --bundle=*) BUNDLE_PATH="${arg#*=}" ;; + --platform=*) PLATFORM="${arg#*=}" ;; + --app=*) APP="${arg#*=}" ;; + --no-delete) DO_DELETE=false ;; + --repeat=*) REPEAT="${arg#*=}" ;; + --help) + sed -n '2,/^$/p' "$0" + exit 0 + ;; + *) echo "Unknown option: $arg"; exit 1 ;; + esac +done + +BUNDLE_URL="http://localhost:$PORT/$BUNDLE_PATH?platform=$PLATFORM&dev=true&app=$APP" +STATUS_URL="http://localhost:$PORT/status" + +BOLD='\033[1m' +DIM='\033[2m' +NC='\033[0m' + +find_metro_pid() { + pgrep -f "[f]b-metro-cli/index.js" 2>/dev/null | head -1 || true +} + +read_rss_mb() { + awk '/^VmRSS:/ {printf "%d", $2/1024}' /proc/"$1"/status 2>/dev/null +} + +read_hwm_mb() { + awk '/^VmHWM:/ {printf "%d", $2/1024}' /proc/"$1"/status 2>/dev/null +} + +print_proc_memory() { + local pid=$1 + echo " VmRSS (current resident): $(awk '/^VmRSS:/ {printf "%d MB", $2/1024}' /proc/"$pid"/status)" + echo " VmHWM (peak resident): $(awk '/^VmHWM:/ {printf "%d MB", $2/1024}' /proc/"$pid"/status)" + echo " VmSize (virtual): $(awk '/^VmSize:/ {printf "%d MB", $2/1024}' /proc/"$pid"/status)" + echo " VmData (heap+data): $(awk '/^VmData:/ {printf "%d MB", $2/1024}' /proc/"$pid"/status)" +} + +echo -e "${BOLD}Metro Memory Profiler${NC}" +echo "" + +# Find Metro +METRO_PID=$(find_metro_pid) +if [ -z "$METRO_PID" ]; then + echo "Metro is not running. Start it first:" + echo "" + echo ' NODE_ARGS="--expose-gc" DEV=1 js1 run --prefetch=false' + echo "" + echo "For V8 heap inspection via Chrome DevTools, add --inspect:" + echo "" + echo ' NODE_ARGS="--expose-gc --inspect=9230" DEV=1 js1 run --prefetch=false' + echo " Then open chrome://inspect and connect to the Metro process." + exit 1 +fi +echo "Metro PID: $METRO_PID" + +# Wait for ready +echo -n "Waiting for Metro... " +READY=false +for _ in $(seq 1 120); do + if curl -s --connect-timeout 2 "$STATUS_URL" 2>/dev/null | grep -q "packager-status:running"; then + READY=true + echo "ready" + break + fi + sleep 1 +done +if [ "$READY" = false ]; then + echo "timed out after 120s" + exit 1 +fi + +# Baseline +echo "" +echo -e "${BOLD}Baseline (startup complete, no bundles loaded)${NC}" +BASELINE_RSS=$(read_rss_mb "$METRO_PID") +print_proc_memory "$METRO_PID" + +# Start background sampler +SAMPLE_FILE=$(mktemp /tmp/metro-mem-XXXXXX.csv) +echo "epoch_s,rss_mb" > "$SAMPLE_FILE" +( + while kill -0 "$METRO_PID" 2>/dev/null; do + rss=$(read_rss_mb "$METRO_PID") + [ -n "$rss" ] && echo "$(date +%s),$rss" >> "$SAMPLE_FILE" + sleep 1 + done +) & +SAMPLER_PID=$! +trap 'kill "$SAMPLER_PID" 2>/dev/null; wait "$SAMPLER_PID" 2>/dev/null || true' EXIT + +for iteration in $(seq 1 "$REPEAT"); do + if [ "$REPEAT" -gt 1 ]; then + echo "" + echo -e "${BOLD}=== Iteration $iteration / $REPEAT ===${NC}" + fi + + # Build + echo "" + echo -e "${BOLD}Building bundle${NC}" + echo -e "${DIM} $BUNDLE_URL${NC}" + BUILD_START=$(date +%s) + HTTP_OUT=$(curl -sS -o /dev/null -w "%{http_code}\t%{time_total}\t%{size_download}" "$BUNDLE_URL" 2>&1) + BUILD_END=$(date +%s) + + HTTP_CODE=$(echo "$HTTP_OUT" | cut -f1) + HTTP_TIME=$(echo "$HTTP_OUT" | cut -f2) + HTTP_SIZE=$(echo "$HTTP_OUT" | cut -f3) + + echo " HTTP $HTTP_CODE in ${HTTP_TIME}s, $(echo "$HTTP_SIZE" | awk '{printf "%.1f MB", $1/1048576}') (wall: $((BUILD_END - BUILD_START))s)" + + if [ "$HTTP_CODE" != "200" ]; then + echo " Bundle build failed (HTTP $HTTP_CODE). Check Metro logs." + echo " Try the URL in a browser to see the error:" + echo " $BUNDLE_URL" + kill "$SAMPLER_PID" 2>/dev/null + exit 1 + fi + + sleep 2 + echo "" + echo -e "${BOLD}Post-build${NC}" + POSTBUILD_RSS=$(read_rss_mb "$METRO_PID") + print_proc_memory "$METRO_PID" + + # Delete graph + if [ "$DO_DELETE" = true ]; then + echo "" + echo -e "${BOLD}After DELETE (graph freed)${NC}" + curl -sS -X DELETE "$BUNDLE_URL" > /dev/null 2>&1 + sleep 2 + POSTDELETE_RSS=$(read_rss_mb "$METRO_PID") + print_proc_memory "$METRO_PID" + else + POSTDELETE_RSS=$POSTBUILD_RSS + fi +done + +# Stop sampler +kill "$SAMPLER_PID" 2>/dev/null || true +wait "$SAMPLER_PID" 2>/dev/null || true +trap - EXIT + +# Peak from samples (HWM from /proc is more reliable than 1s polling) +PEAK_RSS=$(read_hwm_mb "$METRO_PID") +[ -z "$PEAK_RSS" ] && PEAK_RSS=$POSTBUILD_RSS + +# Summary +echo "" +echo -e "${BOLD}Summary${NC}" +echo "-------" +printf " %-30s %6s MB\n" "Baseline RSS:" "$BASELINE_RSS" +printf " %-30s %6s MB\n" "Peak RSS (sampled @1s):" "$PEAK_RSS" +printf " %-30s %6s MB\n" "Post-build RSS:" "$POSTBUILD_RSS" +if [ "$DO_DELETE" = true ]; then + printf " %-30s %6s MB\n" "Post-delete RSS:" "$POSTDELETE_RSS" +fi +echo "" +printf " %-30s %+6d MB\n" "Growth (build):" "$((POSTBUILD_RSS - BASELINE_RSS))" +if [ "$DO_DELETE" = true ]; then + printf " %-30s %+6d MB\n" "Retained after delete:" "$((POSTDELETE_RSS - BASELINE_RSS))" +fi +echo "" + +# Save report +REPORT="/tmp/metro-memory-$(date +%Y%m%d-%H%M%S).txt" +{ + echo "Metro Memory Profile — $(date)" + echo "Bundle: $BUNDLE_PATH ($PLATFORM, app=$APP)" + echo "PID: $METRO_PID" + echo "" + echo "Baseline RSS: ${BASELINE_RSS} MB" + echo "Peak RSS: ${PEAK_RSS} MB" + echo "Post-build RSS: ${POSTBUILD_RSS} MB" + echo "Post-delete RSS: ${POSTDELETE_RSS} MB" + echo "Build growth: $((POSTBUILD_RSS - BASELINE_RSS)) MB" + echo "Retained: $((POSTDELETE_RSS - BASELINE_RSS)) MB" + echo "" + echo "Samples (${SAMPLE_FILE}):" + cat "$SAMPLE_FILE" 2>/dev/null || echo "(no samples)" +} > "$REPORT" + +echo "Report: $REPORT" +echo "Samples: $SAMPLE_FILE" From e1a932e01106b19887037f8eacc0c1919f295ea7 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Wed, 24 Jun 2026 09:01:28 -0700 Subject: [PATCH 2/3] Derive source-map tuples from Babel's decoded map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: The transform worker built its source-map tuples via `result.rawMappings.map(toSegmentTuple)`. Accessing `result.rawMappings` forces `babel/generator` to run a second decode (`allMappings`) that allocates a flat array of ~4-5 objects per segment — even though Babel *already* computed an equivalent decoded map (`result.decodedMap`, the jridgewell/gen-mapping decoded format) eagerly during generation and Metro was discarding it. This swaps the source to `result.decodedMap` via a new `tuplesFromBabelDecodedMap` (decoded source lines are 0-based -> +1, name indices resolved against `decodedMap.names`). Output is byte-identical to `result.rawMappings.map(toSegmentTuple)`, and it eliminates the redundant `allMappings` decode for *every* build (not just compact source maps). This is a standalone, unconditional improvement, so it sits first in the stack ahead of the compact-source-map work, which builds on it. - `metro-source-map`: add `BabelDecodedMap` type + `tuplesFromBabelDecodedMap`. - `metro-transform-worker`: source tuples from `result.decodedMap`. - `babel_v7.x.x` libdef: add `decodedMap` to `GeneratorResult`. Microbenchmark (real `babel/generator` 7.29.1, 133 modules / ~30.6K segments, `--expose-gc`, median of 11): `generate()` alone 20.2 ms; `generate()` + access `decodedMap` 19.2 ms (~0 delta — it's a sunk, eager cost); `generate()` + access `rawMappings` 28.8 ms (+8.6 ms) with ~40% more heap (19.5 vs 13.9 MB). So consuming `decodedMap` drops the `rawMappings`/`allMappings` decode entirely. (`decodedMap` is eager in 7.29.1; even if a future Babel makes it lazy it allocates arrays-of-numbers vs `rawMappings`' nested objects, so it stays <=.) ## E2E benchmark — cold WildeBundle (this diff vs baseline = parent) Interleaved, paired A/B: each of 12 rounds runs one cold build per cell — {baseline, this diff} x {child-process workers, worker threads} — so slow machine drift is shared within each round and cancels in the per-round delta. Fresh Metro per build, transform cache wiped (cold), `maxWorkers=16`, default path (no compact source maps). "Transform CPU" = total user+sys CPU across the whole worker process tree; "tree RSS" = whole-tree resident set (captures workers in both modes); "graph heap" = main-isolate heapUsed post-build (the retained module graph). base/this-diff columns are medians; Δ is the paired mean with a 95% CI (Student-t, 11 df); "n.s." = CI includes 0. Child-process workers (Metro default; 12 paired rounds): | metric | baseline | this diff | Δ (95% CI) | |---|---|---|---| | transform CPU (s) | 625 | 612 | **-16.6 (-2.6%) [-24.7, -8.5]** | | build wall (s) | 65.9 | 65.6 | -0.5 (-0.7%) n.s. | | transient tree RSS (GB) | 15.8 | 16.0 | +0.06, n.s. | | post-build tree RSS (GB) | 15.1 | 15.1 | +0.08, n.s. | | graph heap, main isolate (GB) | 1.59 | 1.59 | ~0, n.s. | Worker threads (`unstable_workerThreads`; 12 paired rounds): | metric | baseline | this diff | Δ (95% CI) | |---|---|---|---| | transform CPU (s) | 664 | 653 | -18.6 (-2.8%) [-37.5, +0.3] | | build wall (s) | 59.8 | 59.5 | -1.2 (-1.9%) n.s. | | transient RSS (GB) | 13.2 | 12.7 | -0.46 (-3.5%) [-0.81, -0.11] | | post-build RSS (GB) | 12.3 | 11.9 | -0.45 (-3.7%) [-0.80, -0.10] | | graph heap, main isolate (GB) | 1.60 | 1.60 | ~0, n.s. | Takeaways: - **Transform CPU drops ~2.6-2.8%, equally in both worker modes** — the point estimates (-16.6 s child-process, -18.6 s threads) agree to within 2 s and their CIs overlap almost entirely, so there is no real asymmetry. This is exactly what the mechanism predicts: the optimization runs *inside* the worker (consume `decodedMap` instead of forcing the `rawMappings`/`allMappings` decode), so the saving is identical whether the worker is a child process or a thread. (An earlier small-n pass suggested a child-process-only win; that was sampling noise — threads-mode CPU is just noisier, SD 30 s vs 13 s, which only widens its CI without moving the point estimate.) - Build wall time is ~1-2% lower in both modes but within noise — the CPU saving is spread across 16 workers, so it moves the critical path little. - Main-isolate post-build heap (the retained graph of stored tuples) is unchanged in every config — no memory regression, byte-identical output. - Transient/post tree RSS shows a ~0.5 GB (~3.5%) reduction that is resolvable only in the lower-variance threads configuration; the noisier child-process configuration (RSS ~16 GB, CI half-width ~0.3 GB) cannot corroborate it, so treat it as suggestive, not established. Harness: `memory-investigation/run-worker-bench-ab.sh` (interleaved A/B) + `worker-bench-measure.js` + `worker-bench-stats.js` (paired CIs), in the base diff of this stack. Worker-threads mode under `js1 run` is GK-gated (`metro_worker_threads`); benched via a local `FORCE_WORKER_THREADS` override (not committed). Reviewed By: huntie, GijsWeterings Differential Revision: D108506323 --- packages/metro-source-map/src/source-map.js | 64 +++++++++++++++++ .../metro-source-map/types/source-map.d.ts | 24 ++++++- .../tuplesFromBabelDecodedMap-test.js | 68 +++++++++++++++++++ packages/metro-transform-worker/src/index.js | 8 ++- 4 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 packages/metro-transform-worker/src/__tests__/tuplesFromBabelDecodedMap-test.js diff --git a/packages/metro-source-map/src/source-map.js b/packages/metro-source-map/src/source-map.js index 3a5f282111..b0f98553e9 100644 --- a/packages/metro-source-map/src/source-map.js +++ b/packages/metro-source-map/src/source-map.js @@ -35,6 +35,24 @@ export type MetroSourceMapSegmentTuple = | SourceMapping | GeneratedCodeMapping; +// A single segment of a standard "decoded" source map (as produced by +// `@babel/generator`'s `result.decodedMap` / `@jridgewell/gen-mapping`), +// grouped by generated line. All fields are 0-based, including the source line +// (unlike Metro's `MetroSourceMapSegmentTuple`, whose source line is 1-based): +// [generatedColumn] +// [generatedColumn, sourceIndex, sourceLine, sourceColumn] +// [generatedColumn, sourceIndex, sourceLine, sourceColumn, nameIndex] +type BabelDecodedMapSegment = + | [number] + | [number, number, number, number] + | [number, number, number, number, number]; + +export type BabelDecodedMap = { + readonly mappings: ReadonlyArray>, + readonly names: ReadonlyArray, + ... +}; + export type HermesFunctionOffsets = {[number]: ReadonlyArray, ...}; export type FBSourcesArray = ReadonlyArray; @@ -279,6 +297,51 @@ function toSegmentTuple( return [line, column, original.line, original.column, name]; } +/** + * Converts a Babel/gen-mapping "decoded" source map (`result.decodedMap` from + * `@babel/generator`) into raw mapping tuples, byte-identical to + * `result.rawMappings.map(toSegmentTuple)`. + * + * Preferred over `result.rawMappings` because `decodedMap` is computed eagerly + * during generation, whereas accessing `rawMappings` triggers a second decode + * (`allMappings`) that allocates ~4-5 objects per segment. No terminating + * mapping is appended (callers that need one use `countLinesAndTerminateMap`). + */ +function tuplesFromBabelDecodedMap( + decodedMap: BabelDecodedMap, +): Array { + const {mappings, names} = decodedMap; + const tuples: Array = []; + for (let line = 0, n = mappings.length; line < n; ++line) { + // Decoded mappings are grouped by generated line (0-based); tuples use + // 1-based generated lines. + const generatedLine = line + 1; + const segments = mappings[line]; + for (let i = 0, m = segments.length; i < m; ++i) { + const segment = segments[i]; + switch (segment.length) { + case 1: + tuples.push([generatedLine, segment[0]]); + break; + case 4: + // Decoded source lines are 0-based; tuples use 1-based source lines. + tuples.push([generatedLine, segment[0], segment[2] + 1, segment[3]]); + break; + case 5: + tuples.push([ + generatedLine, + segment[0], + segment[2] + 1, + segment[3], + names[segment[4]], + ]); + break; + } + } + } + return tuples; +} + function addMappingsForFile( generator: Generator, mappings: Array, @@ -349,6 +412,7 @@ export { normalizeSourcePath, toBabelSegments, toSegmentTuple, + tuplesFromBabelDecodedMap, }; /** diff --git a/packages/metro-source-map/types/source-map.d.ts b/packages/metro-source-map/types/source-map.d.ts index cadc3b0109..e7f58e0c6c 100644 --- a/packages/metro-source-map/types/source-map.d.ts +++ b/packages/metro-source-map/types/source-map.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<7303fe7149cb12d764c6106cdf4f49ee>> + * @generated SignedSource<> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-source-map/src/source-map.js @@ -35,6 +35,14 @@ export type MetroSourceMapSegmentTuple = | SourceMappingWithName | SourceMapping | GeneratedCodeMapping; +type BabelDecodedMapSegment = + | [number] + | [number, number, number, number] + | [number, number, number, number, number]; +export type BabelDecodedMap = { + readonly mappings: ReadonlyArray>; + readonly names: ReadonlyArray; +}; export type HermesFunctionOffsets = { [$$Key$$: number]: ReadonlyArray; }; @@ -125,6 +133,19 @@ declare function toBabelSegments( declare function toSegmentTuple( mapping: BabelSourceMapSegment, ): MetroSourceMapSegmentTuple; +/** + * Converts a Babel/gen-mapping "decoded" source map (`result.decodedMap` from + * `@babel/generator`) into raw mapping tuples, byte-identical to + * `result.rawMappings.map(toSegmentTuple)`. + * + * Preferred over `result.rawMappings` because `decodedMap` is computed eagerly + * during generation, whereas accessing `rawMappings` triggers a second decode + * (`allMappings`) that allocates ~4-5 objects per segment. No terminating + * mapping is appended (callers that need one use `countLinesAndTerminateMap`). + */ +declare function tuplesFromBabelDecodedMap( + decodedMap: BabelDecodedMap, +): Array; export { BundleBuilder, composeSourceMaps, @@ -137,6 +158,7 @@ export { normalizeSourcePath, toBabelSegments, toSegmentTuple, + tuplesFromBabelDecodedMap, }; /** * Backwards-compatibility with CommonJS consumers using interopRequireDefault. diff --git a/packages/metro-transform-worker/src/__tests__/tuplesFromBabelDecodedMap-test.js b/packages/metro-transform-worker/src/__tests__/tuplesFromBabelDecodedMap-test.js new file mode 100644 index 0000000000..eae81bf8a2 --- /dev/null +++ b/packages/metro-transform-worker/src/__tests__/tuplesFromBabelDecodedMap-test.js @@ -0,0 +1,68 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +'use strict'; + +import generate from '@babel/generator'; +import * as babylon from '@babel/parser'; +import {toSegmentTuple, tuplesFromBabelDecodedMap} from 'metro-source-map'; + +// The transform worker derives source-map tuples from Babel's eagerly-computed +// `result.decodedMap` instead of triggering the more expensive `rawMappings` +// (`allMappings`) decode. This must be byte-identical to the previous +// `result.rawMappings.map(toSegmentTuple)`. +const SAMPLES = [ + `function foo(aaa, bbb) { + const ccc = aaa + bbb; + return ccc * 2; +} +class Bar extends Foo { + method(xxx) { + return this.value + xxx; + } +} +export default function entry(items) { + const obj = {a: 1, b: 2, c: [1, 2, 3]}; + return items.map(x => x.value).filter(Boolean); +} +`, + `const x = require('foo');\nmodule.exports = (a, b) => { let s = 0; for (let i = 0; i < a.length; i++) { s += a[i] * b; } return s; };\n`, + `// header\nconst y = 1;\n\n\nfunction z() { return y; }\n`, + `const w = 42; const v = w + 1; export {w, v};`, + `1 + 1;\n`, +]; + +describe('tuplesFromBabelDecodedMap', () => { + test.each(SAMPLES.map((code, i) => [i, code]))( + 'is byte-identical to rawMappings.map(toSegmentTuple) [sample %i]', + (_i, code) => { + const ast = babylon.parse(code, {sourceType: 'unambiguous'}); + const result = generate( + ast, + {sourceMaps: true, sourceFileName: 'file.js'}, + code, + ); + const fromRaw = (result.rawMappings ?? []).map(toSegmentTuple); + const fromDecoded = tuplesFromBabelDecodedMap( + nullthrowsLocal(result.decodedMap), + ); + expect(fromDecoded).toEqual(fromRaw); + expect(fromDecoded.length).toBeGreaterThan(0); + }, + ); +}); + +function nullthrowsLocal(x: ?T): T { + if (x == null) { + throw new Error('Expected decodedMap to be present'); + } + return x; +} diff --git a/packages/metro-transform-worker/src/index.js b/packages/metro-transform-worker/src/index.js index e6fb462232..ea93e87de4 100644 --- a/packages/metro-transform-worker/src/index.js +++ b/packages/metro-transform-worker/src/index.js @@ -46,6 +46,7 @@ import { functionMapBabelPlugin, toBabelSegments, toSegmentTuple, + tuplesFromBabelDecodedMap, } from 'metro-source-map'; import metroTransformPlugins from 'metro-transform-plugins'; import collectDependencies from 'metro/private/ModuleGraph/worker/collectDependencies'; @@ -471,7 +472,12 @@ async function transformJS( file.code, ); - let map = result.rawMappings ? result.rawMappings.map(toSegmentTuple) : []; + // Derive tuples from Babel's eagerly-computed decoded map rather than + // `result.rawMappings`, which would trigger a second, more expensive decode + // (`allMappings`). Byte-identical to `result.rawMappings.map(toSegmentTuple)`. + let map = result.decodedMap + ? tuplesFromBabelDecodedMap(result.decodedMap) + : []; let code = result.code; if (minify) { From 65dd902140a1800f269c70a8a209a0c4520fc325 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Wed, 24 Jun 2026 09:01:28 -0700 Subject: [PATCH 3/3] Optionally store source maps as VLQ-encoded (1/2): Type widening, consumer support (#1742) 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}`) as an alternative to the current `Array` 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 --- .../src/__tests__/source-map-test.js | 179 +++++++++++++++++- packages/metro-source-map/src/source-map.js | 111 +++++++---- .../metro-source-map/types/source-map.d.ts | 51 +++-- .../Serializers/getExplodedSourceMap.js | 3 +- .../Serializers/helpers/getSourceMapInfo.js | 3 +- .../src/Server/__tests__/symbolicate-test.js | 101 ++++++++++ packages/metro/src/Server/symbolicate.js | 55 ++++-- .../Serializers/getExplodedSourceMap.d.ts | 5 +- .../Serializers/helpers/getSourceMapInfo.d.ts | 5 +- 9 files changed, 429 insertions(+), 84 deletions(-) create mode 100644 packages/metro/src/Server/__tests__/symbolicate-test.js diff --git a/packages/metro-source-map/src/__tests__/source-map-test.js b/packages/metro-source-map/src/__tests__/source-map-test.js index 664163966c..9497eb249a 100644 --- a/packages/metro-source-map/src/__tests__/source-map-test.js +++ b/packages/metro-source-map/src/__tests__/source-map-test.js @@ -9,8 +9,16 @@ * @oncall react_native */ +import type {MetroSourceMapSegmentTuple} from '../source-map'; + import Generator from '../Generator'; -import {fromRawMappings, toBabelSegments, toSegmentTuple} from '../source-map'; +import { + fromRawMappings, + isVlqMap, + toBabelSegments, + toSegmentTuple, + vlqMapFromTuples, +} from '../source-map'; describe('flattening mappings / compacting', () => { test('flattens simple mappings', () => { @@ -167,3 +175,172 @@ describe('build map from raw mappings', () => { }); const lines = (n: number) => Array(n).join('\n'); + +function makeVlqMap( + mappings: string, + names: ReadonlyArray, +): {readonly mappings: string, readonly names: ReadonlyArray} { + return { + mappings, + names, + }; +} + +describe('isVlqMap', () => { + test('returns false for null', () => { + expect(isVlqMap(null)).toBe(false); + }); + + test('returns false for tuple array', () => { + expect(isVlqMap([[1, 2, 3, 4]])).toBe(false); + }); + + test('returns true for VlqMap', () => { + expect(isVlqMap(makeVlqMap('AAAA', []))).toBe(true); + }); + + test('returns false for plain object without string mappings', () => { + // $FlowFixMe[incompatible-type] Testing runtime behavior with invalid type + expect(isVlqMap({mappings: 123, names: []})).toBe(false); + }); +}); + +describe('fromRawMappings with VlqMap', () => { + // Shared tuple definitions. We build two parallel module lists from these — + // one storing decoded tuples, one storing the equivalent VLQ — and assert the + // serialized flat map is byte-identical, i.e. VLQ storage is transparent. + const tuples0: Array = [ + [1, 2], + [3, 4, 5, 6, 'apples'], + [7, 8, 9, 10], + [11, 12, 13, 14, 'pears'], + ]; + const tuples1: Array = [ + [1, 2], + [3, 4, 15, 16, 'bananas'], + ]; + + const tupleModules = [ + { + code: lines(11), + functionMap: {names: [''], mappings: 'AAA'}, + map: tuples0, + source: 'code1', + path: 'path1', + isIgnored: false, + }, + { + code: lines(3), + functionMap: null, + map: tuples1, + source: 'code2', + path: 'path2', + isIgnored: true, + }, + ]; + + const vlqModules = [ + {...tupleModules[0], map: vlqMapFromTuples(tuples0)}, + {...tupleModules[1], map: vlqMapFromTuples(tuples1)}, + ]; + + test('produces a flat (non-indexed) map for VlqMap inputs', () => { + const map = fromRawMappings(vlqModules).toMap(); + expect(typeof map.mappings).toBe('string'); + expect(map.sources).toEqual(['path1', 'path2']); + expect(map.version).toBe(3); + }); + + test('VlqMap input serializes byte-identically to tuple input', () => { + expect(fromRawMappings(vlqModules).toString()).toBe( + fromRawMappings(tupleModules).toString(), + ); + expect(fromRawMappings(vlqModules).toMap()).toEqual( + fromRawMappings(tupleModules).toMap(), + ); + }); + + test('preserves functionMap and ignoreList from VlqMap modules', () => { + const map = fromRawMappings(vlqModules).toMap(); + expect(map.x_facebook_sources).toEqual([ + [{names: [''], mappings: 'AAA'}], + null, + ]); + expect(map.x_google_ignoreList).toEqual([1]); + }); + + test('handles mixed tuple and VlqMap modules identically to all-tuple', () => { + const mixed = [tupleModules[0], vlqModules[1]]; + expect(fromRawMappings(mixed).toString()).toBe( + fromRawMappings(tupleModules).toString(), + ); + }); + + test('applies offsetLines identically for VlqMap and tuple inputs', () => { + expect(fromRawMappings(vlqModules, 8).toString()).toBe( + fromRawMappings(tupleModules, 8).toString(), + ); + }); + + test('excludeSource option omits sourcesContent', () => { + const map = fromRawMappings(vlqModules).toMap(undefined, { + excludeSource: true, + }); + expect(map.sourcesContent).toBeUndefined(); + }); +}); + +describe('vlqMapFromTuples', () => { + // Decode via Metro's existing string->tuples path, the inverse of + // vlqMapFromTuples. + const decode = (vlqMap: { + readonly mappings: string, + readonly names: ReadonlyArray, + }) => + toBabelSegments({ + version: 3, + sources: [''], + names: [...vlqMap.names], + mappings: vlqMap.mappings, + }).map(toSegmentTuple); + + test('encodes tuples into a VlqMap', () => { + const vlqMap = vlqMapFromTuples([ + [1, 2], + [3, 4, 5, 6, 'apples'], + [7, 8, 9, 10], + [11, 12, 13, 14, 'pears'], + ]); + expect(isVlqMap(vlqMap)).toBe(true); + expect(typeof vlqMap.mappings).toBe('string'); + expect(vlqMap.names).toEqual(['apples', 'pears']); + }); + + test('round-trips via toBabelSegments + toSegmentTuple', () => { + const tuples = [ + [1, 2], + [3, 4, 5, 6, 'apples'], + [7, 8, 9, 10], + [11, 12, 13, 14, 'pears'], + [11, 20, 30, 40], + ]; + expect(decode(vlqMapFromTuples(tuples))).toEqual(tuples); + }); + + test('round-trips multi-line, multi-segment maps', () => { + const tuples = [ + [1, 0, 1, 0], + [1, 8, 1, 4, 'foo'], + [2, 0, 2, 0], + [3, 4, 3, 2, 'bar'], + [5, 0], + ]; + expect(decode(vlqMapFromTuples(tuples))).toEqual(tuples); + }); + + test('encodes an empty map', () => { + const vlqMap = vlqMapFromTuples([]); + expect(vlqMap.mappings).toBe(''); + expect(decode(vlqMap)).toEqual([]); + }); +}); diff --git a/packages/metro-source-map/src/source-map.js b/packages/metro-source-map/src/source-map.js index b0f98553e9..f9175a4458 100644 --- a/packages/metro-source-map/src/source-map.js +++ b/packages/metro-source-map/src/source-map.js @@ -21,6 +21,7 @@ import { generateFunctionMap, } from './generateFunctionMap'; import Generator from './Generator'; +import nullthrows from 'nullthrows'; // $FlowFixMe[untyped-import] - source-map import SourceMap from 'source-map'; @@ -53,6 +54,11 @@ export type BabelDecodedMap = { ... }; +export type VlqMap = { + readonly mappings: string, + readonly names: ReadonlyArray, +}; + export type HermesFunctionOffsets = {[number]: ReadonlyArray, ...}; export type FBSourcesArray = ReadonlyArray; @@ -123,18 +129,26 @@ type SourceMapConsumerMapping = { name: ?string, }; +export type RawMappingsModule = { + readonly map: ?ReadonlyArray | VlqMap, + readonly functionMap: ?FBSourceFunctionMap, + readonly path: string, + readonly source: string, + readonly code: string, + readonly isIgnored: boolean, + readonly lineCount?: number, +}; + +function isVlqMap( + map: ?ReadonlyArray | VlqMap, +): implies map is VlqMap { + return map != null && !Array.isArray(map) && typeof map.mappings === 'string'; +} + function fromRawMappingsImpl( isBlocking: boolean, onDone: Generator => void, - modules: ReadonlyArray<{ - readonly map: ?ReadonlyArray, - readonly functionMap: ?FBSourceFunctionMap, - readonly path: string, - readonly source: string, - readonly code: string, - readonly isIgnored: boolean, - readonly lineCount?: number, - }>, + modules: ReadonlyArray, offsetLines: number, ): void { const modulesToProcess = modules.slice(); @@ -146,15 +160,18 @@ function fromRawMappingsImpl( return true; } - const mod = modulesToProcess.shift(); - // $FlowFixMe[incompatible-use] + const mod = nullthrows(modulesToProcess.shift()); const {code, map} = mod; - if (Array.isArray(map)) { - // $FlowFixMe[incompatible-type] + if (isVlqMap(map)) { + // Modules may store their map compactly as VLQ. Decode it back to tuples + // just-in-time so it can be folded into the flat Generator like any other + // module. Decoding one module at a time keeps the transient tuple arrays + // short-lived, preserving the memory win of VLQ storage. + addMappingsForFile(generator, decodeVlqMap(map), mod, carryOver); + } else if (Array.isArray(map)) { addMappingsForFile(generator, map, mod, carryOver); } else if (map != null) { throw new Error( - // $FlowFixMe[incompatible-use] `Unexpected module with full source map found: ${mod.path}`, ); } @@ -197,15 +214,7 @@ function fromRawMappingsImpl( * the resulting bundle, e.g. by some prefix code. */ function fromRawMappings( - modules: ReadonlyArray<{ - readonly map: ?ReadonlyArray, - readonly functionMap: ?FBSourceFunctionMap, - readonly path: string, - readonly source: string, - readonly code: string, - readonly isIgnored: boolean, - readonly lineCount?: number, - }>, + modules: ReadonlyArray, offsetLines: number = 0, ): Generator { let generator: void | Generator; @@ -224,15 +233,7 @@ function fromRawMappings( } async function fromRawMappingsNonBlocking( - modules: ReadonlyArray<{ - readonly map: ?ReadonlyArray, - readonly functionMap: ?FBSourceFunctionMap, - readonly path: string, - readonly source: string, - readonly code: string, - readonly isIgnored: boolean, - readonly lineCount?: number, - }>, + modules: ReadonlyArray, offsetLines: number = 0, ): Promise { return new Promise(resolve => { @@ -344,16 +345,8 @@ function tuplesFromBabelDecodedMap( function addMappingsForFile( generator: Generator, - mappings: Array, - module: { - readonly code: string, - readonly functionMap: ?FBSourceFunctionMap, - readonly map: ?Array, - readonly path: string, - readonly source: string, - readonly isIgnored: boolean, - readonly lineCount?: number, - }, + mappings: ReadonlyArray, + module: RawMappingsModule, carryOver: number, ) { generator.startFile(module.path, module.source, module.functionMap, { @@ -400,6 +393,38 @@ const newline = /\r\n?|\n|\u2028|\u2029/g; const countLines = (string: string): number => (string.match(newline) || []).length + 1; +/** + * Decodes a compact VLQ map back into raw mapping tuples — the inverse of + * `vlqMapFromTuples`, reusing Metro's existing source-map consumer. + */ +function decodeVlqMap(vlqMap: VlqMap): Array { + return toBabelSegments({ + version: 3, + sources: [''], + names: [...vlqMap.names], + mappings: vlqMap.mappings, + }).map(toSegmentTuple); +} + +/** + * Encodes raw mapping tuples into a compact VLQ `mappings` string + `names` + * table. Decode the inverse via `decodeVlqMap` (or `toBabelSegments` + + * `toSegmentTuple`). Storing maps in this form uses far less memory than the + * equivalent decoded tuple arrays. + */ +function vlqMapFromTuples( + mappings: ReadonlyArray, +): VlqMap { + const generator = new Generator(); + generator.startFile('', '', null); + for (const mapping of mappings) { + addMapping(generator, mapping, 0); + } + generator.endFile(); + const map = generator.toMap(); + return {mappings: map.mappings, names: map.names}; +} + export { BundleBuilder, composeSourceMaps, @@ -409,10 +434,12 @@ export { fromRawMappings, fromRawMappingsNonBlocking, functionMapBabelPlugin, + isVlqMap, normalizeSourcePath, toBabelSegments, toSegmentTuple, tuplesFromBabelDecodedMap, + vlqMapFromTuples, }; /** diff --git a/packages/metro-source-map/types/source-map.d.ts b/packages/metro-source-map/types/source-map.d.ts index e7f58e0c6c..c45df1a20b 100644 --- a/packages/metro-source-map/types/source-map.d.ts +++ b/packages/metro-source-map/types/source-map.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<> + * @generated SignedSource<<313a3bbbf29c3ac69821b3124678d4e0>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-source-map/src/source-map.js @@ -43,6 +43,10 @@ export type BabelDecodedMap = { readonly mappings: ReadonlyArray>; readonly names: ReadonlyArray; }; +export type VlqMap = { + readonly mappings: string; + readonly names: ReadonlyArray; +}; export type HermesFunctionOffsets = { [$$Key$$: number]: ReadonlyArray; }; @@ -92,6 +96,20 @@ export type IndexMap = { readonly x_google_ignoreList?: void; }; export type MixedSourceMap = IndexMap | BasicSourceMap; +export type RawMappingsModule = { + readonly map: + | (null | undefined | ReadonlyArray) + | VlqMap; + readonly functionMap: null | undefined | FBSourceFunctionMap; + readonly path: string; + readonly source: string; + readonly code: string; + readonly isIgnored: boolean; + readonly lineCount?: number; +}; +declare function isVlqMap( + map: (null | undefined | ReadonlyArray) | VlqMap, +): map is VlqMap; /** * Creates a source map from modules with "raw mappings", i.e. an array of * tuples with either 2, 4, or 5 elements: @@ -100,27 +118,11 @@ export type MixedSourceMap = IndexMap | BasicSourceMap; * the resulting bundle, e.g. by some prefix code. */ declare function fromRawMappings( - modules: ReadonlyArray<{ - readonly map: null | undefined | ReadonlyArray; - readonly functionMap: null | undefined | FBSourceFunctionMap; - readonly path: string; - readonly source: string; - readonly code: string; - readonly isIgnored: boolean; - readonly lineCount?: number; - }>, + modules: ReadonlyArray, offsetLines?: number, ): Generator; declare function fromRawMappingsNonBlocking( - modules: ReadonlyArray<{ - readonly map: null | undefined | ReadonlyArray; - readonly functionMap: null | undefined | FBSourceFunctionMap; - readonly path: string; - readonly source: string; - readonly code: string; - readonly isIgnored: boolean; - readonly lineCount?: number; - }>, + modules: ReadonlyArray, offsetLines?: number, ): Promise; /** @@ -146,6 +148,15 @@ declare function toSegmentTuple( declare function tuplesFromBabelDecodedMap( decodedMap: BabelDecodedMap, ): Array; +/** + * Encodes raw mapping tuples into a compact VLQ `mappings` string + `names` + * table. Decode the inverse via `decodeVlqMap` (or `toBabelSegments` + + * `toSegmentTuple`). Storing maps in this form uses far less memory than the + * equivalent decoded tuple arrays. + */ +declare function vlqMapFromTuples( + mappings: ReadonlyArray, +): VlqMap; export { BundleBuilder, composeSourceMaps, @@ -155,10 +166,12 @@ export { fromRawMappings, fromRawMappingsNonBlocking, functionMapBabelPlugin, + isVlqMap, normalizeSourcePath, toBabelSegments, toSegmentTuple, tuplesFromBabelDecodedMap, + vlqMapFromTuples, }; /** * Backwards-compatibility with CommonJS consumers using interopRequireDefault. diff --git a/packages/metro/src/DeltaBundler/Serializers/getExplodedSourceMap.js b/packages/metro/src/DeltaBundler/Serializers/getExplodedSourceMap.js index 45c88a83a8..b1d301f3c2 100644 --- a/packages/metro/src/DeltaBundler/Serializers/getExplodedSourceMap.js +++ b/packages/metro/src/DeltaBundler/Serializers/getExplodedSourceMap.js @@ -13,12 +13,13 @@ import type {Module} from '../types'; import type { FBSourceFunctionMap, MetroSourceMapSegmentTuple, + VlqMap, } from 'metro-source-map'; import {getJsOutput, isJsModule} from './helpers/js'; export type ExplodedSourceMap = ReadonlyArray<{ - readonly map: Array, + readonly map: Array | VlqMap, readonly firstLine1Based: number, readonly functionMap: ?FBSourceFunctionMap, readonly path: string, diff --git a/packages/metro/src/DeltaBundler/Serializers/helpers/getSourceMapInfo.js b/packages/metro/src/DeltaBundler/Serializers/helpers/getSourceMapInfo.js index d70aa79ec1..8513ee4e7b 100644 --- a/packages/metro/src/DeltaBundler/Serializers/helpers/getSourceMapInfo.js +++ b/packages/metro/src/DeltaBundler/Serializers/helpers/getSourceMapInfo.js @@ -13,6 +13,7 @@ import type {Module} from '../../types'; import type { FBSourceFunctionMap, MetroSourceMapSegmentTuple, + VlqMap, } from 'metro-source-map'; import {getJsOutput} from './js'; @@ -25,7 +26,7 @@ export default function getSourceMapInfo( getSourceUrl: ?(module: Module<>) => string, }, ): { - readonly map: Array, + readonly map: Array | VlqMap, readonly functionMap: ?FBSourceFunctionMap, readonly code: string, readonly path: string, diff --git a/packages/metro/src/Server/__tests__/symbolicate-test.js b/packages/metro/src/Server/__tests__/symbolicate-test.js new file mode 100644 index 0000000000..38e26e3892 --- /dev/null +++ b/packages/metro/src/Server/__tests__/symbolicate-test.js @@ -0,0 +1,101 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {ExplodedSourceMap} from '../../DeltaBundler/Serializers/getExplodedSourceMap'; +import type {InputConfigT} from 'metro-config'; +import type {MetroSourceMapSegmentTuple, VlqMap} from 'metro-source-map'; + +import symbolicate from '../symbolicate'; +import {getDefaultConfig, mergeConfig} from 'metro-config'; +import {vlqMapFromTuples} from 'metro-source-map'; + +// symbolicate() only reads `config.symbolicator`. Stub metro-config so the test +// stays independent of the full config pipeline (and the Node version it needs). +jest.mock('metro-config', () => ({ + getDefaultConfig: {getDefaultValues: () => ({})}, + mergeConfig: (base, override) => ({...base, ...override}), +})); + +const config = mergeConfig(getDefaultConfig.getDefaultValues('/'), { + symbolicator: { + customizeFrame: () => null, + customizeStack: stack => stack, + }, +} as InputConfigT); + +// genLine1Based, genCol0Based, srcLine1Based, srcCol0Based[, name] +const TUPLES: Array = [ + [1, 0, 10, 4], + [1, 8, 10, 12, 'greet'], + [2, 0, 11, 0], +]; + +function makeMap( + map: Array | VlqMap, +): ExplodedSourceMap { + return [ + { + firstLine1Based: 1, + functionMap: null, + map, + path: 'foo.js', + }, + ]; +} + +test('symbolicates a frame against a decoded tuple map', async () => { + const [frame] = await symbolicate( + [{file: 'bundle.js', lineNumber: 1, column: 8, methodName: null}], + [['bundle.js', makeMap(TUPLES)]], + config, + null, + ); + expect(frame).toMatchObject({file: 'foo.js', lineNumber: 10, column: 12}); +}); + +test('VLQ map symbolicates identically to its decoded tuples', async () => { + const frame = [ + {file: 'bundle.js', lineNumber: 1, column: 8, methodName: null}, + ]; + + const [fromTuples] = await symbolicate( + frame, + [['bundle.js', makeMap(TUPLES)]], + config, + null, + ); + const [fromVlq] = await symbolicate( + frame, + [['bundle.js', makeMap(vlqMapFromTuples(TUPLES))]], + config, + null, + ); + + expect(fromVlq).toEqual(fromTuples); + expect(fromVlq).toMatchObject({file: 'foo.js', lineNumber: 10, column: 12}); +}); + +test('reuses a single VLQ map across multiple frames in the same module', async () => { + const explodedMap = makeMap(vlqMapFromTuples(TUPLES)); + + const out = await symbolicate( + [ + {file: 'bundle.js', lineNumber: 1, column: 0, methodName: null}, + {file: 'bundle.js', lineNumber: 1, column: 8, methodName: null}, + {file: 'bundle.js', lineNumber: 2, column: 0, methodName: null}, + ], + [['bundle.js', explodedMap]], + config, + null, + ); + + expect(out.map(f => f.lineNumber)).toEqual([10, 10, 11]); +}); diff --git a/packages/metro/src/Server/symbolicate.js b/packages/metro/src/Server/symbolicate.js index b219d39661..9452367dca 100644 --- a/packages/metro/src/Server/symbolicate.js +++ b/packages/metro/src/Server/symbolicate.js @@ -12,10 +12,12 @@ import type { FBSourceFunctionMap, MetroSourceMapSegmentTuple, + VlqMap, } from '../../../metro-source-map/src/source-map'; import type {ExplodedSourceMap} from '../DeltaBundler/Serializers/getExplodedSourceMap'; import type {ConfigT} from 'metro-config'; +import {toBabelSegments, toSegmentTuple} from 'metro-source-map'; import {greatestLowerBound} from 'metro-source-map/private/Consumer/search'; import {SourceMetadataMapConsumer} from 'metro-symbolicate/private/Symbolication'; @@ -35,6 +37,26 @@ export type StackFrameOutput = Readonly; type ExplodedSourceMapModule = ExplodedSourceMap[number]; type Position = {readonly line1Based: number, column0Based: number}; +function ensureDecodedMap( + map: Array | VlqMap, + decodedMapCache: Map>, +): Array { + if (Array.isArray(map)) { + return map; + } + let decoded = decodedMapCache.get(map); + if (decoded == null) { + decoded = toBabelSegments({ + version: 3, + sources: [''], + names: [...map.names], + mappings: map.mappings, + }).map(toSegmentTuple); + decodedMapCache.set(map, decoded); + } + return decoded; +} + function createFunctionNameGetter( module: ExplodedSourceMapModule, ): Position => ?string { @@ -70,12 +92,19 @@ export default async function symbolicate( { readonly firstLine1Based: number, readonly functionMap: ?FBSourceFunctionMap, - readonly map: Array, + readonly map: Array | VlqMap, readonly path: string, }, (Position) => ?string, >(); + // Decoded VLQ maps are cached only for the duration of this request, then + // discarded. The cache dedupes decoding across frames that resolve to the + // same module, while keeping the (large) decoded tuples short-lived — the + // VlqMaps themselves are retained by the long-lived module graph, so caching + // beyond request scope would defeat the memory savings of storing them as VLQ. + const decodedMapCache = new Map>(); + function findModule(frame: StackFrameInput): ?ExplodedSourceMapModule { const map = mapsByUrl.get(frame.file); if (!map || frame.lineNumber == null) { @@ -96,19 +125,18 @@ export default async function symbolicate( frame: StackFrameInput, module: ExplodedSourceMapModule, ): ?Position { - if ( - module.map == null || - frame.lineNumber == null || - frame.column == null - ) { + const lineNumber = frame.lineNumber; + const column = frame.column; + if (module.map == null || lineNumber == null || column == null) { return null; } + const decodedMap = ensureDecodedMap(module.map, decodedMapCache); const generatedPosInModule = { - line1Based: frame.lineNumber - module.firstLine1Based + 1, - column0Based: frame.column, + line1Based: lineNumber - module.firstLine1Based + 1, + column0Based: column, }; const mappingIndex = greatestLowerBound( - module.map, + decodedMap, generatedPosInModule, (target, candidate) => { if (target.line1Based === candidate[0]) { @@ -120,7 +148,7 @@ export default async function symbolicate( if (mappingIndex == null) { return null; } - const mapping = module.map[mappingIndex]; + const mapping = decodedMap[mappingIndex]; if ( mapping[0] !== generatedPosInModule.line1Based || mapping.length < 4 /* no source line/column info */ @@ -140,7 +168,7 @@ export default async function symbolicate( module: { readonly firstLine1Based: number, readonly functionMap: ?FBSourceFunctionMap, - readonly map: Array, + readonly map: Array | VlqMap, readonly path: string, }, ): ?string { @@ -160,11 +188,6 @@ export default async function symbolicate( if (!module) { return {...frame}; } - if (!Array.isArray(module.map)) { - throw new Error( - `Unexpected module with serialized source map found: ${module.path}`, - ); - } const originalPos = findOriginalPos(frame, module); if (!originalPos) { return {...frame}; diff --git a/packages/metro/types/DeltaBundler/Serializers/getExplodedSourceMap.d.ts b/packages/metro/types/DeltaBundler/Serializers/getExplodedSourceMap.d.ts index a9d62b738d..5f4804a75b 100644 --- a/packages/metro/types/DeltaBundler/Serializers/getExplodedSourceMap.d.ts +++ b/packages/metro/types/DeltaBundler/Serializers/getExplodedSourceMap.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<623892927b76c4f68802bb69f19d9974>> + * @generated SignedSource<<2f0ab0435f64798986366df74674d02a>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro/src/DeltaBundler/Serializers/getExplodedSourceMap.js @@ -19,10 +19,11 @@ import type {Module} from '../types'; import type { FBSourceFunctionMap, MetroSourceMapSegmentTuple, + VlqMap, } from 'metro-source-map'; export type ExplodedSourceMap = ReadonlyArray<{ - readonly map: Array; + readonly map: Array | VlqMap; readonly firstLine1Based: number; readonly functionMap: null | undefined | FBSourceFunctionMap; readonly path: string; diff --git a/packages/metro/types/DeltaBundler/Serializers/helpers/getSourceMapInfo.d.ts b/packages/metro/types/DeltaBundler/Serializers/helpers/getSourceMapInfo.d.ts index e154274d74..b30c4224f8 100644 --- a/packages/metro/types/DeltaBundler/Serializers/helpers/getSourceMapInfo.d.ts +++ b/packages/metro/types/DeltaBundler/Serializers/helpers/getSourceMapInfo.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<> + * @generated SignedSource<> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro/src/DeltaBundler/Serializers/helpers/getSourceMapInfo.js @@ -19,6 +19,7 @@ import type {Module} from '../../types'; import type { FBSourceFunctionMap, MetroSourceMapSegmentTuple, + VlqMap, } from 'metro-source-map'; declare function getSourceMapInfo( @@ -29,7 +30,7 @@ declare function getSourceMapInfo( getSourceUrl: null | undefined | ((module: Module) => string); }, ): { - readonly map: Array; + readonly map: Array | VlqMap; readonly functionMap: null | undefined | FBSourceFunctionMap; readonly code: string; readonly path: string;