Skip to content

Minify: Use the minifier's decoded source map where available - #1984

Merged
robhogan merged 2 commits into
mainfrom
pr1984
Nov 3, 2026
Merged

robhogan merged 2 commits into
mainfrom
pr1984

Conversation

@robhogan

@robhogan robhogan commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

When minifying, the transform worker converted Babel's decoded map to tuples and encoded it for the minifier, then decoded the minifier's output map with source-map (toBabelSegments), converted it back to tuples and encoded it again as the stored VLQ map. Terser already builds that decoded map, and only encodes it when result.map is read.

This PR:

  • Adds an optional decodedMap to MinifierResult. When a minifier provides it, the worker encodes the stored map straight from it with vlqMapFromBabelDecodedMap, as it already does for unminified modules. Otherwise it decodes map with @jridgewell/sourcemap-codec, which is already installed via @babel/generator.
  • Has metro-minify-terser return Terser's decoded_map as decodedMap, and encode map only if it's read.
  • Passes Babel's own encoded result.map to the minifier, rather than re-encoding it from tuples.
  • Documents the minifier contract under minifierPath.
  • Deprecates MinifierResult.map in favour of decodedMap, and toBabelSegments, toSegmentTuple and tuplesFromBabelDecodedMap in metro-source-map, which Metro no longer uses.

Output is unchanged - a release build of our benchmark app* with minification on produces a byte-identical bundle and source map, and so does each module minified on its own. Custom minifiers that only return map keep working as before.

Minifying the whole app is about 11% faster, from no longer converting the map to and from tuples, and from Terser no longer encoding its map at all:

Minify step CPU (median) vs main
This PR -11.0% (95% CI -18.2% to -7.5%)

Changelog

 - **[Performance]**: Faster source map handling when minifying, using the minifier's decoded map if it provides one (`MinifierResult.decodedMap`)
 - **[Deprecated]**: Custom minifiers should return their source map as `decodedMap` - `MinifierResult.map` is deprecated
 - **[Deprecated]**: `metro-source-map`: Deprecate `toBabelSegments`, `toSegmentTuple` and `tuplesFromBabelDecodedMap`

Test plan

New tests cover the worker using decodedMap without reading map, and metro-minify-terser returning Terser's decoded map and encoding map only on request.

yarn jest
yarn flow check
yarn lint
yarn verify-api-snapshots

* AI-driven benchmark: Mattermost Mobile 2.45.0 (React Native 0.83.9), its release bundle split back into its 7,082 modules (5.13M mapping segments), each minified with metro-minify-terser from its code and map as the worker does. Times are single-threaded totals across all modules - medians of 15 rounds interleaving main and both PRs in this stack, Node 22.13.1 on an M4 Pro shared with other work (1-minute load 12-38), with 95% bootstrap confidence intervals. Output was checked byte-identical in every run.

@robhogan
robhogan added this pull request to stack #1986 September 26, 2026 10:22
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Sep 26, 2026
@robhogan
robhogan force-pushed the pr1984 branch 2 times, most recently from 4497155 to 5d3015b Compare September 28, 2026 11:32
When minifying, the transform worker converted Babel's decoded map to tuples and encoded it for the minifier, then decoded the minifier's output map with `source-map` (`toBabelSegments`), converted it back to tuples and encoded it again as the stored VLQ map. Terser already builds that decoded map, and only encodes it when `result.map` is read.

This diff:
 - Adds an optional `decodedMap` to `MinifierResult`. When a minifier provides it, the worker encodes the stored map straight from it with `vlqMapFromBabelDecodedMap`, as it already does for unminified modules. Otherwise it decodes `map` with `@jridgewell/sourcemap-codec`, which is already installed via `@babel/generator`.
 - Has `metro-minify-terser` return Terser's `decoded_map` as `decodedMap`, and encode `map` only if it's read.
 - Passes Babel's own encoded `result.map` to the minifier, rather than re-encoding it from tuples.
 - Deprecates `MinifierResult.map` in favour of `decodedMap`, and `toBabelSegments`, `toSegmentTuple` and `tuplesFromBabelDecodedMap` in `metro-source-map`, which Metro no longer uses. The next diff removes them.

Output is unchanged - across every module of our benchmark app*, the minified code, line count and stored map are byte-identical. Custom minifiers that only return `map` keep working as before.

Minifying the whole app is 10.4% faster (95% CI 7.4-11.4%; 21.3s -> 19.2s of CPU on our benchmark app*), from no longer converting the map to and from tuples, and from Terser no longer encoding its map at all:

