Skip to content

Breaking: Remove unused metro-source-map APIs - #2156

Draft
robhogan wants to merge 4 commits into
pr1985from
remove-source-map-apis
Draft

robhogan wants to merge 4 commits into
pr1985from
remove-source-map-apis

Conversation

@robhogan

@robhogan robhogan commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

#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.

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.
@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 Nov 3, 2026
robhogan and others added 3 commits November 3, 2026 08:50
#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.
#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.
@robhogan
robhogan force-pushed the remove-source-map-apis branch from aa4b398 to e41a7ff Compare November 3, 2026 09:09
@robhogan robhogan mentioned this pull request Nov 6, 2026
robhogan added a commit that referenced this pull request Nov 6, 2026
Summary:
Publish Metro 0.88.0 from `main`.

This is a draft until we choose the cut. These breaking PRs are still open, and can land first or wait for the next minor:

 - #2186 (`globalPrefix` for all module system globals)
 - #2158 (`/symbolicate` stops reading `req.rawBody`)
 - #2157 (remove `functionMap` from the Babel transformer result)
 - #1990 (keep the file extension in async bundle paths, approved and held for 0.88)
 - #1985 and #2156 (drafts, decoded source maps)

Approved non-breaking PRs that could also make it: #2185, #2040, #2034 and #2150 (which turns a missing watch folder into an immediate startup error, rather than a 240s hang). I'll rebase this and extend the notes as they land.

Changelog: [Internal]

Test plan:
```
node scripts/updateVersion.js 0.88.0
sl diff --stat
```

Only the 17 `packages/*/package.json` manifests change, and every `0.87.3` reference is replaced.

Notable breakage in third parties, for the notes or a heads-up before React Native adopts this:

 - `@rnx-kit/metro-config` 2.2.6 adds `path.dirname(resolver.emptyModulePath)` as a watch folder when there are none, which is now `metro:runtime/modules`, so the dev server never serves a bundle for a single-package app.
 - `@tamagui/metro-plugin` 3.x refuses `metro-resolver` outside 0.84-0.87, and then imports the removed `metro-resolver/private/createDefaultContext`.
 - `@rnx-kit/metro-resolver-symlinks` calls the removed `context.redirectModulePath`.

End to end (AI-driven), with the packed tarballs against 0.87.3 from npm, in a React Native 0.89.0-rc.0 template app with a few popular dependencies: dev and release bundles, source maps and assets for iOS and Android are byte-identical, except where #2174 changes resolution. `rxjs/operators` now resolves to the same ESM build as `rxjs`, dropping 190 duplicate modules, and `require('nanoid/non-secure')` gets nanoid's CJS build. Release builds reach the first screen on a Galaxy A16 and the iOS simulator (built before #2184 landed, which leaves the prelude and module system byte-identical). The dev server passes the same checks on both, with and without Watchman (edits, added and deleted files, HMR, `/symbolicate`, assets and lazy `import()`), and Fast Refresh works on the simulator.

Draft release notes (from the 14 commits in v0.87.3...main - internal-only changes omitted):

```
 - **[Breaking]**: Resolver: Read `"exports"` from the package root, and apply `"browser"` redirections to the file it resolves to, by default. Set `resolver.unstable_resolveExportsFromPackageRoot` and `resolver.unstable_applyBrowserSpecAfterExports` to `false` for the previous behavior (#2174 by @robhogan)
 - **[Breaking]**: Default `transformer.asyncRequireModulePath` and `resolver.emptyModulePath` to `metro:runtime/...`. A custom `resolver.resolveRequest` must delegate `metro:` specifiers it doesn't handle to `context.resolveRequest`, and code that reads either option as a file path must resolve it instead (#2180 by @robhogan)
 - **[Breaking]**: Resolve the module system via `metro:runtime/polyfills/require`. A custom `resolver.resolveRequest` must delegate `metro:` specifiers it doesn't handle to `context.resolveRequest` (#2184 by @robhogan)
 - **[Breaking]**: Remove the deprecated `redirectModulePath` from `ResolutionContext`. A custom resolver should drop any call to it and delegate to `context.resolveRequest` or `resolve`, which already apply `"browser"` redirections and return `{type: 'empty'}` for `false` (#2181 by @robhogan)
 - **[Breaking]**: Remove the deprecated `doesFileExist` from `ResolutionContext`, and the `DoesFileExist` type from `metro-resolver` (use `fileSystemLookup`) (#2181 by @robhogan)
 - **[Breaking]**: `ResolutionContext.isESMImport` is required (#2182 by @robhogan)
 - **[Breaking]**: Source and JSON files containing the reserved string `_$$_METRO_`, including in comments and string literals, now fail to transform (#2155 by @robhogan)
 - **[Breaking]**: Remove the deprecated, always-`true` `hot` option from `getTransformOptions` (#2159 by @robhogan)
 - **[Feature]**: Custom serializers receive the reserved string as `options.reservedString` (#2155 by @robhogan)
 - **[Fix]**: Fall back to file-based resolution, instead of throwing a `TypeError`, when a package's `"exports"` field is not a string, array or object (#2187 by @kwy404)
 - **[Fix]**: Fix relative symlinks on a different Windows drive than the project root (#2161 by @ramya-2196)
 - **[Performance]**: ~5-10% faster resolution (#2181 by @robhogan)

> NOTE: Experimental features are not covered by semver and can change at any time.

 - **[Experimental]**: Remove `unstable_incrementalResolution` from `ResolutionContext`, and `metro-resolver/private/createDefaultContext` (#2181 by @robhogan)

 **Full Changelog**: v0.87.3...v0.88.0
```
robhogan added a commit that referenced this pull request Nov 6, 2026
Summary:
Publish Metro 0.88.0 from `main`.

