Skip to content

Breaking: Resolve the module system via metro:runtime - #2184

Merged
robhogan merged 7 commits into
mainfrom
pr2183
Nov 6, 2026
Merged

robhogan merged 7 commits into
mainfrom
pr2183

Conversation

@robhogan

@robhogan robhogan commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Note: the module system is not publicly configurable - although the path lives in metro-config's defaults, it's not actually a config option.

Currently it's set to require.resolve('metro-runtime/src/polyfills/require.js') within in metro-config, an absolute path which does not pass through Metro's resolver, depending instead on metro-config's dependency on metro-runtime.

That's potentially a different copy of metro-runtime than #2180 and react/react-native#58584 resolves to, and unlike those, isn't guaranteed to be watched - though it must be, as it's included in the bundle via getPrependedScripts.

This changes it to metro:runtime/polyfills/require, resolved once per graph via Metro's resolver, so it always comes from the copy Metro adds to the file map, and it's one fewer use of metro-runtime/src/*.

A custom resolveRequest will now see this specifier on every build and must delegate it to context.resolveRequest.

With #2180 landed, this was metro-config's last use of metro-runtime, so this also drops that dependency.

Changelog:

 - **[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`

Test plan:
New integration test. Built and ran a bundle from a project outside this repo, so that metro-runtime isn't reachable from it. An unused emptyModulePath that's missing, outside the file map or thrown on by the resolver still builds.

Overwriting the moduleSystem export with an absolute path, as Expo CLI does, still builds and gives a byte-identical bundle. I haven't run this through Expo itself.

@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 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Metro review: Looks good

Risk: medium · Commit: 7d84878 · 7 commands run

The PR now resolves the module system as metro:runtime/polyfills/require through Metro's resolver, once per prepended-scripts build, instead of using an absolute path from metro-config's own copy of metro-runtime. It also drops metro-config's now-unused metro-runtime dependency. Since my last review, the isEmptyModule check has been removed, so nothing resolves emptyModulePath eagerly any more. The new test fails on the base and passes on the head, the wider integration, Server and lib suites pass, and overriding moduleSystem with an absolute path still gives a byte-identical bundle.

No findings.

Verified
  • ✅ yarn jest --no-watchman packages/metro/src/integration_tests/__tests__/module-system-test.js packages/metro-config — 5 suites, 50 tests
  • ✅ (base-worktree) yarn jest --no-watchman packages/metro/src/integration_tests/__tests__/module-system-test.js (PR test copied to base) (failed as expected) — resolveRequest is never called with 'metro:runtime/polyfills/require' on the base, so the test does detect the change
  • ✅ yarn jest --no-watchman packages/metro/src/integration_tests packages/metro/src/Server packages/metro/src/lib — 406 tests
  • ✅ yarn jest --no-watchman packages/metro/src/integration_tests/__tests__/review-2184-test.js — A missing, unused emptyModulePath still builds. Overriding defaults.moduleSystem with an absolute require.resolve path gives a bundle byte-identical to the default (IDENTICAL true).
  • ✅ yarn typecheck
  • ✅ yarn eslint <changed js files>
  • ✅ yarn verify-api-snapshots — Verified 18 snapshots. DependencyGraph.isEmptyModule is no longer in API.md.

Not run: No benchmark. The change adds one memoized resolver call per prepended-scripts build, not per module, so it can't plausibly affect cost. Windows wasn't checked: no Windows machine was available, and the metro: path ignores the origin, so ${projectRoot}/. isn't path-sensitive here. Expo CLI itself wasn't run, but its absolute-path override was simulated in Jest. I couldn't delete the copy of the PR test left in the base checkout, because the sandbox denied the rm.

Automated review by Claude. Advisory only: it never approves or blocks, and a maintainer makes the call.


// A bundle with no module system builds successfully but fails at runtime.
if (
moduleSystem === resolveFromProjectRoot(config.resolver.emptyModulePath)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

should fix · The empty-module guard makes every build depend on emptyModulePath resolving

The guard calls resolveFromProjectRoot(config.resolver.emptyModulePath) for every graph, even when nothing resolves to empty. Before this PR that path was only resolved lazily, in ModuleResolver._getEmptyModule, when a resolution actually returned {type: 'empty'}. There are three consequences:

  • Metro's copy doesn't fully replace metro-config's. The default emptyModulePath is still require.resolve('metro-runtime/src/modules/empty-module.js') from metro-config. If metro-config has its own metro-runtime copy outside the file map, the module system now resolves from Metro's copy, but the build still fails on empty-module.js. The description says this change removes that dependency on metro-config's copy.
  • Configs that build today break. A custom emptyModulePath that is missing or outside the file map, and never used, now fails with Unable to resolve module <emptyModulePath> from <projectRoot>/.. The error gives no hint that the module system check caused it.
  • Custom resolvers see a new specifier. resolveRequest now receives the absolute emptyModulePath on every graph, and a resolver that throws on it fails the build. The changelog line only mentions metro: specifiers.

Suggested fix: don't let this comparison fail the build. If emptyModulePath can't be resolved, the module system can't have resolved to empty, because _getEmptyModule would already have thrown. Wrapping only the second resolve in a try/catch and treating failure as "not empty" is therefore sound. Alternatively, have the resolver report that the resolution type was empty, so emptyModulePath isn't touched at all. Separately, defaulting emptyModulePath to metro:runtime/modules/empty-module (already in metro-runtime's exports) would remove the metro-config copy dependency for the default config.

Reproduced: packages/metro/src/integration_tests/__tests__/review-2184-test.js ran the same scenarios on head and base. With emptyModulePath set to /does/not/exist/empty.js, or to a real file outside the file map, or with a resolver that throws on emptyModulePath, base built and ran. Head failed with UnableToResolveError: Unable to resolve module /does/not/exist/empty.js from .../basic_bundle/. and resolver saw emptyModulePath. The outside-the-file-map path stands in for a duplicate metro-config copy; no real duplicate install was built. · id 25f357bd

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 334ed31 - the check now asks the resolver whether the module system is its cached empty module, so emptyModulePath is only resolved when something returns {type: 'empty'}. This adds a small isEmptyModule to DependencyGraph.

) {
throw new Error(
`The module system ('${defaults.moduleSystem}') resolved to an empty ` +
'module. A custom `resolver.resolveRequest` must delegate this ' +

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

should fix · Docs still describe delegating from a custom resolver as optional

With default config, a resolveRequest that handles bare specifiers itself and doesn't delegate metro: specifiers used to build and run. It now fails. docs/Configuration.md still shows this under resolveRequest with the comment // Optionally, chain to the standard Metro resolver., and the docs/Resolution.md section on resolveRequest says nothing about the requirement. Users hitting this breaking change will only find it in the changelog.

Suggested fix: state in both places that a custom resolver must delegate metro: specifiers it doesn't handle, and that this includes the module system on every build.

Reproduced: Read docs/Configuration.md:320-335 and docs/Resolution.md:311-342; the PR changes no docs. In review-2184-test.js, the scenario where bare specifiers return {type: 'empty'} and paths delegate built and ran on base, and gave the new error on head. · id c5e4fe65

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 334ed31 - Configuration.md and Resolution.md now both say a custom resolver must delegate metro: specifiers, including the module system.

data: {key: name, locs: [], asyncType: null, isESMImport: false},
}).filePath;

const moduleSystem = resolveFromProjectRoot(defaults.moduleSystem);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit · A literal catch-all {type: 'empty'} resolver overflows the stack instead of reaching the new error

The description says a catch-all {type: 'empty'} resolver gets the new error. A resolver that really returns {type: 'empty'} for everything recurses instead: _getEmptyModule resolves emptyModulePath through the same resolver, gets empty again, and calls itself. The build dies with RangeError: Maximum call stack size exceeded on line 55, before the guard runs.

The recursion also exists on base, so this isn't a regression, but it is the most direct form of the mistake this error is meant to explain. The new test only maps the exact module system specifier to empty, so it doesn't cover this.

Suggested fix: detect the recursion in _getEmptyModule and throw a descriptive error, and add a true catch-all case to the test.

Reproduced: review-2184-test.js with resolveRequest: () => ({type: 'empty'}) gave RangeError: Maximum call stack size exceeded on both head and base. The new message only appeared when the resolver delegated paths, including emptyModulePath. · id 823a17cb

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is handled in #2180, which replaces the overflow with a descriptive error, so I have not duplicated it here. Checked the literal catch-all with both merged.

The module system is currently an absolute path from `require.resolve` in `metro-config`, which skips resolution and depends on `metro-config`'s copy of `metro-runtime` being in the file map. This changes it to `metro:runtime/polyfills/require`, resolved once per graph, so it always comes from the copy Metro adds to the file map, and it's one fewer use of `metro-runtime/src/*`.

A custom `resolveRequest` will now see this specifier and must delegate it to `context.resolveRequest`. A catch-all returning `{type: 'empty'}` would otherwise build a bundle with no module system that only fails at runtime, so that's now a build error.

Changelog:
```
 - **[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`
```

Test plan:
New integration tests. Built and ran a bundle from a project outside this repo, so that `metro-runtime` isn't reachable from it (setting `asyncRequireModulePath` to `metro:runtime/modules/asyncRequire` for that), and a catch-all `{type: 'empty'}` resolver giving the new error.

Overwriting the `moduleSystem` export with an absolute path, as Expo CLI does, still builds and gives a byte-identical bundle. I haven't run this through Expo itself.
@robhogan
robhogan merged commit 8d5cc49 into main Nov 6, 2026
16 checks passed
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
```
robhogan added a commit that referenced this pull request Nov 6, 2026
Summary:
Publish Metro 0.88.0 from `main`.

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. Flow, ESLint, Prettier, TypeScript, API snapshots, Markdown lint and Jest pass on this commit.

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.

The end-to-end checks above were on 9de42bbed3b2, before #2170, #2157, #2158 and #1990 landed. I'm re-running them now and will update this.

Draft release notes (from the 19 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]**: Async bundle and HMR module URLs keep the module's file extension (`foo.js.bundle`) and carry the bundle's `platform`, fixing lazy loading and HMR source maps for files whose extension isn't in `sourceExts` (e.g. `.cjs`, `.mjs`) or that share a name with a sibling (e.g. `foo.json` next to `foo.js`). Old URLs still work (#1990 by @dasjideepak)
 - **[Breaking]**: Babel transformers can no longer return a `functionMap`. Apply the `plugins` passed by Metro and return the resulting Babel `metadata` to keep function names in stack traces (#2157 by @robhogan)
 - **[Breaking]**: `/symbolicate` no longer reads `req.rawBody`, and fails if a middleware has already read the request body (#2158 by @robhogan)
 - **[Breaking]**: Remove the `bundle_transform_progressed_throttled` event from `TerminalReportableEvent`. `TerminalReporter` subclasses should handle `bundle_transform_progressed`, which their `_log` now receives for every progress update (#2170 by @vzaidman)
 - **[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]**: Don't print the terminal status in the middle of bursts of output written outside of it, and don't wait for a pending status update before exiting (#2170 by @vzaidman)
 - **[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`.

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. Flow, ESLint, Prettier, TypeScript, API snapshots, Markdown lint and Jest pass on this commit.

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.

The end-to-end checks above were on 9de42bbed3b2, before #2170, #2157, #2158 and #1990 landed. I'm re-running them now and will update this.

Draft release notes (from the 19 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]**: Async bundle and HMR module URLs keep the module's file extension (`foo.js.bundle`) and carry the bundle's `platform`, fixing lazy loading and HMR source maps for files whose extension isn't in `sourceExts` (e.g. `.cjs`, `.mjs`) or that share a name with a sibling (e.g. `foo.json` next to `foo.js`). Old URLs still work (#1990 by @dasjideepak)
 - **[Breaking]**: Babel transformers can no longer return a `functionMap`. Apply the `plugins` passed by Metro and return the resulting Babel `metadata` to keep function names in stack traces (#2157 by @robhogan)
 - **[Breaking]**: `/symbolicate` no longer reads `req.rawBody`, and fails if a middleware has already read the request body (#2158 by @robhogan)
 - **[Breaking]**: Remove the `bundle_transform_progressed_throttled` event from `TerminalReportableEvent`. `TerminalReporter` subclasses should handle `bundle_transform_progressed`, which their `_log` now receives for every progress update (#2170 by @vzaidman)
 - **[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]**: Don't print the terminal status in the middle of bursts of output written outside of it, and don't wait for a pending status update before exiting (#2170 by @vzaidman)
 - **[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
```
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