| | Minify step (median) | vs `main` |
|---|---|---|
| `main` | 21,286ms (20,844 to 21,561) | |
| This diff | 19,228ms (18,886 to 19,773) | -10.4% (-11.4 to -7.4) |

## Changelog

```
 - **[Performance]**: Faster source map handling when minifying, using the minifier's decoded map if it provides one (`MinifierResult.decodedMap`)
 - **[Deprecated]**: Custom minifiers should return their source map as `decodedMap` - `MinifierResult.map` is deprecated
 - **[Deprecated]**: `metro-source-map`: Deprecate `toBabelSegments`, `toSegmentTuple` and `tuplesFromBabelDecodedMap`
```

## Test plan

New tests cover the worker using `decodedMap` without reading `map`, and `metro-minify-terser` returning Terser's decoded map and encoding `map` only on request.

```
yarn jest
yarn flow check
yarn lint
yarn verify-api-snapshots
```

\* Benchmark: Mattermost Mobile 2.45.0 (React Native 0.83.9), its release bundle split back into its 7,082 modules (5.13M mapping segments), each minified with `metro-minify-terser` from its code and map as the worker does. Times are single-threaded totals across all modules - medians of 15 rounds interleaving `main` and both diffs in this stack, Node 22.13.1 on an M5 Pro, with 95% bootstrap confidence intervals. Output was checked byte-identical in every run.
@robhogan
robhogan marked this pull request as ready for review November 2, 2026 19:30
@robhogan
robhogan requested review from huntie and vzaidman November 2, 2026 19:30
robhogan added a commit that referenced this pull request Nov 2, 2026
…map APIs

#1984 added `MinifierResult.decodedMap` and deprecated `MinifierResult.map`, `toBabelSegments`, `toSegmentTuple` and `tuplesFromBabelDecodedMap`. This removes them:

 - Minifiers return their source map only as `decodedMap`. `MinifierResult.map` is removed, along with the worker's fallback that decoded it with `@jridgewell/sourcemap-codec`, and `metro-minify-terser` now returns only Terser's decoded map. The minifier contract is documented under `minifierPath`.
 - `toBabelSegments`, `toSegmentTuple` and `tuplesFromBabelDecodedMap` are removed from `metro-source-map`'s exports. Metro no longer uses them, and they have no users in React Native or Expo.

A custom minifier (`transformer.minifierPath`) that returns an encoded `map` can return `decodedMap: {mappings: decode(map.mappings), names: map.names}` instead, using `decode` from `@jridgewell/sourcemap-codec`.

A minifier that still returns only `map` now fails the build with an error pointing at `decodedMap`, rather than losing its source map. An empty `map`, as in esbuild's `transform` result, still builds with no source map. Anything reading `map` from `metro-minify-terser`'s result directly, which includes `@expo/metro-config`'s transform worker, needs updating too. We'll do that when Expo adopts this release, and its generated types fail to compile until we do.

`tuplesFromBabelDecodedMap` stays as an internal helper. `decodeVlqMap`, which expands compact maps when serialising a flat source map (`fromRawMappings`), now decodes with `@jridgewell/sourcemap-codec` into it, rather than through `source-map` via `toBabelSegments`. That drops `source-map.js`'s last use of `source-map` - the package still depends on it for `normalizeSourcePath`.

Minified output with Terser is unchanged from #1984, and minification time doesn't measurably change (+1.9%, 95% CI -8.7% to +4.1%*), since that PR already stops Terser encoding its map. Flat maps built from compact maps are about 4.4x faster (95% CI 4.30-4.51x*) and use about 18% less peak memory (95% CI -19.0% to -18.2%), with identical output. Metro's own `.map` responses are index maps, which don't decode compact maps at all, so this only affects the lazy module definition serializer and direct callers of `fromRawMappings`.

## Changelog

```
 - **[Breaking]**: Custom minifiers must return their source map as `decodedMap` - `MinifierResult.map` is removed
 - **[Breaking]**: `metro-source-map`: Remove deprecated `toBabelSegments`, `toSegmentTuple` and `tuplesFromBabelDecodedMap`
```

## Test plan

The worker's mock minifier and `metro-minify-terser`'s tests now use `decodedMap`, tests that used `toBabelSegments` as a reference decoder now use `Consumer`, and tests of the removed functions are removed. A minified release build of our benchmark app* is byte-identical to `main` and to #1984, bundle and source map. New unit tests cover the build error. I also ran a `{code, map}` minifier and `metro-minify-esbuild`'s raw esbuild result through the worker: the first fails with the new error, the second still builds.

