Skip to content

Resolver: Optionally apply "browser" field redirections after "exports" - #2134

Merged
robhogan merged 7 commits into
react:mainfrom
mozzius:resolver-browser-spec-after-exports
Nov 2, 2026
Merged

robhogan merged 7 commits into
react:mainfrom
mozzius:resolver-browser-spec-after-exports

Conversation

@mozzius

@mozzius mozzius commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Addresses #1135 behind a new opt-in flag, resolver.unstable_applyBrowserSpecAfterExports.

For a bare import of a package that has "exports", Metro applies "browser" spec redirections (object-valued mainFields) to the requested subpath before it looks the subpath up in "exports". If the redirect target isn't itself an exported subpath, Metro logs "not listed in the "exports"" and falls back to file-based resolution. Two widely used packages hit this on every bundle:

The comparison in #1135 found that webpack and Vite resolve "exports" first, then apply the object "browser" map to the resolved file. The conclusion there was that Metro should do the same. With the flag on:

  • Packages with "exports": "exports" resolves the requested subpath, without redirecting it first. BROWSER_SPEC_REDIRECTION is then applied to the resolved file, using its closest package.json, including false → empty module. This covers node_modules, nodeModulesPaths and extraNodeModules lookups, Haste packages, and self-references.
  • A redirection to a file that doesn't exist: logs a warning and keeps the "exports" target, so the flag can't break an import that resolves without it.
  • Subpaths not matched by "exports": same fallback as today (a warning, then file-based resolution with redirections), except that the warning now names the requested subpath rather than the redirect target.
  • Unchanged: packages without "exports", relative and absolute imports, and unstable_enablePackageExports: false.

It's opt-in because results can change, not just the warnings. React Native lists "browser" in mainFields but doesn't assert the browser condition on iOS/Android, so under the flag react-dom/server on iOS resolves to server.node.js (the "exports" default target) instead of server.browser.js. With the flag off, the only new work is a boolean check. With it on, there's one extra getPackageForModule call per file resolved from "exports", for the second pass.

Not covered: webpack also applies the "browser" map to "imports" targets. I left that out to keep this change small.

Changelog: [Experimental] Add resolver.unstable_applyBrowserSpecAfterExports to apply "browser" field redirections to files resolved from "exports", as webpack and Vite do

Test plan

New "browser" spec redirections with "exports" block in package-exports-test.js. With the flag on, it covers:

  • a subpath (both iOS and web)
  • the main entry point
  • a key matching the exported subpath that should not redirect it (the react-dom shape)
  • false → empty module
  • mainFields without "browser"
  • a self-reference
  • the not-exported fallback
  • a redirection to a missing file (warns and keeps the "exports" target), for both an import and a self-reference

Every flag-on case also asserts that no warning is logged, except the two fallback cases, which each assert their one warning. One more test pins today's behavior with the flag off. Against the resolver on main, the flag-on cases fail except the mainFields control.

yarn jest packages/metro-resolver   # pass
yarn flow check                     # no errors
yarn lint && yarn format-check      # pass
yarn verify-api-snapshots           # pass (metro-config / metro-resolver snapshots updated)
yarn jest                           # pass, apart from one FallbackWatcher-test timing failure
                                    # in metro-file-map that passes on rerun (untouched here)

I also ran this branch's resolver against the real published packages. Settings: Expo's defaults, i.e. mainFields ['react-native', 'browser', 'main'] with condition react-native for iOS, and ['browser', 'module', 'main'] with condition browser for web.

Import Platform Flag off Flag on
multiformats@14.0.5/hashes/sha2 (and /sha1) ios sha2-browser.js + warning sha2-browser.js
web sha2-browser.js + warning sha2-browser.js
react-dom@19.3.0/server ios server.browser.js + warning server.node.js
web server.browser.js + warning server.browser.js
multiformats, multiformats/cid, multiformats/basics, react-dom both unchanged unchanged

Happy to rename the flag, or to flip the default in a follow-up once it's had some use.

🤖 Generated with Claude Code

@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 Oct 30, 2026
@huntie
huntie requested a review from robhogan October 30, 2026 10:12
@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Metro review: Looks good

Risk: low · Commit: 2d49dab · 9 commands run, in a restricted sandbox after an automated safety assessment

