Skip to content

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

Open
mozzius wants to merge 3 commits into
react:mainfrom
mozzius:resolver-browser-spec-after-exports
Open

mozzius wants to merge 3 commits into
react:mainfrom
mozzius:resolver-browser-spec-after-exports

Conversation

@mozzius

@mozzius mozzius commented Oct 30, 2026 •

Copy link
Copy Markdown

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

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>
@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: 64b4ad8 · 6 commands run, in a restricted sandbox after an automated safety assessment

Adds an opt-in resolver flag that applies "browser" redirections to the file resolved from "exports" instead of to the requested subpath, and every new code path is gated on it. The only remaining issue is a rare nit: a redirect to a directory with an invalid main throws instead of falling back. Both earlier findings are fixed, and in-memory fixtures using the published exports/browser fields of react-dom and multiformats gave the results in the PR's table for ESM imports; a CommonJS require of multiformats on iOS keeps its warning because that package is import-only.

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, 470 tests passed.
  • ✅ yarn flow check — No errors.
  • ✅ yarn eslint <changed source files and package-exports-test.js> — No lint errors reported.
  • ✅ yarn verify-api-snapshots — Verified 18 public API snapshots.
  • ✅ yarn jest --no-watchman packages/metro-resolver/src/__tests__/review-2134-test.js (failed as expected) — Reproduces the nit (flag on throws InvalidPackageError). The table test printed react-dom and multiformats results matching the PR for ESM imports; nested package.json scope and packages without exports also behaved correctly.
  • ✅ (Windows) jest packages/metro-resolver/src/__tests__/package-exports-test.js — 77 tests passed on a GitHub-hosted VM, including all new flag-on cases.

Not run: Two commands were refused for lack of an approval path and not retried: (1) resolving the published packages, a pnpm-style symlinked package and a real asset target through a real DependencyGraph, so in-memory fixtures with the published manifest fields were used and config plumbing was checked by reading and Flow; (2) running the repro on the base checkout, so flag-off behavior was checked from the gated code and head suites, not differentially. No benchmark: with the flag off the added cost is one boolean check.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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

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