```
yarn jest
yarn flow check
yarn lint
yarn verify-api-snapshots
```

\* AI-driven benchmark: Mattermost Mobile 2.45.0 (React Native 0.83.9), its release bundle split back into its 7,082 modules (5.13M mapping segments), each stored as a compact map, then serialised with `fromRawMappings(...).toString()`. Medians of 15 rounds interleaving before and after, Node 22.13.1 on an M4 Pro shared with other work (1-minute load 12-38), with 95% bootstrap confidence intervals. Output was checked identical in every run. Minification times are from the benchmark on #1984, over the same modules.
robhogan added a commit that referenced this pull request Nov 3, 2026
#1984 added `MinifierResult.decodedMap` and deprecated `MinifierResult.map`. This removes `map`, so minifiers return their source map only as `decodedMap`. The worker's fallback that decoded `map` with `@jridgewell/sourcemap-codec` is removed, and `metro-minify-terser` now returns only Terser's decoded map. The minifier contract is documented under `minifierPath`.

A custom minifier (`transformer.minifierPath`) that returns an encoded `map` can return `decodedMap: {mappings: decode(map.mappings), names: map.names}` instead, using `decode` from `@jridgewell/sourcemap-codec`.

A minifier that still returns only `map` now fails the build with an error pointing at `decodedMap`, rather than losing its source map. An empty `map`, as in esbuild's `transform` result, still builds with no source map. Anything reading `map` from `metro-minify-terser`'s result directly, which includes `@expo/metro-config`'s transform worker, needs updating too. We'll do that when Expo adopts this release, and its generated types fail to compile until we do.

Minified output with Terser is unchanged from #1984, and minification time doesn't measurably change (+1.9%, 95% CI -8.7% to +4.1%*), since that PR already stops Terser encoding its map.

## Changelog

```
 - **[Breaking]**: Custom minifiers must return their source map as `decodedMap` - `MinifierResult.map` is removed
```

## Test plan

The worker's mock minifier and `metro-minify-terser`'s tests now use `decodedMap`. New unit tests cover the build error. I also ran a `{code, map}` minifier and `metro-minify-esbuild`'s raw esbuild result through the worker: the first fails with the new error, the second still builds. A minified release build of our benchmark app* is byte-identical to `main` and to #1984, bundle and source map.

```
yarn jest
yarn flow check
yarn lint
yarn verify-api-snapshots
```

\* AI-driven benchmark: Mattermost Mobile 2.45.0 (React Native 0.83.9), its release bundle split back into its 7,082 modules (5.13M mapping segments), each minified with `metro-minify-terser`. Medians of 15 rounds interleaving `main` and this stack, Node 22.13.1 on an M4 Pro shared with other work (1-minute load 12-38), with 95% bootstrap confidence intervals. Output was checked byte-identical in every run.
robhogan added a commit that referenced this pull request Nov 3, 2026
#1984 added `MinifierResult.decodedMap` and deprecated `MinifierResult.map`. This removes `map`, so minifiers return their source map only as `decodedMap`. The worker's fallback that decoded `map` with `@jridgewell/sourcemap-codec` is removed, and `metro-minify-terser` now returns only Terser's decoded map.

A custom minifier (`transformer.minifierPath`) that returns an encoded `map` can return `decodedMap: {mappings: decode(map.mappings), names: map.names}` instead, using `decode` from `@jridgewell/sourcemap-codec`.

A minifier that still returns only `map` now fails the build with an error pointing at `decodedMap`, rather than losing its source map. An empty `map`, as in esbuild's `transform` result, still builds with no source map. Anything reading `map` from `metro-minify-terser`'s result directly, which includes `@expo/metro-config`'s transform worker, needs updating too. We'll do that when Expo adopts this release, and its generated types fail to compile until we do.

Minified output with Terser is unchanged from #1984, and minification time doesn't measurably change (+1.9%, 95% CI -8.7% to +4.1%*), since that PR already stops Terser encoding its map.

## Changelog

```
 - **[Breaking]**: Custom minifiers must return their source map as `decodedMap` - `MinifierResult.map` is removed
```

## Test plan

The worker's mock minifier and `metro-minify-terser`'s tests now use `decodedMap`. New unit tests cover the build error. I also ran a `{code, map}` minifier and `metro-minify-esbuild`'s raw esbuild result through the worker: the first fails with the new error, the second still builds. A minified release build of our benchmark app* is byte-identical to `main` and to #1984, bundle and source map.

```
yarn jest
yarn flow check
yarn lint
yarn verify-api-snapshots
```

