From baf3fdfffc4d44affc371e57fc457d6c7e835755 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 30 Oct 2026 09:02:18 +0200 Subject: [PATCH 1/7] resolver: optionally apply browser field redirections after exports 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 --- docs/Configuration.md | 20 +++ docs/PackageExports.md | 1 + docs/Resolution.md | 14 +- packages/metro-config/API.md | 1 + packages/metro-config/src/defaults/index.js | 1 + packages/metro-config/src/types.js | 1 + packages/metro-resolver/API.md | 1 + .../src/__tests__/package-exports-test.js | 153 ++++++++++++++++++ .../metro-resolver/src/__tests__/utils.js | 1 + packages/metro-resolver/src/resolve.js | 68 +++++++- packages/metro-resolver/src/types.js | 8 + .../DependencyGraph/ModuleResolution.js | 3 + .../DependencyGraph/createModuleResolver.js | 2 + 13 files changed, 267 insertions(+), 7 deletions(-) diff --git a/docs/Configuration.md b/docs/Configuration.md index efaef875ed..e796957dbe 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -451,6 +451,26 @@ resolver: { }, ``` +#### `unstable_applyBrowserSpecAfterExports`
Experimental
+ +Type: `boolean` + +When a package has an [`"exports"` field](https://nodejs.org/docs/latest-v18.x/api/packages.html#exports), apply its [`"browser"` field spec](https://github.com/defunctzombie/package-browser-field-spec) redirections (from any object-valued field in [`resolverMainFields`](#resolvermainfields)) to the file that `"exports"` resolves to, instead of to the requested subpath before `"exports"` is considered. This matches webpack and Vite. Takes effect when [`unstable_enablePackageExports`](#unstable_enablepackageexports-experimental) is `true` (the default). Defaults to `false`. + +For example, given a package with: + +```json + "exports": { + "./hash": {"browser": "./hash-browser.js", "default": "./hash-node.js"} + }, + "browser": { + "./hash": "./hash-browser.js", + "./hash-node.js": "./hash-browser.js" + } +``` + +With this option, `'pkg/hash'` resolves through `"exports"` to `hash-browser.js` for `web` (the `"browser"` condition), and to `hash-node.js` elsewhere, which the `"./hash-node.js"` key then redirects to `hash-browser.js` if `"browser"` is in `resolverMainFields`. Without it, the `"./hash"` key redirects the import to `./hash-browser.js` first, which is not listed in `"exports"`, so Metro logs a warning and falls back to file-based resolution. + #### `unstable_conditionNames`
Experimental
Type: `Array` diff --git a/docs/PackageExports.md b/docs/PackageExports.md index 944c28d040..148a7721c2 100644 --- a/docs/PackageExports.md +++ b/docs/PackageExports.md @@ -73,6 +73,7 @@ If a package's `package.json` has an `"exports"` field, Metro consults it first. - `"exports"` is used instead of any `"react-native"`, `"browser"`, or `"main"` field, and instead of a file on disk at the same subpath (e.g. with `"./utils": "./dist/utils.js"`, `'some-pkg/utils'` resolves to `dist/utils.js` even if the package has a root `utils.js`). - **Fallback**: If the requested subpath isn't matched in `"exports"`, Metro tries again using the fields above. +- An object-valued `"browser"` field (or other object-valued [main field](./Configuration.md#resolvermainfields)) can still redirect the requested subpath before `"exports"` is matched. Enable [`resolver.unstable_applyBrowserSpecAfterExports`](./Configuration.md#unstable_applybrowserspecafterexports-experimental) to apply those redirections to the file that `"exports"` resolves to instead, as webpack and Vite do. Subpaths matched in `"exports"` (including via [subpath patterns](https://nodejs.org/docs/latest-v19.x/api/packages.html#subpath-patterns)) resolve to the exact target file the package specifies: diff --git a/docs/Resolution.md b/docs/Resolution.md index 9bceb30fe2..96019432bd 100644 --- a/docs/Resolution.md +++ b/docs/Resolution.md @@ -79,15 +79,15 @@ Parameters: (_context_, _moduleName_, _platform_) 2. If resolved, return result. 8. If [`context.disableHierarchicalLookup`](#disablehierarchicallookup-boolean) is not `true`, then 1. Try resolving _moduleName_ under `node_modules` from the current directory (the parent of [`context.originModulePath`](#originmodulepath-string)) up to the root directory. E.g. `'lodash/merge'` from `/app/src/screens/Home.js` tries `/app/src/screens/node_modules/lodash/merge`, then `/app/src/node_modules/…`, `/app/node_modules/…` and `/node_modules/…`. - 2. Perform [**RESOLVE_PACKAGE**](#resolve_package)(_context_, _modulePath_, _platform_) for each candidate path. + 2. Perform [**RESOLVE_PACKAGE**](#resolve_package)(_context_, _modulePath_, _platform_) for each candidate path, where _modulePath_ is the result of applying [**BROWSER_SPEC_REDIRECTION**](#browser_spec_redirection) to the candidate path (returning the empty module if this is `false`). If [`context.unstable_applyBrowserSpecAfterExports`](#unstable_applybrowserspecafterexports-boolean) is enabled and the candidate path's closest `package.json` has an `"exports"` field, _modulePath_ is the candidate path itself. 9. For each element _nodeModulesPath_ of [`context.nodeModulesPaths`](#nodemodulespaths-readonlyarraystring): 1. Try resolving _moduleName_ under _nodeModulesPath_ as if it were another `node_modules` directory (similar to step 8). E.g. `nodeModulesPaths: ['/monorepo/node_modules']` tries `/monorepo/node_modules/lodash/merge`. - 2. Perform [**RESOLVE_PACKAGE**](#resolve_package)(_context_, _modulePath_, _platform_) for each candidate path. + 2. Perform [**RESOLVE_PACKAGE**](#resolve_package)(_context_, _modulePath_, _platform_) for each candidate path, with _modulePath_ as in step 8.2. 10. If [`context.extraNodeModules`](#extranodemodules-string-string) is set: 1. Split _moduleName_ into a package name (including an optional [scope](https://docs.npmjs.com/cli/v8/using-npm/scope)) and relative path. 2. Look up the package name in [`context.extraNodeModules`](#extranodemodules-string-string). If found, then 1. Let _modulePath_ be _moduleName_ with its package name replaced by the value found. E.g. `extraNodeModules: {'shared': '/monorepo/shared'}` maps `'shared/utils'` to `/monorepo/shared/utils`. - 2. Return [**RESOLVE_PACKAGE**](#resolve_package)(_context_, _modulePath_, _platform_). + 2. Return [**RESOLVE_PACKAGE**](#resolve_package)(_context_, _modulePath_, _platform_), with _modulePath_ redirected as in step 8.2. 11. If no valid resolution has been found, throw a resolution failure error — a scheme-specific error if step 4.2 applied, otherwise a generic one. #### RESOLVE_MODULE @@ -118,7 +118,9 @@ Parameters: (_context_, _moduleName_, _platform_) Resolves an absolute path that may be inside an npm package, e.g. a candidate path under `node_modules`. Let _filePath_ be _moduleName_, _packagePath_ the directory of the closest containing `package.json`, and _exportsField_ its `"exports"` field. E.g. for `'some-pkg/utils'` from `/app/src/App.js`, [**RESOLVE**](#resolve) step 8 passes _filePath_ `/app/node_modules/some-pkg/utils`, so _packagePath_ is `/app/node_modules/some-pkg` and the `"exports"` subpath is `'./utils'`. 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"}`. + 2. Return result. 2. Else, log either a package configuration or package encapsulation warning. 2. Return [**RESOLVE_MODULE**](#resolve_module)(_context_, _filePath_, _platform_). @@ -347,6 +349,10 @@ A matching resolver is called with the full specifier (e.g. `my-scheme:foo` for Relative (`./`, `../`) and subpath (`#…`) imports are handled first and never treated as schemes. See [**RESOLVE**](#resolve) step 4, and [`resolver.schemeResolvers`](./Configuration.md#schemeresolvers) for precedence relative to [`resolveRequest`](#resolverequest-customresolver). +#### `unstable_applyBrowserSpecAfterExports: boolean` + +Whether [**BROWSER_SPEC_REDIRECTION**](#browser_spec_redirection) is applied to the file resolved from a package's `"exports"` field, instead of to the requested subpath before `"exports"` is considered. Defaults to [`resolver.unstable_applyBrowserSpecAfterExports`](./Configuration.md#unstable_applybrowserspecafterexports-experimental). + #### `unstable_conditionNames: $ReadOnlyArray` Additional condition names asserted when resolving the `"exports"` and `"imports"` fields. Defaults to [`resolver.unstable_conditionNames`](./Configuration.md#unstable_conditionnames-experimental). diff --git a/packages/metro-config/API.md b/packages/metro-config/API.md index 3df6adb4e7..ff7287159a 100644 --- a/packages/metro-config/API.md +++ b/packages/metro-config/API.md @@ -152,6 +152,7 @@ export type ResolverConfigT = { resolverMainFields: ReadonlyArray; schemeResolvers: Readonly<{[scheme: string]: CustomResolver}>; sourceExts: ReadonlyArray; + unstable_applyBrowserSpecAfterExports: boolean; unstable_conditionNames: ReadonlyArray; unstable_conditionsByPlatform: Readonly<{ [platform: string]: ReadonlyArray; diff --git a/packages/metro-config/src/defaults/index.js b/packages/metro-config/src/defaults/index.js index 1fe3b0a454..f74da6cab5 100644 --- a/packages/metro-config/src/defaults/index.js +++ b/packages/metro-config/src/defaults/index.js @@ -47,6 +47,7 @@ const getDefaultValues = (projectRoot: ?string): ConfigT => ({ resolveRequest: null, resolverMainFields: ['browser', 'main'], schemeResolvers: {}, + unstable_applyBrowserSpecAfterExports: false, unstable_conditionNames: [], unstable_conditionsByPlatform: { web: ['browser'], diff --git a/packages/metro-config/src/types.js b/packages/metro-config/src/types.js index dbc936bdf2..801dcc3c34 100644 --- a/packages/metro-config/src/types.js +++ b/packages/metro-config/src/types.js @@ -120,6 +120,7 @@ type ResolverConfigT = { resolverMainFields: ReadonlyArray, schemeResolvers: Readonly<{[scheme: string]: CustomResolver}>, sourceExts: ReadonlyArray, + unstable_applyBrowserSpecAfterExports: boolean, unstable_conditionNames: ReadonlyArray, unstable_conditionsByPlatform: Readonly<{ [platform: string]: ReadonlyArray, diff --git a/packages/metro-resolver/API.md b/packages/metro-resolver/API.md index 7f2a36fd85..e7a6b02c9f 100644 --- a/packages/metro-resolver/API.md +++ b/packages/metro-resolver/API.md @@ -84,6 +84,7 @@ export type ResolutionContext = Readonly<{ resolveRequest?: null | undefined | CustomResolver; schemeResolvers?: Readonly<{[scheme: string]: CustomResolver}> | undefined; sourceExts: ReadonlyArray; + unstable_applyBrowserSpecAfterExports: boolean; unstable_conditionNames: ReadonlyArray; unstable_conditionsByPlatform: Readonly<{ [platform: string]: ReadonlyArray; diff --git a/packages/metro-resolver/src/__tests__/package-exports-test.js b/packages/metro-resolver/src/__tests__/package-exports-test.js index b3166b4541..824950c9c3 100644 --- a/packages/metro-resolver/src/__tests__/package-exports-test.js +++ b/packages/metro-resolver/src/__tests__/package-exports-test.js @@ -1439,4 +1439,157 @@ describe('with package exports resolution enabled', () => { }); }); }); + + describe('"browser" spec redirections with "exports"', () => { + const baseContext = { + ...createResolutionContext({ + [p('/root/src/main.js')]: '', + [p('/root/node_modules/test-pkg/package.json')]: JSON.stringify({ + name: 'test-pkg', + exports: { + '.': './index.js', + './hash': { + browser: './hash-browser.js', + default: './hash-node.js', + }, + './server': { + browser: './server-browser.js', + default: './server-node.js', + }, + './fs': './fs-node.js', + }, + browser: { + './index.js': './index-browser.js', + // Redirects both the exported subpath and the file it resolves + // to, as in multiformats. + './hash': './hash-browser.js', + './hash-node.js': './hash-browser.js', + // Matches the "./server" subpath (as "./server.js"), as in + // react-dom. + './server.js': './server-browser.js', + './fs-node.js': false, + './private': './private-browser.js', + }, + }), + [p('/root/node_modules/test-pkg/index.js')]: '', + [p('/root/node_modules/test-pkg/index-browser.js')]: '', + [p('/root/node_modules/test-pkg/hash-browser.js')]: '', + [p('/root/node_modules/test-pkg/hash-node.js')]: '', + [p('/root/node_modules/test-pkg/server-browser.js')]: '', + [p('/root/node_modules/test-pkg/server-node.js')]: '', + [p('/root/node_modules/test-pkg/fs-node.js')]: '', + [p('/root/node_modules/test-pkg/private-browser.js')]: '', + }), + mainFields: ['react-native', 'browser', 'main'], + originModulePath: p('/root/src/main.js'), + unstable_enablePackageExports: true, + }; + + test('should redirect the requested subpath before "exports" by default', () => { + const logWarning = jest.fn(); + const context = {...baseContext, unstable_logWarning: logWarning}; + + expect(Resolver.resolve(context, 'test-pkg/hash', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/test-pkg/hash-browser.js'), + }); + expect(logWarning).toHaveBeenCalledTimes(1); + expect(logWarning.mock.calls[0][0]).toContain( + 'under the requested subpath "./hash-browser.js"', + ); + }); + + describe('with unstable_applyBrowserSpecAfterExports', () => { + const logWarning = jest.fn(); + const context = { + ...baseContext, + unstable_applyBrowserSpecAfterExports: true, + unstable_logWarning: logWarning, + }; + + beforeEach(() => { + logWarning.mockClear(); + }); + + afterEach(() => { + expect(logWarning).not.toHaveBeenCalled(); + }); + + test('should redirect the file resolved from "exports"', () => { + expect(Resolver.resolve(context, 'test-pkg/hash', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/test-pkg/hash-browser.js'), + }); + expect(Resolver.resolve(context, 'test-pkg/hash', 'web')).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/test-pkg/hash-browser.js'), + }); + }); + + test('should redirect the main entry point resolved from "exports"', () => { + expect(Resolver.resolve(context, 'test-pkg', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/test-pkg/index-browser.js'), + }); + }); + + test('should not redirect an exported subpath', () => { + expect(Resolver.resolve(context, 'test-pkg/server', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/test-pkg/server-node.js'), + }); + expect(Resolver.resolve(context, 'test-pkg/server', 'web')).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/test-pkg/server-browser.js'), + }); + }); + + test('should resolve to the empty module for a file mapped to `false`', () => { + expect(Resolver.resolve(context, 'test-pkg/fs', 'ios')).toEqual({ + type: 'empty', + }); + }); + + test('should not redirect when the field is not in `mainFields`', () => { + expect( + Resolver.resolve( + {...context, mainFields: ['main']}, + 'test-pkg/hash', + 'ios', + ), + ).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/test-pkg/hash-node.js'), + }); + }); + + test('should redirect the file resolved from "exports" for a self-reference', () => { + expect( + Resolver.resolve( + { + ...context, + originModulePath: p('/root/node_modules/test-pkg/index.js'), + }, + 'test-pkg/hash', + 'ios', + ), + ).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/test-pkg/hash-browser.js'), + }); + }); + + test('[nonstrict] should warn about the requested subpath, then redirect it, when it is not exported', () => { + expect(Resolver.resolve(context, 'test-pkg/private', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/test-pkg/private-browser.js'), + }); + expect(logWarning).toHaveBeenCalledTimes(1); + expect(logWarning.mock.calls[0][0]).toContain( + 'under the requested subpath "./private"', + ); + logWarning.mockClear(); + }); + }); + }); }); diff --git a/packages/metro-resolver/src/__tests__/utils.js b/packages/metro-resolver/src/__tests__/utils.js index 8a529c6b48..9779488eb4 100644 --- a/packages/metro-resolver/src/__tests__/utils.js +++ b/packages/metro-resolver/src/__tests__/utils.js @@ -86,6 +86,7 @@ export function createResolutionContext( resolveHastePackage: (name: string) => null, schemeResolvers: {}, sourceExts: ['js', 'jsx', 'json', 'ts', 'tsx'], + unstable_applyBrowserSpecAfterExports: false, unstable_conditionNames: ['require'], unstable_conditionsByPlatform: { web: ['browser'], diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index c85f55f7c3..1703bc672e 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -12,6 +12,7 @@ import type { FileAndDirCandidates, FileCandidates, + FileResolution, PackageForModule, Resolution, ResolutionContext, @@ -262,7 +263,18 @@ export default function resolve( platform, ); if (packageExportsResult != null) { - return packageExportsResult; + if (!context.unstable_applyBrowserSpecAfterExports) { + return packageExportsResult; + } + const result = redirectPackageExportsResolution( + context, + packageExportsResult, + platform, + ); + if (result.type === 'failed') { + throw new FailedToResolvePathError(result.candidates); + } + return result.resolution; } } catch (e) { // NB: Falling back is a departure from the spec, but retained for @@ -406,7 +418,9 @@ function resolveModuleFromTargetPath( platform: string | null, targetPath: string, ): Resolution | null { - const candidate = redirectModulePath(context, targetPath); + const candidate = isBrowserSpecAppliedAfterExports(context, targetPath) + ? targetPath + : redirectModulePath(context, targetPath); if (candidate === false) { return {type: 'empty'}; } @@ -625,7 +639,13 @@ function resolvePackage( ); if (packageExportsResult != null) { - return resolvedAs(packageExportsResult); + return context.unstable_applyBrowserSpecAfterExports + ? redirectPackageExportsResolution( + context, + packageExportsResult, + platform, + ) + : resolvedAs(packageExportsResult); } } catch (e) { if (e instanceof PackagePathNotExportedError) { @@ -648,6 +668,48 @@ function resolvePackage( return resolveModulePath(context, absoluteCandidatePath, platform); } +/** + * Whether to resolve a path inside a package against the package's "exports" + * field before applying "browser" spec redirections, which are then applied to + * the resolved file instead (`unstable_applyBrowserSpecAfterExports`). This + * matches webpack and Vite. + */ +function isBrowserSpecAppliedAfterExports( + context: ResolutionContext, + absoluteCandidatePath: string, +): boolean { + return ( + context.unstable_applyBrowserSpecAfterExports && + context.unstable_enablePackageExports && + context.getPackageForModule(absoluteCandidatePath)?.packageJson.exports != + null + ); +} + +/** + * Apply "browser" spec redirections to a file resolved from "exports". These + * match the resolved file (e.g. "./lib/index.node.js"), not the requested + * subpath (e.g. "./lib"), and are read from the `package.json` closest to the + * file. + */ +function redirectPackageExportsResolution( + context: ResolutionContext, + resolution: FileResolution, + platform: string | null, +): Result { + if (resolution.type !== 'sourceFile') { + return resolvedAs(resolution); + } + const redirectedPath = redirectModulePath(context, resolution.filePath); + if (redirectedPath === false) { + return resolvedAs({type: 'empty'}); + } + if (redirectedPath === resolution.filePath) { + return resolvedAs(resolution); + } + return resolveModulePath(context, redirectedPath, platform); +} + /** * Attempt to resolve a module path as an npm package entry point, or resolve as * a file if no `package.json` file is present. diff --git a/packages/metro-resolver/src/types.js b/packages/metro-resolver/src/types.js index 33525db159..75f2265aa6 100644 --- a/packages/metro-resolver/src/types.js +++ b/packages/metro-resolver/src/types.js @@ -253,6 +253,14 @@ export type ResolutionContext = Readonly<{ schemeResolvers?: Readonly<{[scheme: string]: CustomResolver}>, sourceExts: ReadonlyArray, + + /** + * When resolving a package with an "exports" field, apply "browser" spec + * redirections (from object-valued `mainFields`) to the file that + * "exports" resolves to, instead of to the requested subpath before + * "exports" is considered. This matches webpack and Vite. + */ + unstable_applyBrowserSpecAfterExports: boolean, unstable_conditionNames: ReadonlyArray, unstable_conditionsByPlatform: Readonly<{ [platform: string]: ReadonlyArray, diff --git a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js index d8db716879..c110677ede 100644 --- a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js +++ b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js @@ -53,6 +53,7 @@ type Options = Readonly<{ resolveRequest: ?CustomResolver, schemeResolvers: Readonly<{[scheme: string]: CustomResolver}>, sourceExts: ReadonlyArray, + unstable_applyBrowserSpecAfterExports: boolean, unstable_conditionNames: ReadonlyArray, unstable_conditionsByPlatform: Readonly<{ [platform: string]: ReadonlyArray, @@ -119,6 +120,7 @@ export class ModuleResolver { resolveRequest, schemeResolvers, sourceExts, + unstable_applyBrowserSpecAfterExports, unstable_conditionNames, unstable_conditionsByPlatform, unstable_enablePackageExports, @@ -152,6 +154,7 @@ export class ModuleResolver { resolveRequest, schemeResolvers, sourceExts, + unstable_applyBrowserSpecAfterExports, unstable_conditionNames, unstable_conditionsByPlatform, unstable_enablePackageExports, diff --git a/packages/metro/src/node-haste/DependencyGraph/createModuleResolver.js b/packages/metro/src/node-haste/DependencyGraph/createModuleResolver.js index 4483c3afa9..6ccdadc332 100644 --- a/packages/metro/src/node-haste/DependencyGraph/createModuleResolver.js +++ b/packages/metro/src/node-haste/DependencyGraph/createModuleResolver.js @@ -93,6 +93,8 @@ export default function createModuleResolver({ ...config.resolver.schemeResolvers, }, sourceExts: config.resolver.sourceExts, + unstable_applyBrowserSpecAfterExports: + config.resolver.unstable_applyBrowserSpecAfterExports, unstable_conditionNames: config.resolver.unstable_conditionNames, unstable_conditionsByPlatform: config.resolver.unstable_conditionsByPlatform, From 9c435214f5442ca8f9697d08ce8dcb84170b8229 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 30 Oct 2026 17:46:57 +0200 Subject: [PATCH 2/7] resolver: keep the exports target when its browser redirect is missing 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 --- .../src/__tests__/package-exports-test.js | 28 ++++++++ packages/metro-resolver/src/resolve.js | 64 +++++++++++-------- 2 files changed, 67 insertions(+), 25 deletions(-) diff --git a/packages/metro-resolver/src/__tests__/package-exports-test.js b/packages/metro-resolver/src/__tests__/package-exports-test.js index 824950c9c3..3247ac2fc6 100644 --- a/packages/metro-resolver/src/__tests__/package-exports-test.js +++ b/packages/metro-resolver/src/__tests__/package-exports-test.js @@ -1457,6 +1457,7 @@ describe('with package exports resolution enabled', () => { default: './server-node.js', }, './fs': './fs-node.js', + './broken': './broken-node.js', }, browser: { './index.js': './index-browser.js', @@ -1468,6 +1469,7 @@ describe('with package exports resolution enabled', () => { // react-dom. './server.js': './server-browser.js', './fs-node.js': false, + './broken-node.js': './does-not-exist.js', './private': './private-browser.js', }, }), @@ -1478,6 +1480,7 @@ describe('with package exports resolution enabled', () => { [p('/root/node_modules/test-pkg/server-browser.js')]: '', [p('/root/node_modules/test-pkg/server-node.js')]: '', [p('/root/node_modules/test-pkg/fs-node.js')]: '', + [p('/root/node_modules/test-pkg/broken-node.js')]: '', [p('/root/node_modules/test-pkg/private-browser.js')]: '', }), mainFields: ['react-native', 'browser', 'main'], @@ -1590,6 +1593,31 @@ describe('with package exports resolution enabled', () => { ); logWarning.mockClear(); }); + + test.each([ + ['an import', p('/root/src/main.js')], + ['a self-reference', p('/root/node_modules/test-pkg/index.js')], + ])( + '[nonstrict] should warn and keep the "exports" target when it is redirected to a missing file, for %s', + (_, originModulePath) => { + expect( + Resolver.resolve( + {...context, originModulePath}, + 'test-pkg/broken', + 'ios', + ), + ).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/test-pkg/broken-node.js'), + }); + expect(logWarning).toHaveBeenCalledTimes(1); + expect(logWarning.mock.calls[0][0]).toBe( + `The package ${p('/root/node_modules/test-pkg')} contains an invalid package.json configuration. Consider raising this issue with the package maintainer(s).\n` + + `Reason: The file "${p('/root/node_modules/test-pkg/broken-node.js')}" resolved from "exports" is redirected to "${p('/root/node_modules/test-pkg/does-not-exist.js')}" by the "browser" spec, however this file does not exist. Falling back to the file resolved from "exports".`, + ); + logWarning.mockClear(); + }, + ); }); }); }); diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index 1703bc672e..7893408a6a 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -263,18 +263,13 @@ export default function resolve( platform, ); if (packageExportsResult != null) { - if (!context.unstable_applyBrowserSpecAfterExports) { - return packageExportsResult; - } - const result = redirectPackageExportsResolution( - context, - packageExportsResult, - platform, - ); - if (result.type === 'failed') { - throw new FailedToResolvePathError(result.candidates); - } - return result.resolution; + return context.unstable_applyBrowserSpecAfterExports + ? redirectPackageExportsResolution( + context, + packageExportsResult, + platform, + ) + : packageExportsResult; } } catch (e) { // NB: Falling back is a departure from the spec, but retained for @@ -639,13 +634,15 @@ function resolvePackage( ); if (packageExportsResult != null) { - return context.unstable_applyBrowserSpecAfterExports - ? redirectPackageExportsResolution( - context, - packageExportsResult, - platform, - ) - : resolvedAs(packageExportsResult); + return resolvedAs( + context.unstable_applyBrowserSpecAfterExports + ? redirectPackageExportsResolution( + context, + packageExportsResult, + platform, + ) + : packageExportsResult, + ); } } catch (e) { if (e instanceof PackagePathNotExportedError) { @@ -690,24 +687,41 @@ function isBrowserSpecAppliedAfterExports( * Apply "browser" spec redirections to a file resolved from "exports". These * match the resolved file (e.g. "./lib/index.node.js"), not the requested * subpath (e.g. "./lib"), and are read from the `package.json` closest to the - * file. + * file. A redirection to a file that does not exist is ignored with a + * warning, so that enabling `unstable_applyBrowserSpecAfterExports` cannot + * break an import that resolves without it. */ function redirectPackageExportsResolution( context: ResolutionContext, resolution: FileResolution, platform: string | null, -): Result { +): Resolution { if (resolution.type !== 'sourceFile') { - return resolvedAs(resolution); + return resolution; } const redirectedPath = redirectModulePath(context, resolution.filePath); if (redirectedPath === false) { - return resolvedAs({type: 'empty'}); + return {type: 'empty'}; } if (redirectedPath === resolution.filePath) { - return resolvedAs(resolution); + return resolution; } - return resolveModulePath(context, redirectedPath, platform); + const result = resolveModulePath(context, redirectedPath, platform); + if (result.type === 'resolved') { + return result.resolution; + } + context.unstable_logWarning( + new InvalidPackageConfigurationError({ + packagePath: + context.getPackageForModule(resolution.filePath)?.rootPath ?? + path.dirname(resolution.filePath), + reason: + `The file "${resolution.filePath}" resolved from "exports" is ` + + `redirected to "${redirectedPath}" by the "browser" spec, however ` + + 'this file does not exist.', + }).message + ' Falling back to the file resolved from "exports".', + ); + return resolution; } /** From 6822863eef512a8fba73522f1716c1d68fc42631 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Sun, 1 Nov 2026 11:06:30 +0200 Subject: [PATCH 3/7] docs: describe browser redirect fallback and self-references Co-Authored-By: Claude Opus 5.5 --- docs/Resolution.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/Resolution.md b/docs/Resolution.md index 96019432bd..38e9359f22 100644 --- a/docs/Resolution.md +++ b/docs/Resolution.md @@ -119,7 +119,7 @@ Resolves an absolute path that may be inside an npm package, e.g. a candidate pa 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, 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"}`. + 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_) if that resolves, otherwise log a package configuration warning and continue. E.g. `"browser": {"./lib/index.node.js": "./lib/index.browser.js"}` redirects `"exports": {".": "./lib/index.node.js"}`. 2. Return result. 2. Else, log either a package configuration or package encapsulation warning. 2. Return [**RESOLVE_MODULE**](#resolve_module)(_context_, _filePath_, _platform_). @@ -135,7 +135,7 @@ Lets code inside a package import that package by its own name via its `"exports 3. Split _moduleName_ into a package name (including an optional [scope](https://docs.npmjs.com/cli/v8/using-npm/scope)) and relative path. 4. If the package name does not match the current package's `"name"` field, return no resolution. 5. Get the result of [**RESOLVE_PACKAGE_EXPORTS**](#resolve_package_exports)(_context_, _packagePath_, _filePath_, _exportsField_, _platform_) against the current package's `"exports"` field. - 1. If resolved path exists, return result. + 1. If resolved path exists, perform [**RESOLVE_PACKAGE**](#resolve_package) step 1.1.1 for it, then return result. 2. Else, log either a package configuration or package encapsulation warning, then continue with normal resolution. #### RESOLVE_PACKAGE_EXPORTS From 8e8f1fcead4c3815c4530eca49ff36f308abe726 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 2 Nov 2026 15:53:30 +0200 Subject: [PATCH 4/7] resolver: look up the package once per node_modules resolution 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 --- .../src/__tests__/package-scope-test.js | 25 +++++++ packages/metro-resolver/src/resolve.js | 68 ++++++++++++------- 2 files changed, 68 insertions(+), 25 deletions(-) diff --git a/packages/metro-resolver/src/__tests__/package-scope-test.js b/packages/metro-resolver/src/__tests__/package-scope-test.js index 5c59f44b2d..20f4d5d841 100644 --- a/packages/metro-resolver/src/__tests__/package-scope-test.js +++ b/packages/metro-resolver/src/__tests__/package-scope-test.js @@ -238,7 +238,32 @@ describe('package scope lookups', () => { expect(lookedUpPaths()).toEqual([ p('/root/src/main.js'), p('/root/node_modules/pkg/sub'), + ]); + }); + + test('package subpath with package exports, redirected after "exports"', () => { + useFileMap({ + ...fileMap, + [p('/root/node_modules/pkg/package.json')]: JSON.stringify({ + name: 'pkg', + exports: {'./sub': './lib/sub.js'}, + }), + }); + context = { + ...context, + unstable_applyBrowserSpecAfterExports: true, + unstable_enablePackageExports: true, + }; + expect(Resolver.resolve(context, 'pkg/sub', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/pkg/lib/sub.js'), + }); + // The redirections are read from the scope of the file "exports" resolves + // to, which may differ from the package's. + expect(lookedUpPaths()).toEqual([ + p('/root/src/main.js'), p('/root/node_modules/pkg/sub'), + p('/root/node_modules/pkg/lib/sub.js'), ]); }); }); diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index 7893408a6a..337445f89f 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -413,16 +413,40 @@ function resolveModuleFromTargetPath( platform: string | null, targetPath: string, ): Resolution | null { - const candidate = isBrowserSpecAppliedAfterExports(context, targetPath) - ? targetPath - : redirectModulePath(context, targetPath); + // Look up the package once, for both "browser" spec redirections and + // "exports". With `unstable_applyBrowserSpecAfterExports`, "exports" + // resolves the requested subpath first, and `resolvePackage` applies the + // redirections to the file it resolves to instead. This matches webpack and + // Vite. + 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, + ); if (candidate === false) { return {type: 'empty'}; } - // candidate should be absolute here - we assume that redirectModulePath + // candidate should be absolute here - we assume that redirectPackageSubpath // always returns an absolute path when given an absolute path. - const result = resolvePackage(context, candidate, platform); + const result = resolvePackage( + context, + candidate, + platform, + candidate === targetPath + ? pkg + : context.unstable_enablePackageExports + ? context.getPackageForModule(candidate) + : null, + ); if (result.type === 'resolved') { return result.resolution; } @@ -566,7 +590,14 @@ function resolveHastePackage( return failedFor(); } const potentialModulePath = path.join(packageJsonPath, '..', pathInModule); - const result = resolvePackage(context, potentialModulePath, platform); + const result = resolvePackage( + context, + potentialModulePath, + platform, + context.unstable_enablePackageExports + ? context.getPackageForModule(potentialModulePath) + : null, + ); if (result.type === 'resolved') { return result; } @@ -617,9 +648,14 @@ function resolvePackage( */ absoluteCandidatePath: string, platform: string | null, + /** + * The package containing `absoluteCandidatePath`, from + * `context.getPackageForModule`. Only read when package exports are + * enabled, so callers may pass `null` otherwise. + */ + pkg: ?PackageForModule, ): Result { if (context.unstable_enablePackageExports) { - const pkg = context.getPackageForModule(absoluteCandidatePath); const exportsField = pkg?.packageJson.exports; if (pkg != null && exportsField != null) { @@ -665,24 +701,6 @@ function resolvePackage( return resolveModulePath(context, absoluteCandidatePath, platform); } -/** - * Whether to resolve a path inside a package against the package's "exports" - * field before applying "browser" spec redirections, which are then applied to - * the resolved file instead (`unstable_applyBrowserSpecAfterExports`). This - * matches webpack and Vite. - */ -function isBrowserSpecAppliedAfterExports( - context: ResolutionContext, - absoluteCandidatePath: string, -): boolean { - return ( - context.unstable_applyBrowserSpecAfterExports && - context.unstable_enablePackageExports && - context.getPackageForModule(absoluteCandidatePath)?.packageJson.exports != - null - ); -} - /** * Apply "browser" spec redirections to a file resolved from "exports". These * match the resolved file (e.g. "./lib/index.node.js"), not the requested From 2d49dab79c267832b2c743806555e2d5422ff45e Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 2 Nov 2026 15:53:31 +0200 Subject: [PATCH 5/7] resolver: keep the exports target when its browser redirect throws 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 --- .../src/__tests__/package-exports-test.js | 38 +++++++++++++++---- packages/metro-resolver/src/resolve.js | 21 +++++++--- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/packages/metro-resolver/src/__tests__/package-exports-test.js b/packages/metro-resolver/src/__tests__/package-exports-test.js index 3247ac2fc6..8fb3c631e8 100644 --- a/packages/metro-resolver/src/__tests__/package-exports-test.js +++ b/packages/metro-resolver/src/__tests__/package-exports-test.js @@ -1458,6 +1458,7 @@ describe('with package exports resolution enabled', () => { }, './fs': './fs-node.js', './broken': './broken-node.js', + './dir': './dir-node.js', }, browser: { './index.js': './index-browser.js', @@ -1470,6 +1471,7 @@ describe('with package exports resolution enabled', () => { './server.js': './server-browser.js', './fs-node.js': false, './broken-node.js': './does-not-exist.js', + './dir-node.js': './broken-dir', './private': './private-browser.js', }, }), @@ -1481,6 +1483,9 @@ describe('with package exports resolution enabled', () => { [p('/root/node_modules/test-pkg/server-node.js')]: '', [p('/root/node_modules/test-pkg/fs-node.js')]: '', [p('/root/node_modules/test-pkg/broken-node.js')]: '', + [p('/root/node_modules/test-pkg/dir-node.js')]: '', + [p('/root/node_modules/test-pkg/broken-dir/package.json')]: + JSON.stringify({main: './missing.js'}), [p('/root/node_modules/test-pkg/private-browser.js')]: '', }), mainFields: ['react-native', 'browser', 'main'], @@ -1595,25 +1600,44 @@ describe('with package exports resolution enabled', () => { }); test.each([ - ['an import', p('/root/src/main.js')], - ['a self-reference', p('/root/node_modules/test-pkg/index.js')], + [ + 'a missing file', + 'an import', + 'broken', + 'does-not-exist.js', + p('/root/src/main.js'), + ], + [ + 'a missing file', + 'a self-reference', + 'broken', + 'does-not-exist.js', + p('/root/node_modules/test-pkg/index.js'), + ], + [ + 'a directory with an invalid "main"', + 'an import', + 'dir', + 'broken-dir', + p('/root/src/main.js'), + ], ])( - '[nonstrict] should warn and keep the "exports" target when it is redirected to a missing file, for %s', - (_, originModulePath) => { + '[nonstrict] should warn and keep the "exports" target when it is redirected to %s, for %s', + (_, __, subpath, redirectTarget, originModulePath) => { expect( Resolver.resolve( {...context, originModulePath}, - 'test-pkg/broken', + `test-pkg/${subpath}`, 'ios', ), ).toEqual({ type: 'sourceFile', - filePath: p('/root/node_modules/test-pkg/broken-node.js'), + filePath: p(`/root/node_modules/test-pkg/${subpath}-node.js`), }); expect(logWarning).toHaveBeenCalledTimes(1); expect(logWarning.mock.calls[0][0]).toBe( `The package ${p('/root/node_modules/test-pkg')} contains an invalid package.json configuration. Consider raising this issue with the package maintainer(s).\n` + - `Reason: The file "${p('/root/node_modules/test-pkg/broken-node.js')}" resolved from "exports" is redirected to "${p('/root/node_modules/test-pkg/does-not-exist.js')}" by the "browser" spec, however this file does not exist. Falling back to the file resolved from "exports".`, + `Reason: The file "${p(`/root/node_modules/test-pkg/${subpath}-node.js`)}" resolved from "exports" is redirected to "${p(`/root/node_modules/test-pkg/${redirectTarget}`)}" by the "browser" spec, however this could not be resolved. Falling back to the file resolved from "exports".`, ); logWarning.mockClear(); }, diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index 337445f89f..8b043f4bd0 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -705,9 +705,10 @@ function resolvePackage( * Apply "browser" spec redirections to a file resolved from "exports". These * match the resolved file (e.g. "./lib/index.node.js"), not the requested * subpath (e.g. "./lib"), and are read from the `package.json` closest to the - * file. A redirection to a file that does not exist is ignored with a - * warning, so that enabling `unstable_applyBrowserSpecAfterExports` cannot - * break an import that resolves without it. + * file. A redirection that does not resolve (e.g. to a missing file) is + * ignored with a warning, so that enabling + * `unstable_applyBrowserSpecAfterExports` cannot break an import that resolves + * without it. */ function redirectPackageExportsResolution( context: ResolutionContext, @@ -724,8 +725,16 @@ function redirectPackageExportsResolution( if (redirectedPath === resolution.filePath) { return resolution; } - const result = resolveModulePath(context, redirectedPath, platform); - if (result.type === 'resolved') { + let result: ?Result = null; + try { + result = resolveModulePath(context, redirectedPath, platform); + } catch (e) { + // e.g. a directory whose package.json has a "main" that does not resolve + if (!(e instanceof InvalidPackageError)) { + throw e; + } + } + if (result?.type === 'resolved') { return result.resolution; } context.unstable_logWarning( @@ -736,7 +745,7 @@ function redirectPackageExportsResolution( reason: `The file "${resolution.filePath}" resolved from "exports" is ` + `redirected to "${redirectedPath}" by the "browser" spec, however ` + - 'this file does not exist.', + 'this could not be resolved.', }).message + ' Falling back to the file resolved from "exports".', ); return resolution; From 6785a60b5f1ca4253e96125ab19e2690f2fafef9 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 2 Nov 2026 16:34:44 +0200 Subject: [PATCH 6/7] docs: note both flags and the asset exception for browser after exports Co-Authored-By: Claude Opus 5.5 --- docs/Configuration.md | 2 +- docs/Resolution.md | 6 ++--- .../src/__tests__/package-exports-test.js | 27 +++++++++++++++++++ packages/metro-resolver/src/resolve.js | 3 +++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/docs/Configuration.md b/docs/Configuration.md index e796957dbe..97d4c79efd 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -455,7 +455,7 @@ resolver: { Type: `boolean` -When a package has an [`"exports"` field](https://nodejs.org/docs/latest-v18.x/api/packages.html#exports), apply its [`"browser"` field spec](https://github.com/defunctzombie/package-browser-field-spec) redirections (from any object-valued field in [`resolverMainFields`](#resolvermainfields)) to the file that `"exports"` resolves to, instead of to the requested subpath before `"exports"` is considered. This matches webpack and Vite. Takes effect when [`unstable_enablePackageExports`](#unstable_enablepackageexports-experimental) is `true` (the default). Defaults to `false`. +When a package has an [`"exports"` field](https://nodejs.org/docs/latest-v18.x/api/packages.html#exports), apply its [`"browser"` field spec](https://github.com/defunctzombie/package-browser-field-spec) redirections (from any object-valued field in [`resolverMainFields`](#resolvermainfields)) to the file that `"exports"` resolves to, instead of to the requested subpath before `"exports"` is considered. This matches webpack and Vite, except that assets (see [`assetExts`](#assetexts)) resolved from `"exports"` are not redirected. Takes effect when [`unstable_enablePackageExports`](#unstable_enablepackageexports-experimental) is `true` (the default). Defaults to `false`. For example, given a package with: diff --git a/docs/Resolution.md b/docs/Resolution.md index 38e9359f22..0e7505162b 100644 --- a/docs/Resolution.md +++ b/docs/Resolution.md @@ -79,7 +79,7 @@ Parameters: (_context_, _moduleName_, _platform_) 2. If resolved, return result. 8. If [`context.disableHierarchicalLookup`](#disablehierarchicallookup-boolean) is not `true`, then 1. Try resolving _moduleName_ under `node_modules` from the current directory (the parent of [`context.originModulePath`](#originmodulepath-string)) up to the root directory. E.g. `'lodash/merge'` from `/app/src/screens/Home.js` tries `/app/src/screens/node_modules/lodash/merge`, then `/app/src/node_modules/…`, `/app/node_modules/…` and `/node_modules/…`. - 2. Perform [**RESOLVE_PACKAGE**](#resolve_package)(_context_, _modulePath_, _platform_) for each candidate path, where _modulePath_ is the result of applying [**BROWSER_SPEC_REDIRECTION**](#browser_spec_redirection) to the candidate path (returning the empty module if this is `false`). If [`context.unstable_applyBrowserSpecAfterExports`](#unstable_applybrowserspecafterexports-boolean) is enabled and the candidate path's closest `package.json` has an `"exports"` field, _modulePath_ is the candidate path itself. + 2. Perform [**RESOLVE_PACKAGE**](#resolve_package)(_context_, _modulePath_, _platform_) for each candidate path, where _modulePath_ is the result of applying [**BROWSER_SPEC_REDIRECTION**](#browser_spec_redirection) to the candidate path (returning the empty module if this is `false`). If both [`context.unstable_applyBrowserSpecAfterExports`](#unstable_applybrowserspecafterexports-boolean) and [`context.unstable_enablePackageExports`](#unstable_enablepackageexports-boolean) are enabled and the candidate path's closest `package.json` has an `"exports"` field, _modulePath_ is the candidate path itself. 9. For each element _nodeModulesPath_ of [`context.nodeModulesPaths`](#nodemodulespaths-readonlyarraystring): 1. Try resolving _moduleName_ under _nodeModulesPath_ as if it were another `node_modules` directory (similar to step 8). E.g. `nodeModulesPaths: ['/monorepo/node_modules']` tries `/monorepo/node_modules/lodash/merge`. 2. Perform [**RESOLVE_PACKAGE**](#resolve_package)(_context_, _modulePath_, _platform_) for each candidate path, with _modulePath_ as in step 8.2. @@ -119,7 +119,7 @@ Resolves an absolute path that may be inside an npm package, e.g. a candidate pa 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, 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_) if that resolves, otherwise log a package configuration warning and continue. E.g. `"browser": {"./lib/index.node.js": "./lib/index.browser.js"}` redirects `"exports": {".": "./lib/index.node.js"}`. + 1. If [`context.unstable_applyBrowserSpecAfterExports`](#unstable_applybrowserspecafterexports-boolean) is enabled and the result is a [source file](#source-file) (not [asset files](#asset-files)), 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_) if that resolves, otherwise log a package configuration warning and continue. E.g. `"browser": {"./lib/index.node.js": "./lib/index.browser.js"}` redirects `"exports": {".": "./lib/index.node.js"}`. 2. Return result. 2. Else, log either a package configuration or package encapsulation warning. 2. Return [**RESOLVE_MODULE**](#resolve_module)(_context_, _filePath_, _platform_). @@ -351,7 +351,7 @@ Relative (`./`, `../`) and subpath (`#…`) imports are handled first and never #### `unstable_applyBrowserSpecAfterExports: boolean` -Whether [**BROWSER_SPEC_REDIRECTION**](#browser_spec_redirection) is applied to the file resolved from a package's `"exports"` field, instead of to the requested subpath before `"exports"` is considered. Defaults to [`resolver.unstable_applyBrowserSpecAfterExports`](./Configuration.md#unstable_applybrowserspecafterexports-experimental). +Whether [**BROWSER_SPEC_REDIRECTION**](#browser_spec_redirection) is applied to the source file resolved from a package's `"exports"` field, instead of to the requested subpath before `"exports"` is considered. Defaults to [`resolver.unstable_applyBrowserSpecAfterExports`](./Configuration.md#unstable_applybrowserspecafterexports-experimental). #### `unstable_conditionNames: $ReadOnlyArray` diff --git a/packages/metro-resolver/src/__tests__/package-exports-test.js b/packages/metro-resolver/src/__tests__/package-exports-test.js index 8fb3c631e8..3f5c98c3aa 100644 --- a/packages/metro-resolver/src/__tests__/package-exports-test.js +++ b/packages/metro-resolver/src/__tests__/package-exports-test.js @@ -1163,6 +1163,33 @@ describe('with package exports resolution enabled', () => { '.png', ); }); + + test('should not apply "browser" spec redirections to assets with unstable_applyBrowserSpecAfterExports', () => { + const context = { + ...baseContext, + ...createPackageAccessors({ + [p('/root/node_modules/test-pkg/package.json')]: { + main: './index.js', + exports: { + './icons/metro.png': './assets/icons/metro.png', + }, + browser: {'./assets/icons/metro.png': false}, + }, + }), + resolveAsset: (dirPath: string, basename: string, extension: string) => + [dirPath + path.sep + basename + extension].filter(candidate => + baseContext.doesFileExist(candidate), + ), + unstable_applyBrowserSpecAfterExports: true, + }; + + expect( + Resolver.resolve(context, 'test-pkg/icons/metro.png', null), + ).toEqual({ + type: 'assetFiles', + filePaths: [p('/root/node_modules/test-pkg/assets/icons/metro.png')], + }); + }); }); describe('compatibility with non-standard "exports" array formats', () => { diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index 8b043f4bd0..86c2350084 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -715,6 +715,9 @@ function redirectPackageExportsResolution( resolution: FileResolution, platform: string | null, ): Resolution { + // Asset resolutions are returned as is. They list the files for each scale + // (as returned by `context.resolveAsset`) rather than the "exports" target, + // so there is no single path to match against "browser" spec keys. if (resolution.type !== 'sourceFile') { return resolution; } From 59da96c2ecec3b4bb6cab13dea25e9b7c3a41b43 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:56:01 +0000 Subject: [PATCH 7/7] Name webpack and Vite versions, and correct redirect comments --- docs/Configuration.md | 2 +- docs/PackageExports.md | 2 +- packages/metro-resolver/src/resolve.js | 7 ++----- packages/metro-resolver/src/types.js | 2 +- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/docs/Configuration.md b/docs/Configuration.md index 97d4c79efd..511a752905 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -455,7 +455,7 @@ resolver: { Type: `boolean` -When a package has an [`"exports"` field](https://nodejs.org/docs/latest-v18.x/api/packages.html#exports), apply its [`"browser"` field spec](https://github.com/defunctzombie/package-browser-field-spec) redirections (from any object-valued field in [`resolverMainFields`](#resolvermainfields)) to the file that `"exports"` resolves to, instead of to the requested subpath before `"exports"` is considered. This matches webpack and Vite, except that assets (see [`assetExts`](#assetexts)) resolved from `"exports"` are not redirected. Takes effect when [`unstable_enablePackageExports`](#unstable_enablepackageexports-experimental) is `true` (the default). Defaults to `false`. +When a package has an [`"exports"` field](https://nodejs.org/docs/latest-v18.x/api/packages.html#exports), apply its [`"browser"` field spec](https://github.com/defunctzombie/package-browser-field-spec) redirections (from any object-valued field in [`resolverMainFields`](#resolvermainfields)) to the file that `"exports"` resolves to, instead of to the requested subpath before `"exports"` is considered. This matches webpack 5 and Vite 8, except that assets (see [`assetExts`](#assetexts)) resolved from `"exports"` are not redirected. Takes effect when [`unstable_enablePackageExports`](#unstable_enablepackageexports-experimental) is `true` (the default). Defaults to `false`. For example, given a package with: diff --git a/docs/PackageExports.md b/docs/PackageExports.md index 148a7721c2..0ebf4df308 100644 --- a/docs/PackageExports.md +++ b/docs/PackageExports.md @@ -73,7 +73,7 @@ If a package's `package.json` has an `"exports"` field, Metro consults it first. - `"exports"` is used instead of any `"react-native"`, `"browser"`, or `"main"` field, and instead of a file on disk at the same subpath (e.g. with `"./utils": "./dist/utils.js"`, `'some-pkg/utils'` resolves to `dist/utils.js` even if the package has a root `utils.js`). - **Fallback**: If the requested subpath isn't matched in `"exports"`, Metro tries again using the fields above. -- An object-valued `"browser"` field (or other object-valued [main field](./Configuration.md#resolvermainfields)) can still redirect the requested subpath before `"exports"` is matched. Enable [`resolver.unstable_applyBrowserSpecAfterExports`](./Configuration.md#unstable_applybrowserspecafterexports-experimental) to apply those redirections to the file that `"exports"` resolves to instead, as webpack and Vite do. +- An object-valued `"browser"` field (or other object-valued [main field](./Configuration.md#resolvermainfields)) can still redirect the requested subpath before `"exports"` is matched. Enable [`resolver.unstable_applyBrowserSpecAfterExports`](./Configuration.md#unstable_applybrowserspecafterexports-experimental) to apply those redirections to the file that `"exports"` resolves to instead, as webpack 5 and Vite 8 do. Subpaths matched in `"exports"` (including via [subpath patterns](https://nodejs.org/docs/latest-v19.x/api/packages.html#subpath-patterns)) resolve to the exact target file the package specifies: diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index 86c2350084..6abd277239 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -416,8 +416,7 @@ function resolveModuleFromTargetPath( // Look up the package once, for both "browser" spec redirections and // "exports". With `unstable_applyBrowserSpecAfterExports`, "exports" // resolves the requested subpath first, and `resolvePackage` applies the - // redirections to the file it resolves to instead. This matches webpack and - // Vite. + // redirections to the file it resolves to instead. const pkg = context.getPackageForModule(targetPath); const candidate = pkg == null || @@ -706,9 +705,7 @@ function resolvePackage( * match the resolved file (e.g. "./lib/index.node.js"), not the requested * subpath (e.g. "./lib"), and are read from the `package.json` closest to the * file. A redirection that does not resolve (e.g. to a missing file) is - * ignored with a warning, so that enabling - * `unstable_applyBrowserSpecAfterExports` cannot break an import that resolves - * without it. + * ignored with a warning, keeping the file resolved from "exports". */ function redirectPackageExportsResolution( context: ResolutionContext, diff --git a/packages/metro-resolver/src/types.js b/packages/metro-resolver/src/types.js index 75f2265aa6..ce13deb1d3 100644 --- a/packages/metro-resolver/src/types.js +++ b/packages/metro-resolver/src/types.js @@ -258,7 +258,7 @@ export type ResolutionContext = Readonly<{ * When resolving a package with an "exports" field, apply "browser" spec * redirections (from object-valued `mainFields`) to the file that * "exports" resolves to, instead of to the requested subpath before - * "exports" is considered. This matches webpack and Vite. + * "exports" is considered. This matches webpack 5 and Vite 8. */ unstable_applyBrowserSpecAfterExports: boolean, unstable_conditionNames: ReadonlyArray,