Adds opt-in resolver.unstable_applyBrowserSpecAfterExports, which applies "browser" redirections to the file "exports" resolves to, and now shares one package lookup between the redirect and "exports". No blocking or should-fix issues: with the flag off, head matched base on all 21,768 resolutions over Metro's node_modules and on synthetic edge cases, and the PR's react-dom and multiformats table reproduces. My earlier nit is fixed, and the lookup refactor shows no significant timing change on or off in a synthetic benchmark.

No findings.

Verified
  • ✅ yarn jest --no-watchman packages/metro-resolver packages/metro-config packages/metro/src/node-haste packages/metro/src/DeltaBundler/__tests__/resolver-test.js — 18 suites, 472 tests.
  • ✅ yarn lint packages/metro-resolver/src/resolve.js packages/metro-resolver/src/__tests__/package-exports-test.js packages/metro-resolver/src/__tests__/package-scope-test.js
  • ✅ yarn verify-api-snapshots — 18 snapshots verified.
  • ✅ yarn typecheck (Linux VM) — Flow: no errors.
  • ✅ jest package-exports-test.js package-scope-test.js browser-spec-test.js (Windows) — 3 suites, 95 tests.
  • ✅ node bench/diff.js table — react-dom@19.3.0 and multiformats@14.0.5 match the PR's table, with import added to the conditions: same files, and the warnings are gone with the flag on.
  • ✅ node bench/diff.js diff — 21,768 base/head resolutions across 2,721 specifiers: 0 flag-off differences, 0 new throws with the flag on. 12 flag-on changes, all react-dom/server, react-dom/static or @‍noble/hashes/crypto.
  • ✅ node bench/diff.js edge — Nested scopes, chains, loops, false, directory targets, self-references, extraNodeModules, nodeModulesPaths: 0 flag-off differences.
  • ✅ node bench/diff.js bench — Interleaved base, head flag off and head flag on; results are in benchmarks.

Benchmarks (base vs this PR)

Benchmark Base This PR Change
Resolver, head flag off vs base 102.5ms (100.2-106.3) 99.1ms (95.5-109.5) no significant change
Resolver, head flag on vs base 102.5ms (100.2-106.3) 101.6ms (99.1-105.5) no significant change
Method
  • Resolver, head flag off vs base: 2,721 specifiers from Metro's node_modules × 5 per sample, iOS, in-memory file context, one process, 21 rounds with rotated order; base always loaded first. Median (range). Getting ranges overlap; lookups fell 93,292 to 82,516 in the separate diff run. bench/diff.js bench
  • Resolver, head flag on vs base: Same run as above. Median (range). Lookups in the separate diff run: 93,292 at base, 87,596 with the flag on. In-memory context, so this understates real file map lookup cost. bench/diff.js bench

Not run: Local flow check was denied by this session's permission policy, so Flow ran on the Linux VM instead. Not run: full Jest suite, yarn typecheck-ts, yarn test-smoke, and a benchmark through the real DependencyGraph/TreeFS or on Bluesky. Differential runs used a hand-written context, not TreeFS. Haste with the flag on was only read, not exercised. Relative nodeModulesPaths/extraNodeModules weren't exercised: docs show absolute paths, and the refactor could change which package's "browser" map applies to relative ones.

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

Comment thread packages/metro-resolver/src/resolve.js Outdated
if (redirectedPath === resolution.filePath) {
return resolvedAs(resolution);
}
return resolveModulePath(context, redirectedPath, platform);

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 · A "browser" redirect to a missing file after "exports" silently drops the resolution

redirectPackageExportsResolution passes the redirected path to resolveModulePath and returns its failed result as is. In resolvePackage, that failure goes up to resolveModuleFromTargetPath, which returns null, so the node_modules walk moves on to the next level.

Example: a package has "exports": {"./broken": "./broken-node.js"} and "browser": {"./broken-node.js": "./does-not-exist.js"}, and broken-node.js exists.

  • Flag off: pkg/broken resolves to broken-node.js.
  • Flag on: the resolution is thrown away with no warning. The import ends in FailedToResolveNameError ("Module does not exist in the Haste module map or in these directories: …"), which names only the node_modules directories it searched. It says nothing about the "browser" entry or the target file. If a parent node_modules has another copy of the package, that copy is used silently.

The self-reference path, by contrast, throws FailedToResolvePathError with candidates. So the same broken entry fails differently depending on how the package was reached.