\* AI-driven benchmark: Mattermost Mobile 2.45.0 (React Native 0.83.9), its release bundle split back into its 7,082 modules (5.13M mapping segments), each minified with `metro-minify-terser`. Medians of 15 rounds interleaving `main` and this stack, Node 22.13.1 on an M4 Pro shared with other work (1-minute load 12-38), with 95% bootstrap confidence intervals. Output was checked byte-identical in every run.
robhogan added a commit that referenced this pull request Nov 3, 2026
#1984 deprecated `toBabelSegments`, `toSegmentTuple` and `tuplesFromBabelDecodedMap` in `metro-source-map`. This removes them from the package's exports. Metro no longer uses them, and they have no users in React Native or Expo.

`tuplesFromBabelDecodedMap` stays as an internal helper. `decodeVlqMap`, which expands compact maps when serialising a flat source map (`fromRawMappings`), now decodes with `@jridgewell/sourcemap-codec` into it, rather than through `source-map` via `toBabelSegments`. That drops `source-map.js`'s last use of `source-map` - the package still depends on it for `normalizeSourcePath`.

Flat maps built from compact maps are about 4.4x faster (95% CI 4.30-4.51x*) and use about 18% less peak memory (95% CI -19.0% to -18.2%), with identical output. Metro's own `.map` responses are index maps, which don't decode compact maps at all, so this only affects the lazy module definition serializer and direct callers of `fromRawMappings`.

## Changelog

```
 - **[Breaking]**: `metro-source-map`: Remove deprecated `toBabelSegments`, `toSegmentTuple` and `tuplesFromBabelDecodedMap`
```

## Test plan

Tests that used `toBabelSegments` as a reference decoder now use `Consumer`, and tests of the removed functions are removed.

```
yarn jest
yarn flow check
yarn lint
yarn verify-api-snapshots
```

\* AI-driven benchmark: Mattermost Mobile 2.45.0 (React Native 0.83.9), its release bundle split back into its 7,082 modules (5.13M mapping segments), each stored as a compact map, then serialised with `fromRawMappings(...).toString()`. Medians of 15 rounds interleaving before and after, Node 22.13.1 on an M4 Pro shared with other work (1-minute load 12-38), with 95% bootstrap confidence intervals. Output was checked identical in every run.

@huntie huntie left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perf wins everywhere 👏🏻

@robhogan
robhogan merged commit cddcf36 into main Nov 3, 2026
16 checks passed
robhogan added a commit that referenced this pull request Nov 3, 2026
#1984 added `MinifierResult.decodedMap` and deprecated `MinifierResult.map`. This removes `map`, so minifiers return their source map only as `decodedMap`. The worker's fallback that decoded `map` with `@jridgewell/sourcemap-codec` is removed, and `metro-minify-terser` now returns only Terser's decoded map.

A custom minifier (`transformer.minifierPath`) that returns an encoded `map` can return `decodedMap: {mappings: decode(map.mappings), names: map.names}` instead, using `decode` from `@jridgewell/sourcemap-codec`.

A minifier that still returns only `map` now fails the build with an error pointing at `decodedMap`, rather than losing its source map. An empty `map`, as in esbuild's `transform` result, still builds with no source map. Anything reading `map` from `metro-minify-terser`'s result directly, which includes `@expo/metro-config`'s transform worker, needs updating too. We'll do that when Expo adopts this release, and its generated types fail to compile until we do.

Minified output with Terser is unchanged from #1984, and minification time doesn't measurably change (+1.9%, 95% CI -8.7% to +4.1%*), since that PR already stops Terser encoding its map.

## Changelog

```
 - **[Breaking]**: Custom minifiers must return their source map as `decodedMap` - `MinifierResult.map` is removed
```

## Test plan

The worker's mock minifier and `metro-minify-terser`'s tests now use `decodedMap`. New unit tests cover the build error. I also ran a `{code, map}` minifier and `metro-minify-esbuild`'s raw esbuild result through the worker: the first fails with the new error, the second still builds. A minified release build of our benchmark app* is byte-identical to `main` and to #1984, bundle and source map.

```
yarn jest
yarn flow check
yarn lint
yarn verify-api-snapshots
```

\* AI-driven benchmark: Mattermost Mobile 2.45.0 (React Native 0.83.9), its release bundle split back into its 7,082 modules (5.13M mapping segments), each minified with `metro-minify-terser`. Medians of 15 rounds interleaving `main` and this stack, Node 22.13.1 on an M4 Pro shared with other work (1-minute load 12-38), with 95% bootstrap confidence intervals. Output was checked byte-identical in every run.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. needs-meta-import

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants