diff --git a/docs/Configuration.md b/docs/Configuration.md
index efaef875ed..511a752905 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 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:
+
+```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..0ebf4df308 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 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/docs/Resolution.md b/docs/Resolution.md
index 9bceb30fe2..0e7505162b 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 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.
+ 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 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_).
@@ -133,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
@@ -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 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`
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..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', () => {
@@ -1439,4 +1466,209 @@ 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',
+ './broken': './broken-node.js',
+ './dir': './dir-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,
+ './broken-node.js': './does-not-exist.js',
+ './dir-node.js': './broken-dir',
+ './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/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'],
+ 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();
+ });
+
+ test.each([
+ [
+ '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 %s, for %s',
+ (_, __, subpath, redirectTarget, originModulePath) => {
+ expect(
+ Resolver.resolve(
+ {...context, originModulePath},
+ `test-pkg/${subpath}`,
+ 'ios',
+ ),
+ ).toEqual({
+ type: 'sourceFile',
+ 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/${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/__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/__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..6abd277239 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,13 @@ export default function resolve(
platform,
);
if (packageExportsResult != null) {
- return packageExportsResult;
+ return context.unstable_applyBrowserSpecAfterExports
+ ? redirectPackageExportsResolution(
+ context,
+ packageExportsResult,
+ platform,
+ )
+ : packageExportsResult;
}
} catch (e) {
// NB: Falling back is a departure from the spec, but retained for
@@ -406,14 +413,39 @@ function resolveModuleFromTargetPath(
platform: string | null,
targetPath: string,
): Resolution | null {
- const candidate = 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.
+ 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;
}
@@ -557,7 +589,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;
}
@@ -608,9 +647,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) {
@@ -625,7 +669,15 @@ function resolvePackage(
);
if (packageExportsResult != null) {
- return resolvedAs(packageExportsResult);
+ return resolvedAs(
+ context.unstable_applyBrowserSpecAfterExports
+ ? redirectPackageExportsResolution(
+ context,
+ packageExportsResult,
+ platform,
+ )
+ : packageExportsResult,
+ );
}
} catch (e) {
if (e instanceof PackagePathNotExportedError) {
@@ -648,6 +700,57 @@ function resolvePackage(
return resolveModulePath(context, absoluteCandidatePath, platform);
}
+/**
+ * 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 that does not resolve (e.g. to a missing file) is
+ * ignored with a warning, keeping the file resolved from "exports".
+ */
+function redirectPackageExportsResolution(
+ context: ResolutionContext,
+ 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;
+ }
+ const redirectedPath = redirectModulePath(context, resolution.filePath);
+ if (redirectedPath === false) {
+ return {type: 'empty'};
+ }
+ if (redirectedPath === resolution.filePath) {
+ return resolution;
+ }
+ 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(
+ 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 could not be resolved.',
+ }).message + ' Falling back to the file resolved from "exports".',
+ );
+ return resolution;
+}
+
/**
* 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..ce13deb1d3 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 5 and Vite 8.
+ */
+ 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,