Skip to content

Resolver: Optionally read "exports" from the package root - #2160

Merged
robhogan merged 6 commits into
mainfrom
pr2160
Nov 4, 2026
Merged

robhogan merged 6 commits into
mainfrom
pr2160

Conversation

@robhogan

@robhogan robhogan commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Currently, Metro reads "exports" from the package.json closest to the requested subpath, so a nested {"type": "commonjs"} or legacy shim hides the root's "exports". That's different to Node's PACKAGE_RESOLVE, webpack 5 and Vite 8.

This adds resolver.unstable_resolveExportsFromPackageRoot, off by default, which reads "exports" from the root of the package named by a bare specifier, following Node. The subpath is the literal text after the package name, so pkg/./sub and pkg/node_modules/dep/foo aren't exported - we warn and fall back to file-based resolution, as for any other unexported subpath. A package with no package.json at its root has no "exports", so a nested one doesn't apply. Haste packages and extraNodeModules mappings have no Node equivalent, and read the root of the mapped directory too, using the closest package if it has no package.json. "browser" redirections, "imports" and self-references keep using the closest, which is correct for those.

Being off, this can go out in 0.87.x without changing what anyone resolves. We'll enable it by default for 0.88 in a follow-up.

Refs #2153

Changelog:

 - **[Experimental]**: Add `resolver.unstable_resolveExportsFromPackageRoot` to read `"exports"` from the package root, instead of the closest `package.json`

Test plan:
New unit tests.

AI-driven comparison: import graph walks through DependencyGraph of Bluesky (iOS, Android, web) and Mattermost (iOS, Android). With the flag off, no edges, failures or warnings differ from main. With it on, 29 edges change on each Mattermost platform and 2 on Bluesky web, as described in the issue, and no failures or warnings differ.

@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
Currently, Metro reads `"exports"` from the `package.json` closest to the requested subpath, so a nested `{"type": "commonjs"}` or legacy shim hides the root's `"exports"`. That's different to [Node's `PACKAGE_RESOLVE`](https://nodejs.org/api/esm.html#resolution-algorithm-specification), webpack 5 and Vite 8.

This adds `resolver.unstable_resolveExportsFromPackageRoot`, off by default, which reads `"exports"` from the root of the package named by a bare specifier. If there's no `package.json` there, we use the closest as we do today. `"browser"` redirections, `"imports"` and self-references keep using the closest, which is correct for those.

Being off, this can go out in 0.87.x without changing what anyone resolves. We'll enable it by default for 0.88 in a follow-up.

Refs #2153

Changelog:
```
 - **[Experimental]**: Add `resolver.unstable_resolveExportsFromPackageRoot` to read `"exports"` from the package root, instead of the closest `package.json`
```

Test plan:
New unit tests.

AI-driven comparison: import graph walks through `DependencyGraph` of Bluesky (iOS, Android, web) and Mattermost (iOS, Android). With the flag off, no edges, failures or warnings differ from main. With it on, 29 edges change on each Mattermost platform and 2 on Bluesky web, as described in the issue, and no failures or warnings differ.

Not covered E2E: a symlinked (pnpm) package with a nested `package.json`. No edges changed in the pnpm app I walked, so that relies on `getPackageForModule` returning the real `rootPath`.
@robhogan
robhogan marked this pull request as ready for review November 3, 2026 13:41
@robhogan
robhogan requested a review from huntie November 3, 2026 13:41
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Metro review: Looks good

Risk: low · Commit: 972327f · 10 commands run

This adds an off-by-default resolver.unstable_resolveExportsFromPackageRoot, which reads "exports" from the root of the package named by a bare specifier, as Node does. All three earlier findings are fixed, and their new tests fail when each fix is disabled. On a 36-specifier fixture through a real DependencyGraph, flag-off results match base, and flag-on differs from Node only in the documented warn-and-fall-back cases (plus pkg/ treated as pkg), so I have no new findings.

No findings.

Verified
  • ✅ yarn jest --no-watchman packages/metro-resolver packages/metro/src/DeltaBundler/__tests__/resolver-test.js packages/metro-config — 14 suites, 522 tests.
  • ✅ yarn flow check — No errors. Run before the review's repro tests were added.
  • ✅ yarn eslint on resolve.js, PackageExportsResolve.js and package-exports-test.js
  • ✅ yarn verify-api-snapshots — 18 snapshots verified.
  • ✅ yarn jest --no-watchman packages/metro-resolver/src/__tests__/package-exports-test.js, with the pattern-segment check and nested node_modules check disabled (failed as expected) — 10 of 120 tests fail, covering both fixes. Temporary edits reverted.
  • ✅ yarn jest --no-watchman package-exports-test.js resolver-test.js, using the lexical packagePath instead of realPath for the no-root case (failed as expected) — The new symlink test fails on linux and mocked win32. Temporary edit reverted; git status showed only the repro test untracked at that point.
  • ✅ yarn jest --no-watchman packages/metro/src/DeltaBundler/__tests__/review-node-diff-test.js (head) — Logs results rather than asserting. 36 specifiers on real fs and DependencyGraph against Node's import.meta.resolve, including scoped, symlinked, no-root and nested node_modules cases.
  • ✅ yarn --cwd base-worktree jest --no-watchman packages/metro/src/DeltaBundler/__tests__/review-node-diff-test.js (base) — Flag-off column compared by eye with head: same results and warnings for all 36 specifiers.
  • ✅ yarn jest --no-watchman packages/metro/src/DeltaBundler/__tests__/review-real-pkgs-test.js — Real nanoid 3.3.20 and @‍mswjs/interceptors 0.41.9 on ios and android. With the flag, nanoid/async gets a crypto build unless applyBrowserSpecAfterExports is on, and fetch gets lib/node, whose chunks use node:zlib. Matches the docs.
  • ✅ node bench/resolve-bench.js <head> <base> — Run without and with nested manifests. No significant change in either.

Benchmarks (base vs this PR)

Benchmark Base This PR Change
resolve() of 4000 bare specifiers, flag off 17.32ms (17.03-17.90) 17.24ms (16.97-17.91) no significant change
resolve() of 4000 bare specifiers, flag on at head vs base 17.32ms (17.03-17.90) 17.50ms (17.26-18.11) no significant change
Method
  • resolve() of 4000 bare specifiers, flag off: Generated in-memory tree of 2000 packages, half with exports, each with a nested lib/package.json. One Node process loaded both checkouts, 5 warm-up rounds, then 20 alternating rounds per side. Median with range. Script: bench/resolve-bench.js
  • resolve() of 4000 bare specifiers, flag on at head vs base: Same run and fixture as the flag-off row, so half the packages have exports and the root is not the closest manifest for their subpaths. Median with range; the ranges overlap. Script: bench/resolve-bench.js

Not run: The Bluesky and Mattermost comparison in the test plan wasn't repeated, because it needs those apps and the PR's harness. Flag-off equivalence rests on the 36-specifier fixture, the existing tests and code tracing. Real Windows wasn't available, so only mocked win32 in resolver-test.js ran. Real-package checks only resolved entry points and searched source files; nothing was bundled. A few compound shell commands were denied by the session's permission check, so I reran the checks as simple commands or read earlier review output instead.

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 Resolved

@huntie huntie left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Package exports never ends 😅👏🏻

Comment thread packages/metro-resolver/src/resolve.js Resolved
Comment thread packages/metro-resolver/src/resolve.js Resolved
@robhogan
robhogan merged commit 94cc963 into main Nov 4, 2026
16 checks passed
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