This breaks the resolver's "errors are data" contract. It also turns a working import into an opaque failure only because the flag was turned on. Existing "exports" handling of a missing target is the model to follow: it raises InvalidPackageConfigurationError with the paths involved, logs a warning and falls back. Suggested fix: when the redirected path fails to resolve, either throw an InvalidPackageConfigurationError that names the package, the "exports" target and the redirect target, or log a warning and keep the "exports" result. Add a test for this case.

Reproduced: Repro __review__/__tests__/browser-after-exports-review-test.js ('redirect to a missing file after exports'), run on Linux and Windows, logged: broken -> {"threw":"FailedToResolveNameError","message":"Module does not exist in the Haste module map or in these directories:\n /root/src/node_modules\n /root/node_modules\n /node_modules\n"}. Code path: redirectPackageExportsResolution (resolve.js:710) → resolvePackage returns failed → resolveModuleFromTargetPath returns null (resolve.js:431-435) → the walk continues. · id 609ce9d6

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in c9e8e89, taking your second option. A redirection to a missing file now logs an InvalidPackageConfigurationError warning that names the package, the "exports" target and the redirect target. Resolution then keeps the "exports" target, so turning on the flag can no longer break an import that resolves without it. Self-references go through the same helper, so both paths now behave the same. I added a test covering both paths; it fails on 6278f15.

