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
36 changes: 36 additions & 0 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,42 @@ resolveRequest: (context, moduleName, platform) => {

For more information on customizing the resolver, see [Module Resolution](https://metrobundler.dev/docs/resolution).

#### `schemeResolvers`

Type: `?{[scheme: string]: `[`CustomResolver`](./Resolution.md#resolverequest-customresolver)`}`

An object of custom resolvers for import specifiers prefixed with a URI scheme, keyed by lowercase scheme name (the prefix before the first `:`, without the colon). When Metro's default resolution encounters a specifier whose scheme matches a registered key (for example `my-scheme:foo` matching `'my-scheme'`), the corresponding resolver is invoked with the full specifier.

```javascript
schemeResolvers: {
'my-scheme': (context, specifier, platform) => {
// `specifier` is the full 'my-scheme:...' string.
// Resolve it to a file, or delegate back to the default resolver via
// `context.resolveRequest(context, someOtherName, platform)`.
return {
type: 'sourceFile',
filePath: '/absolute/path/to/file.js',
};
},
},
```

This differs from [`resolveRequest`](#resolverequest) in a few ways:

- Scheme resolvers run *within* Metro's default resolution rather than replacing it. A user [`resolveRequest`](#resolverequest) still takes precedence, and can delegate back into default resolution (via `context.resolveRequest`), at which point scheme resolvers apply.
- Only specifiers matching a registered scheme are dispatched. Relative (`./`, `../`) and subpath (`#…`) imports are resolved first and are never treated as schemes.
- The resolver receives a `context` whose [`resolveRequest`](./Resolution.md#resolverequest-customresolver) delegates to Metro's default resolution, for easy chaining.

The scheme parsed from a specifier is lowercased before lookup, so keys must be lowercase — both `Foo:` and `foo:` match the `'foo'` key. When multiple configs are combined with `mergeConfig`, `schemeResolvers` are merged per scheme, so a later config replaces an earlier resolver only when it reuses the same (lowercase) key.

:::warning Deprecated

For backwards compatibility, Metro falls back to other methods to resolve the specifier (Haste, `node_modules`, [`extraNodeModules`](#extranodemodules)). This will be removed in a later release.

:::

Defaults to `{}`.

#### `useWatchman`

Type: `boolean`
Expand Down
26 changes: 18 additions & 8 deletions docs/Resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,28 +68,30 @@ Parameters: (*context*, *moduleName*, *platform*)
2. Return the result of [**RESOLVE_MODULE**](#resolve_module)(*context*, *absoluteModuleName*, *platform*), or continue.
3. If *moduleName* begins `'#'`
1. Throw an error. This will be replaced with subpath imports support in a non-breaking future release.
4. Apply [**BROWSER_SPEC_REDIRECTION**](#browser_spec_redirection) to *moduleName*. If this is `false`:
4. If *moduleName* parses as a URL, let *scheme* be the lowercased scheme (the prefix before `':'`), then
1. If [`context.schemeResolvers`](#schemeresolvers-readonlyscheme-string-customresolver) has a resolver registered for *scheme*, return the result of calling it with (*context*, *moduleName*, *platform*), where *context.resolveRequest* is set to the default resolver for chaining.
5. Apply [**BROWSER_SPEC_REDIRECTION**](#browser_spec_redirection) to *moduleName*. If this is `false`:
1. Return the empty module.
5. If [Haste resolutions are allowed](#allowhaste-boolean), then
6. If [Haste resolutions are allowed](#allowhaste-boolean), then
1. Get the result of [**RESOLVE_HASTE**](#resolve_haste)(*context*, *moduleName*, *platform*).
2. If resolved as a Haste package path, then
1. Perform the algorithm for resolving a path (step 2 above). Throw an error if this resolution fails.
For example, if the Haste package path for `'a/b'` is `foo/package.json`, perform step 2 as if _moduleName_ was `foo/c`.
6. If [`context.enablePackageExports`](#enablepackageexports-boolean) is enabled, then
7. If [`context.enablePackageExports`](#enablepackageexports-boolean) is enabled, then
1. Get the result of [**PACKAGE_SELF_RESOLVE**](#package_self_resolve)(*context*, *moduleName*, *platform*).
2. If resolved, return result.
7. If [`context.disableHierarchicalLookup`](#disableHierarchicalLookup-boolean) is not `true`, then
8. If [`context.disableHierarchicalLookup`](#disableHierarchicalLookup-boolean) is not `true`, then
1. Try resolving _moduleName_ under `node_modules` from the current directory (i.e. parent of [`context.originModulePath`](#originmodulepath-string)) up to the root directory.
2. Perform [**RESOLVE_PACKAGE**](#resolve_package)(*context*, *modulePath*, *platform*) for each candidate path.
8. For each element _nodeModulesPath_ of [`context.nodeModulesPaths`](#nodemodulespaths-readonlyarraystring):
1. Try resolving _moduleName_ under _nodeModulesPath_ as if the latter was another `node_modules` directory (similar to step 5 above).
9. For each element _nodeModulesPath_ of [`context.nodeModulesPaths`](#nodemodulespaths-readonlyarraystring):
1. Try resolving _moduleName_ under _nodeModulesPath_ as if the latter was another `node_modules` directory (similar to step 8 above).
2. Perform [**RESOLVE_PACKAGE**](#resolve_package)(*context*, *modulePath*, *platform*) for each candidate path.
9. If [`context.extraNodeModules`](#extranodemodules-string-string) is set:
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. Construct a path _modulePath_ by replacing the package name part of _moduleName_ with the value found in [`context.extraNodeModules`](#extranodemodules-string-string)
2. Return the result of [**RESOLVE_PACKAGE**](#resolve_package)(*context*, *modulePath*, *platform*).
10. If no valid resolution has been found, throw a resolution failure error.
11. If no valid resolution has been found, throw a resolution failure error.

#### RESOLVE_MODULE

Expand Down Expand Up @@ -323,6 +325,14 @@ When calling the default resolver with a non-null `resolveRequest` function, it

Inside a custom resolver, `resolveRequest` is set to the default resolver function, for easy chaining and customization.

#### `schemeResolvers: Readonly<{[scheme: string]: CustomResolver}>`

An object of [custom resolvers](#resolverequest-customresolver) for import specifiers prefixed with a URI scheme, keyed by lowercased scheme name (the part before the first `':'`, without the colon). The scheme parsed from a specifier is lowercased before lookup, so keys must be lowercase (both `Foo:` and `foo:` match the `'foo'` key). Defaults to [`resolver.schemeResolvers`](./Configuration.md#schemeresolvers).

When the default resolver encounters a specifier whose scheme matches a key — for example `my-scheme:foo` matching `'my-scheme'` — it invokes the corresponding resolver with the full specifier. The resolver is passed a `context` whose [`resolveRequest`](#resolverequest-customresolver) is the default resolver, so it can chain back into default resolution (e.g. to resolve a relative path).

Relative (`./`, `../`) and subpath (`#…`) imports are handled before scheme dispatch and are never treated as schemes. See [**RESOLVE**](#resolve) step 4 for how scheme dispatch fits into the algorithm, and [`resolver.schemeResolvers`](./Configuration.md#schemeresolvers) for precedence relative to [`resolveRequest`](#resolverequest-customresolver).

#### `dependency: ?Dependency`

A dependency descriptor corresponding to the current resolution request. This is provided for diagnostic purposes *only* and may not be used for semantic purposes. See the [Caching](#caching) section for more information.
Expand Down
3 changes: 3 additions & 0 deletions packages/metro-config/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ export type ResolverConfigT = {
platforms: ReadonlyArray<string>;
resolveRequest: null | undefined | CustomResolver;
resolverMainFields: ReadonlyArray<string>;
schemeResolvers: Readonly<{
[scheme: string]: CustomResolver;
}>;
sourceExts: ReadonlyArray<string>;
unstable_conditionNames: ReadonlyArray<string>;
unstable_conditionsByPlatform: Readonly<{
Expand Down
74 changes: 73 additions & 1 deletion packages/metro-config/src/__tests__/mergeConfig-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@
*/

import type {InputConfigT} from '../types';
import type {CustomResolver} from 'metro-resolver';

import {mergeConfig} from '../loadConfig';

describe('mergeConfig', () => {
test('can merge empty configs', () => {
expect(mergeConfig({}, {})).toStrictEqual({
resolver: {},
resolver: {schemeResolvers: {}},
serializer: {},
server: {},
symbolicator: {},
Expand Down Expand Up @@ -206,4 +207,75 @@ describe('mergeConfig', () => {
});
});
});

describe('resolver.schemeResolvers merging', () => {
const resolverA: CustomResolver = () => ({type: 'empty'});
const resolverB: CustomResolver = () => ({type: 'empty'});
const resolverC: CustomResolver = () => ({type: 'empty'});

test('deep merges override schemes into base schemes', () => {
const base: InputConfigT = {
resolver: {schemeResolvers: {a: resolverA}},
};
const override: InputConfigT = {
resolver: {schemeResolvers: {b: resolverB}},
};
const result = mergeConfig(base, override);
expect(result.resolver?.schemeResolvers).toStrictEqual({
a: resolverA,
b: resolverB,
});
});

test('override scheme replaces base scheme with the same key', () => {
const base: InputConfigT = {
resolver: {schemeResolvers: {a: resolverA}},
};
const override: InputConfigT = {
resolver: {schemeResolvers: {a: resolverC}},
};
const result = mergeConfig(base, override);
expect(result.resolver?.schemeResolvers?.a).toBe(resolverC);
});

test('keeps base schemeResolvers when override.resolver sets other fields', () => {
const base: InputConfigT = {
resolver: {schemeResolvers: {a: resolverA}},
};
const override: InputConfigT = {resolver: {sourceExts: ['ts']}};
const result = mergeConfig(base, override);
expect(result.resolver?.schemeResolvers).toStrictEqual({a: resolverA});
});

test('applies override schemeResolvers when base has none', () => {
const base: InputConfigT = {resolver: {}};
const override: InputConfigT = {
resolver: {schemeResolvers: {b: resolverB}},
};
const result = mergeConfig(base, override);
expect(result.resolver?.schemeResolvers).toStrictEqual({b: resolverB});
});

test('other resolver properties are preserved when schemeResolvers is merged', () => {
const base: InputConfigT = {
resolver: {sourceExts: ['js'], schemeResolvers: {a: resolverA}},
};
const override: InputConfigT = {
resolver: {schemeResolvers: {b: resolverB}},
};
const result = mergeConfig(base, override);
expect(result.resolver?.sourceExts).toEqual(['js']);
expect(result.resolver?.schemeResolvers).toStrictEqual({
a: resolverA,
b: resolverB,
});
});

test('results in empty schemeResolvers when neither side sets it', () => {
const base: InputConfigT = {resolver: {}};
const override: InputConfigT = {resolver: {}};
const result = mergeConfig(base, override);
expect(result.resolver?.schemeResolvers).toStrictEqual({});
});
});
});
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 @@ -46,6 +46,7 @@ const getDefaultValues = (projectRoot: ?string): ConfigT => ({
nodeModulesPaths: [],
resolveRequest: null,
resolverMainFields: ['browser', 'main'],
schemeResolvers: {},
unstable_conditionNames: [],
unstable_conditionsByPlatform: {
web: ['browser'],
Expand Down
8 changes: 5 additions & 3 deletions packages/metro-config/src/loadConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,11 @@ function mergeConfigObjects<T extends InputConfigT>(
...(overrides.resolver?.dependencyExtractor != null
? {dependencyExtractor: resolve(overrides.resolver.dependencyExtractor)}
: null),
...(overrides.resolver?.hasteImplModulePath != null
? {hasteImplModulePath: resolve(overrides.resolver.hasteImplModulePath)}
: null),
schemeResolvers: {
// $FlowFixMe[exponential-spread]
...base.resolver?.schemeResolvers,
...overrides.resolver?.schemeResolvers,
},
},
serializer: {
...base.serializer,
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 @@ -113,6 +113,7 @@ type ResolverConfigT = {
platforms: ReadonlyArray<string>,
resolveRequest: ?CustomResolver,
resolverMainFields: ReadonlyArray<string>,
schemeResolvers: Readonly<{[scheme: string]: CustomResolver}>,
sourceExts: ReadonlyArray<string>,
unstable_conditionNames: ReadonlyArray<string>,
unstable_conditionsByPlatform: Readonly<{
Expand Down
3 changes: 3 additions & 0 deletions packages/metro-resolver/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ export type ResolutionContext = Readonly<{
resolveHasteModule: (name: string) => null | undefined | string;
resolveHastePackage: (name: string) => null | undefined | string;
resolveRequest?: null | undefined | CustomResolver;
schemeResolvers?: Readonly<{
[scheme: string]: CustomResolver;
}>;
sourceExts: ReadonlyArray<string>;
unstable_conditionNames: ReadonlyArray<string>;
unstable_conditionsByPlatform: Readonly<{
Expand Down
137 changes: 137 additions & 0 deletions packages/metro-resolver/src/__tests__/scheme-resolvers-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/

'use strict';

import type {
CustomResolutionContext,
CustomResolver,
Resolution,
ResolutionContext,
} from '../index';

import {createResolutionContext} from './utils';

const Resolver = require('../index');

const fileMap = {
'/root/project/foo.js': '',
'/root/project/bar.js': '',
};

function createContext(
schemeResolvers: Readonly<{[scheme: string]: CustomResolver}>,
): ResolutionContext {
return {
...createResolutionContext(fileMap),
originModulePath: '/root/project/foo.js',
schemeResolvers,
};
}

type Call = {
context: CustomResolutionContext,
specifier: string,
platform: string | null,
};

function makeCapturingResolver(resolution: Resolution): {
resolver: CustomResolver,
calls: Array<Call>,
} {
const calls: Array<Call> = [];
const resolver: CustomResolver = (context, specifier, platform) => {
calls.push({context, specifier, platform});
return resolution;
};
return {resolver, calls};
}

test('invokes a registered scheme resolver with the full specifier', () => {
const resolution: Resolution = {
type: 'sourceFile',
filePath: '/resolved/by/scheme.js',
};
const {resolver, calls} = makeCapturingResolver(resolution);
const context = createContext({test: resolver});

expect(Resolver.resolve(context, 'test:some/module', 'ios')).toEqual(
resolution,
);
expect(calls).toHaveLength(1);
expect(calls[0].specifier).toBe('test:some/module');
expect(calls[0].platform).toBe('ios');
// The resolver receives a delegating context whose `resolveRequest` is the
// default `resolve`, so it can fall back to standard resolution.
expect(calls[0].context.resolveRequest).toBe(Resolver.resolve);
});

test('scheme resolver can delegate back to default resolution', () => {
const schemeResolver: CustomResolver = (context, specifier, platform) =>
context.resolveRequest(context, './bar', platform);
const context = createContext({test: schemeResolver});

expect(Resolver.resolve(context, 'test:anything', null)).toEqual({
type: 'sourceFile',
filePath: '/root/project/bar.js',
});
});

test('re-throws an error thrown by a registered scheme resolver as FailedToResolveUnsupportedError', () => {
const failing: CustomResolver = () => {
throw new Error('boom while resolving');
};
const context = createContext({test: failing});

expect(() => Resolver.resolve(context, 'test:anything', 'ios')).toThrow(
Resolver.FailedToResolveUnsupportedError,
);
});

test('throws a scheme-specific error for an unregistered scheme once other resolution is exhausted', () => {
const {resolver, calls} = makeCapturingResolver({type: 'empty'});
const context = createContext({test: resolver});

// `other:` parses as a scheme but has no registered resolver, so it falls
// through to Haste/node_modules/extraNodeModules resolution and, only once
// those are exhausted, throws a scheme-specific error.
expect(() => Resolver.resolve(context, 'other:module', null)).toThrow(
Resolver.FailedToResolveUnsupportedError,
);
expect(calls).toHaveLength(0);
});

test('relative specifiers are resolved before scheme dispatch', () => {
const {resolver, calls} = makeCapturingResolver({type: 'empty'});
const context = createContext({test: resolver});

// `./bar` is handled by relative/absolute resolution and must never be
// mistaken for a scheme, even when scheme resolvers are registered.
expect(Resolver.resolve(context, './bar', null)).toEqual({
type: 'sourceFile',
filePath: '/root/project/bar.js',
});
expect(calls).toHaveLength(0);
});

test('does not dispatch a scheme matching an Object.prototype key to an inherited value', () => {
const {resolver, calls} = makeCapturingResolver({type: 'empty'});
const context = createContext({test: resolver});

// `constructor:` is a valid URL scheme that lowercases to `constructor`, an
// `Object.prototype` key. A naive `schemeResolvers[scheme]` read would return
// `Object.prototype.constructor` (non-null) and wrongly invoke it. The
// own-property guard must treat it as unregistered and fall through.
expect(() => Resolver.resolve(context, 'constructor:module', null)).toThrow(
Resolver.FailedToResolveUnsupportedError,
);
expect(calls).toHaveLength(0);
});
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 @@ -84,6 +84,7 @@ export function createResolutionContext(
resolveAsset: (filePath: string) => null,
resolveHasteModule: (name: string) => null,
resolveHastePackage: (name: string) => null,
schemeResolvers: {},
sourceExts: ['js', 'jsx', 'json', 'ts', 'tsx'],
unstable_conditionNames: ['require'],
unstable_conditionsByPlatform: {
Expand Down
Loading
Loading