Skip to content
Closed
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 @@ -310,6 +310,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.

:::note Backwards compatibility

`schemeResolvers` itself is not deprecated. However, when a specifier's scheme has *no* registered resolver, Metro currently falls back to its other resolution methods (Haste, `node_modules`, [`extraNodeModules`](#extranodemodules)) before failing, in case a project already uses scheme-like specifiers with those. This fallback is deprecated and will be removed in a later release, after which an unregistered scheme will fail immediately.

:::

Defaults to `{}`.

#### `useWatchman`

Type: `boolean`
Expand Down
27 changes: 19 additions & 8 deletions docs/Resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,28 +68,31 @@ 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.
2. Otherwise, continue to the following steps, but if none of them resolve *moduleName*, throw a scheme-specific error at step 11 rather than a generic resolution failure. (This fallback exists for backwards compatibility with projects using scheme-like specifiers via Haste or [`extraNodeModules`](#extranodemodules), and is deprecated.)
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 — a scheme-specific error if step 4.2 applied, otherwise a generic one.

#### RESOLVE_MODULE

Expand Down Expand Up @@ -323,6 +326,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
1 change: 1 addition & 0 deletions packages/metro-config/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ 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
107 changes: 106 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,15 @@
*/

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

import {mergeConfig} from '../loadConfig';
import path from 'node:path';

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

describe('resolver path resolution', () => {
// `resolve()` maps a module specifier to an absolute path, relative to
// metro-config. Without it, these paths are later `require`d from
// unrelated modules (e.g. metro-file-map's Haste worker) and fail.
test('resolves hasteImplModulePath and dependencyExtractor to absolute paths', () => {
const base: InputConfigT = {};
const override: InputConfigT = {
resolver: {
hasteImplModulePath: 'metro-core',
dependencyExtractor: 'metro-cache',
},
};
const result = mergeConfig(base, override);

expect(path.isAbsolute(result.resolver?.hasteImplModulePath ?? '')).toBe(
true,
);
expect(path.isAbsolute(result.resolver?.dependencyExtractor ?? '')).toBe(
true,
);
});

test('leaves resolver paths unset when the override does not specify them', () => {
const base: InputConfigT = {};
const override: InputConfigT = {resolver: {}};
const result = mergeConfig(base, override);

expect(result.resolver?.hasteImplModulePath).toBeUndefined();
expect(result.resolver?.dependencyExtractor).toBeUndefined();
});
});

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
5 changes: 5 additions & 0 deletions packages/metro-config/src/loadConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ function mergeConfigObjects<T extends InputConfigT>(
...(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: 2 additions & 1 deletion packages/metro-resolver/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export class FailedToResolvePathError extends Error {
}

export class FailedToResolveUnsupportedError extends Error {
constructor(message: string);
constructor(message: string, options?: {cause?: unknown | undefined});
}

export type FileAndDirCandidates = {
Expand Down Expand Up @@ -82,6 +82,7 @@ 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}> | undefined;
sourceExts: ReadonlyArray<string>;
unstable_conditionNames: ReadonlyArray<string>;
unstable_conditionsByPlatform: Readonly<{
Expand Down
Loading
Loading