This is a draft until we choose the cut. These breaking PRs are still open, and can land first or wait for the next minor:

 - #2158 (`/symbolicate` stops reading `req.rawBody`)
 - #2157 (remove `functionMap` from the Babel transformer result)
 - #1990 (keep the file extension in async bundle paths, approved and held for 0.88)
 - #1985 and #2156 (drafts, decoded source maps)

Approved non-breaking PRs that could also make it: #2185, #2040, #2034 and #2150 (which turns a missing watch folder into an immediate startup error, rather than a 240s hang). I'll rebase this and extend the notes as they land.

Changelog: [Internal]

Test plan:
```
node scripts/updateVersion.js 0.88.0
sl diff --stat
```

Only the 17 `packages/*/package.json` manifests change, and every `0.87.3` reference is replaced.

Notable breakage in third parties, for the notes or a heads-up before React Native adopts this:

 - `@rnx-kit/metro-config` 2.2.6 adds `path.dirname(resolver.emptyModulePath)` as a watch folder when there are none, which is now `metro:runtime/modules`, so the dev server never serves a bundle for a single-package app.
 - `@tamagui/metro-plugin` 3.x refuses `metro-resolver` outside 0.84-0.87, and then imports the removed `metro-resolver/private/createDefaultContext`.
 - `@rnx-kit/metro-resolver-symlinks` calls the removed `context.redirectModulePath`.

End to end (AI-driven), with the packed tarballs against 0.87.3 from npm, in a React Native 0.89.0-rc.0 template app with a few popular dependencies: dev and release bundles, source maps and assets for iOS and Android are byte-identical, except where #2174 changes resolution. `rxjs/operators` now resolves to the same ESM build as `rxjs`, dropping 190 duplicate modules, and `require('nanoid/non-secure')` gets nanoid's CJS build. With #2186, only the module system and `asyncRequire` differ besides those. Release builds reach the first screen on a Galaxy A16 and the iOS simulator. The dev server passes the same checks on both, with and without Watchman (edits, added and deleted files, HMR, `/symbolicate`, assets and lazy `import()`), and Fast Refresh works on the simulator.

Draft release notes (from the 15 commits in v0.87.3...main - internal-only changes omitted):

```
 - **[Breaking]**: Resolver: Read `"exports"` from the package root, and apply `"browser"` redirections to the file it resolves to, by default. Set `resolver.unstable_resolveExportsFromPackageRoot` and `resolver.unstable_applyBrowserSpecAfterExports` to `false` for the previous behavior (#2174 by @robhogan)
 - **[Breaking]**: Default `transformer.asyncRequireModulePath` and `resolver.emptyModulePath` to `metro:runtime/...`. A custom `resolver.resolveRequest` must delegate `metro:` specifiers it doesn't handle to `context.resolveRequest`, and code that reads either option as a file path must resolve it instead (#2180 by @robhogan)
 - **[Breaking]**: Resolve the module system via `metro:runtime/polyfills/require`. A custom `resolver.resolveRequest` must delegate `metro:` specifiers it doesn't handle to `context.resolveRequest` (#2184 by @robhogan)
 - **[Breaking]**: `transformer.globalPrefix` now also prefixes `__r`, `__c`, `__registerSegment` and `__accept`, and the default run statement. Code that calls these by name must apply the prefix (#2186 by @robhogan)
 - **[Breaking]**: Remove the deprecated `redirectModulePath` from `ResolutionContext`. A custom resolver should drop any call to it and delegate to `context.resolveRequest` or `resolve`, which already apply `"browser"` redirections and return `{type: 'empty'}` for `false` (#2181 by @robhogan)
 - **[Breaking]**: Remove the deprecated `doesFileExist` from `ResolutionContext`, and the `DoesFileExist` type from `metro-resolver` (use `fileSystemLookup`) (#2181 by @robhogan)
 - **[Breaking]**: `ResolutionContext.isESMImport` is required (#2182 by @robhogan)
 - **[Breaking]**: Source and JSON files containing the reserved string `_$$_METRO_`, including in comments and string literals, now fail to transform (#2155 by @robhogan)
 - **[Breaking]**: Remove the deprecated, always-`true` `hot` option from `getTransformOptions` (#2159 by @robhogan)
 - **[Feature]**: Custom serializers receive the reserved string as `options.reservedString` (#2155 by @robhogan)
 - **[Fix]**: Fall back to file-based resolution, instead of throwing a `TypeError`, when a package's `"exports"` field is not a string, array or object (#2187 by @kwy404)
 - **[Fix]**: Fix relative symlinks on a different Windows drive than the project root (#2161 by @ramya-2196)
 - **[Performance]**: ~5-10% faster resolution (#2181 by @robhogan)

> NOTE: Experimental features are not covered by semver and can change at any time.

 - **[Experimental]**: Remove `unstable_incrementalResolution` from `ResolutionContext`, and `metro-resolver/private/createDefaultContext` (#2181 by @robhogan)

 **Full Changelog**: v0.87.3...v0.88.0
```

This branch has not been deployed

No deployments
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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant