Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,26 @@ resolver: {
},
```

#### `unstable_applyBrowserSpecAfterExports` <div class="label experimental">Experimental</div>

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` <div class="label experimental">Experimental</div>

Type: `Array<string>`
Expand Down
1 change: 1 addition & 0 deletions docs/PackageExports.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,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:

Expand Down
16 changes: 11 additions & 5 deletions docs/Resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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*) 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*).

Expand All @@ -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
Expand Down Expand Up @@ -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<string>`

Additional condition names asserted when resolving the `"exports"` and `"imports"` fields. Defaults to [`resolver.unstable_conditionNames`](./Configuration.md#unstable_conditionnames-experimental).
Expand Down
1 change: 1 addition & 0 deletions packages/metro-config/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export type ResolverConfigT = {
resolverMainFields: ReadonlyArray<string>;
schemeResolvers: Readonly<{[scheme: string]: CustomResolver}>;
sourceExts: ReadonlyArray<string>;
unstable_applyBrowserSpecAfterExports: boolean;
unstable_conditionNames: ReadonlyArray<string>;
unstable_conditionsByPlatform: Readonly<{
[platform: string]: ReadonlyArray<string>;
Expand Down
1 change: 1 addition & 0 deletions packages/metro-config/src/defaults/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const getDefaultValues = (projectRoot: ?string): ConfigT => ({
resolveRequest: null,
resolverMainFields: ['browser', 'main'],
schemeResolvers: {},
unstable_applyBrowserSpecAfterExports: false,
unstable_conditionNames: [],
unstable_conditionsByPlatform: {
web: ['browser'],
Expand Down
1 change: 1 addition & 0 deletions packages/metro-config/src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ type ResolverConfigT = {
resolverMainFields: ReadonlyArray<string>,
schemeResolvers: Readonly<{[scheme: string]: CustomResolver}>,
sourceExts: ReadonlyArray<string>,
unstable_applyBrowserSpecAfterExports: boolean,
unstable_conditionNames: ReadonlyArray<string>,
unstable_conditionsByPlatform: Readonly<{
[platform: string]: ReadonlyArray<string>,
Expand Down
1 change: 1 addition & 0 deletions packages/metro-resolver/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export type ResolutionContext = Readonly<{
resolveRequest?: null | undefined | CustomResolver;
schemeResolvers?: Readonly<{[scheme: string]: CustomResolver}> | undefined;
sourceExts: ReadonlyArray<string>;
unstable_applyBrowserSpecAfterExports: boolean;
unstable_conditionNames: ReadonlyArray<string>;
unstable_conditionsByPlatform: Readonly<{
[platform: string]: ReadonlyArray<string>;
Expand Down
181 changes: 181 additions & 0 deletions packages/metro-resolver/src/__tests__/package-exports-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1439,4 +1439,185 @@ 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',
},
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',
'./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/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([
['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();
},
);
});
});
});
1 change: 1 addition & 0 deletions packages/metro-resolver/src/__tests__/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
Loading
Loading