Comment thread docs/Resolution.md Outdated
1. If [`context.unstable_enablePackageExports`](#unstable_enablepackageexports-boolean) is enabled and a containing `package.json` has an `"exports"` field, get the result of [**RESOLVE_PACKAGE_EXPORTS**](#resolve_package_exports)(*context*, *packagePath*, *filePath*, *exportsField*, *platform*).
1. If resolved path exists, return result.
1. If resolved path exists, then
1. If [`context.unstable_applyBrowserSpecAfterExports`](#unstable_applybrowserspecafterexports-boolean) is enabled, apply [**BROWSER_SPEC_REDIRECTION**](#browser_spec_redirection) to the resolved path. If this is `false`, return the empty module. If it is a different path, return [**RESOLVE_MODULE**](#resolve_module)(*context*, *redirectedPath*, *platform*). E.g. `"browser": {"./lib/index.node.js": "./lib/index.browser.js"}` redirects `"exports": {".": "./lib/index.node.js"}`.

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 · RESOLVE_PACKAGE spec step omits the missing-redirect-target fallback

Step 1.1.1 says that if the redirection is a different path, Metro returns RESOLVE_MODULE(context, redirectedPath, platform). The code does something different. If that resolution fails, redirectPackageExportsResolution logs an InvalidPackageConfigurationError warning and returns the original "exports" result (resolve.js, in redirectPackageExportsResolution). Resolution.md is meant to describe the algorithm precisely, so it should include this fallback. Suggested wording: "If that resolves, return it; otherwise log a package configuration warning and return result."

Evidence: Compared docs/Resolution.md lines 120-123 at head with redirectPackageExportsResolution in packages/metro-resolver/src/resolve.js. The test '[nonstrict] should warn and keep the "exports" target when it is redirected to a missing file' confirms the code's fallback behavior. · id 0b3fe9f6

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 64b4ad8 using your wording: step 1.1.1 now returns RESOLVE_MODULE of the redirected path "if that resolves, otherwise log a package configuration warning and continue". Step 1.1.2 then returns the "exports" result. I also fixed the same gap in PACKAGE_SELF_RESOLVE step 5.1. It still said "return result", but self-references get the same redirection under the flag, so it now performs RESOLVE_PACKAGE step 1.1.1 first.

Comment thread packages/metro-resolver/src/resolve.js Outdated
if (redirectedPath === resolution.filePath) {
return resolution;
}
const result = resolveModulePath(context, redirectedPath, platform);

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 redirect to a directory with an invalid package.json throws instead of falling back

redirectPackageExportsResolution promises that enabling the flag "cannot break an import that resolves without it", and the PR description says the same. That only holds when resolveModulePath returns failed. If the redirect target is a directory whose own package.json has an unresolvable main, resolvePackageEntryPoint throws InvalidPackageError. Neither resolvePackage nor the self-reference path catches that error, so it propagates and the build fails.

Failure case: "exports": {"./dir": "./dir-node.js"}, "browser": {"./dir-node.js": "./broken-dir"}, and broken-dir/package.json with {"main": "./missing.js"}. With the flag off, pkg/dir resolves to dir-node.js. With it on, the import throws InvalidPackageError.

This package shape should be rare. Either catch InvalidPackageError around this call and use the existing warn-and-keep path, or narrow the docblock's claim to missing targets.

Reproduced: packages/metro-resolver/src/__tests__/review-2134-test.js, differential pkg/dir: flag off returns /root/node_modules/pkg/dir-node.js; flag on threw InvalidPackageError: The package /root/node_modules/pkg/broken-dir/package.json is invalid because it specifies a main module field that could not be resolved. · id 57a4d3a3

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 7f9c6fe. An InvalidPackageError from resolving the redirect is now caught and goes through the same warn-and-keep path. The warning now says the redirect target "could not be resolved" instead of "this file does not exist", since that wording no longer fits every case. I added your broken-dir case to the existing test.each; it throws on 64b4ad8.

@robhogan

robhogan commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator

With the flag on, this PR adds a second closest-package.json lookup per package resolution. isBrowserSpecAppliedAfterExports walks up from the candidate path to check for "exports", and then resolvePackage does the same walk straight after:

function isBrowserSpecAppliedAfterExports(
context: ResolutionContext,
absoluteCandidatePath: string,
): boolean {
return (
context.unstable_applyBrowserSpecAfterExports &&
context.unstable_enablePackageExports &&
context.getPackageForModule(absoluteCandidatePath)?.packageJson.exports !=
null
);
}

if (context.unstable_enablePackageExports) {
const pkg = context.getPackageForModule(absoluteCandidatePath);
const exportsField = pkg?.packageJson.exports;

It's measurable - on Bluesky, turning the flag on makes node_modules resolutions ~4% slower (~1% of resolution overall). Could we look the package up once in resolveModuleFromTargetPath and pass it down? Roughly:

const pkg = context.getPackageForModule(targetPath);
const candidate =
  pkg == null ||
  (context.unstable_applyBrowserSpecAfterExports &&
    context.unstable_enablePackageExports &&
    pkg.packageJson.exports != null)
    ? targetPath
    : redirectPackageSubpath(context, targetPath, pkg, pkg.packageRelativePath);
// ...
const result = resolvePackage(
  context,
  candidate,
  platform,
  candidate === targetPath
    ? pkg
    : context.unstable_enablePackageExports
      ? context.getPackageForModule(candidate)
      : null,
);

...with resolvePackage taking the package rather than looking it up, and isBrowserSpecAppliedAfterExports dropped. redirectModulePath does its own lookup too, so this also removes one that's on main today. With this shape, turning the flag on is no slower than main without it.

The other reason I'd like this: we currently read "exports" from the package.json closest to the requested subpath, not the one at the package root, so a nested {"type": "commonjs"} or a legacy shim package.json hides the root's "exports" (Bluesky hits this with @floating-ui/utils/dom). That's a separate bug and not for this PR, but the fix is to change where the package comes from. With two independent lookups it'd need changing in both - otherwise, with the flag on, we'd see no "exports" in the nested one, apply the "browser" redirect first, and then run the root's "exports" on the redirected subpath. With one lookup it's a change in one place.

AI-driven benchmark (Bluesky, iOS and web as Expo configures them):

node_modules resolutions All resolutions
This PR, flag on vs off +3.9% to +4.6% +0.7% to +0.8%
One lookup, flag on vs main -0.9% to -1.6% -1.2% to -2.3%
One lookup vs this PR, both flag on -4.3% to -7.9% -1.8% to -4.6%

Each resolution the app makes (~25k, ~6k of them in node_modules) is replayed through DependencyGraph with the resolution cache cleared, both checkouts in one process, 30 rounds with the order rotated every round and both load orders checked. Figures are medians of paired per-round ratios, the range across platforms (and load orders for the last row). The one-lookup version changes no resolutions or warnings on Bluesky, and the resolver tests pass with one lookup-count expectation in package-scope-test.js going down by one.

@mozzius

mozzius commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the benchmark! Done in 7572574, following your sketch:

  • resolveModuleFromTargetPath looks the package up once and passes it to resolvePackage.
  • isBrowserSpecAppliedAfterExports is gone.
  • The Haste path still does its own lookup, and only when exports are enabled, like before.

As you said, the package subpath with package exports expectation in package-scope-test.js drops by one lookup.

I also added a flag-on case there to pin the one extra lookup that remains. The second pass has to read redirections from the scope of the file that "exports" resolves to, because that can be a nested package.json.

Agreed on the nested package.json hiding the root's "exports" being a separate bug. With one lookup, changing where pkg comes from now fixes it for both the redirect and "exports" in one place.

Separately, 7f9c6fe fixes the review bot's last nit: a redirect to a directory with an invalid "main" threw instead of falling back.

mozzius and others added 5 commits November 2, 2026 15:56
Add resolver.unstable_applyBrowserSpecAfterExports. When enabled, a
package's "exports" field resolves the requested subpath first, and
"browser" spec redirections apply to the resolved file instead of to
the requested subpath, matching webpack and Vite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A "browser" redirection of a file resolved from "exports" to a file that
doesn't exist dropped the resolution, so the import failed with an
opaque FailedToResolveNameError (or a self-reference with a different
error). Warn and keep the "exports" target instead, so enabling the flag
can't break an import that resolves without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
resolveModuleFromTargetPath now looks up the package once and passes it
to resolvePackage, instead of a second lookup for "exports" (and a third
with unstable_applyBrowserSpecAfterExports).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A redirection to a directory whose package.json has an unresolvable
"main" threw InvalidPackageError instead of warning and keeping the
"exports" target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mozzius
mozzius force-pushed the resolver-browser-spec-after-exports branch from 7f9c6fe to 2d49dab Compare November 2, 2026 13:58

@robhogan robhogan 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.

Thanks for this!

@robhogan
robhogan merged commit 9e61545 into react:main Nov 2, 2026
4 checks passed
@robhogan

robhogan commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

@mozzius Is this something you're hitting in the wild? Do you need it releasing under any particular Metro/Expo version?

@mozzius

mozzius commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Is this something you're hitting in the wild? Do you need it releasing under any particular Metro/Expo version?

No, not to my knowledge, it just triggers an annoying warning 😅

robhogan added a commit that referenced this pull request Nov 4, 2026
…default

This enables `resolver.unstable_applyBrowserSpecAfterExports` (#2134) and `resolver.unstable_resolveExportsFromPackageRoot` (#2160) by default for 0.88. Both shipped off in 0.87.3.

For a bare specifier, `"exports"` now comes from the `package.json` at the root of the named package, as in Node, webpack 5 and Vite 8, so a nested `package.json` no longer hides it. An object-valued `"browser"` (or `"react-native"`) field now redirects the file that `"exports"` resolves to, rather than the requested subpath.

Some packages rely on the old behaviour to reach a build that works on native:

 - `@mswjs/interceptors/fetch` (0.41) moves from its browser build to its Node build.
 - `react-dom/server` moves from `server.browser.js` to `server.node.js`. `react-dom/server.browser` is still exported.
 - `import 'nanoid/async'` (nanoid 3) moves to `index.native.js`.

Either option can be set to `false` to restore the previous behaviour. The options themselves stay for now.

Fixes #1135, fixes #1517, fixes #1582, fixes #2153.

## Changelog

```
 - **[Breaking]**: Resolver: Read `"exports"` from the package root, and apply `"browser"` redirections to the resolved file, by default (`resolver.unstable_resolveExportsFromPackageRoot`, `resolver.unstable_applyBrowserSpecAfterExports`)
```

## Test plan

Existing tests pass unchanged.

AI-driven import graph walks through `DependencyGraph`, with both options off (as `main`) and on, using this branch and RN/Expo `mainFields` and conditions:

| App, platform | Edges off -> on | Changed edges | New failures | Warnings off -> on |
|---|---|---|---|---|
| Bluesky iOS | 25,081 -> 25,081 | 0 | 0 | 1 -> 0 |
| Bluesky Android | 24,981 -> 24,981 | 0 | 0 | 1 -> 0 |
| Bluesky web | 25,343 -> 25,343 | 2 | 0 | 2 -> 1 |
| Mattermost iOS | 13,843 -> 13,961 | 29 | 0 | 120 -> 120 |
| Mattermost Android | 13,816 -> 13,934 | 29 | 0 | 120 -> 120 |
| pnpm fixture iOS | 5,977 -> 5,977 | 0 | 0 | 1 -> 1 |

The changed edges are `@floating-ui/utils/dom` to its `"exports"` target on Bluesky web, and on Mattermost `rxjs/operators` from `dist/cjs` to `dist/esm5` (matching `rxjs`) and `nanoid/non-secure` from `index.js` to `index.cjs`. The walks parse imports with Babel, so they miss transform-injected dependencies, and nothing has run on a device.
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.

2 participants