Skip to content

Commit

Permalink
Reserve import specifiers beginning '#' for future package imports su…
Browse files Browse the repository at this point in the history
…pport

Summary:
We don't have [package.json subpath imports](https://nodejs.org/api/packages.html#subpath-imports) support yet, but we'd like to give ourselves room to implement it as a non-breaking change at some point in Metro 0.81 / React Native 0.76.

This reserves a space at the right place in the resolution algorithm to add an implementation later, according to https://nodejs.org/api/esm.html#resolution-algorithm-specification.

The breaking change here is that `#foo` is no longer a valid package or Haste name, or browser-field key. It will always be interpreted at a subpath import. This is consistent with the rest of the ecosystem and so shouldn't cause any real-world friction.

Changelog:
```
 - **[Breaking]** Resolver: Reserve import specifiers beginning '#' exclusively for future subpath imports support.
```

Reviewed By: huntie

Differential Revision: D64316184

fbshipit-source-id: d48613e66e904812aef7fef0e2e6fe0e54b5f4ff
  • Loading branch information
robhogan authored and facebook-github-bot committed Oct 14, 2024
1 parent 5e96d17 commit c1c80c7
Show file tree
Hide file tree
Showing 6 changed files with 111 additions and 10 deletions.
18 changes: 10 additions & 8 deletions docs/Resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,28 +63,30 @@ Parameters: (*context*, *moduleName*, *platform*)

1. If a [custom resolver](#resolverequest-customresolver) is defined, then
1. Return the result of the custom resolver.
2. Otherwise, attempt to resolve *moduleName* as a path
1. Let *absoluteModuleName* be the result of prepending the current directory (i.e. parent of [`context.originModulePath`](#originmodulepath-string)) with *moduleName*.
2. If *moduleName* is an absolute path, or equal to `'.'` or `'..'`, or begins `'./'` or `'../'`
1. Let *absoluteModuleName* be *moduleName* if it is absolute path, otherwise the result of prepending the current directory (i.e. parent of [`context.originModulePath`](#originmodulepath-string)) with *moduleName*.
2. Return the result of [**RESOLVE_MODULE**](#resolve_module)(*context*, *absoluteModuleName*, *platform*), or continue.
3. Apply [**BROWSER_SPEC_REDIRECTION**](#browser_spec_redirection) to *moduleName*. If this is `false`:
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`:
1. Return the empty module.
4. If [Haste resolutions are allowed](#allowhaste-boolean), then
5. 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`.
5. If [`context.disableHierarchicalLookup`](#disableHierarchicalLookup-boolean) is not `true`, then
6. 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.
6. For each element _nodeModulesPath_ of [`context.nodeModulesPaths`](#nodemodulespaths-readonlyarraystring):
7. 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).
2. Perform [**RESOLVE_PACKAGE**](#resolve_package)(*context*, *modulePath*, *platform*) for each candidate path.
7. If [`context.extraNodeModules`](#extranodemodules-string-string) is set:
8. 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*).
8. If no valid resolution has been found, throw a resolution failure error.
9. If no valid resolution has been found, throw a resolution failure error.

#### RESOLVE_MODULE

Expand Down
65 changes: 65 additions & 0 deletions packages/metro-resolver/src/__tests__/package-imports-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* 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
*/

import {createResolutionContext} from './utils';

// Implementation of PACKAGE_IMPORTS_RESOLVE described in https://nodejs.org/api/esm.html
describe('subpath imports resolution support', () => {
let Resolver;
const mockRedirectModulePath = jest.fn();

beforeEach(() => {
jest.resetModules();
jest.mock('../PackageResolve', () => ({
...jest.requireActual('../PackageResolve'),
redirectModulePath: mockRedirectModulePath,
}));
Resolver = require('../index');
});

test('specifiers beginning # are reserved for future package imports support', () => {
const mockNeverCalledFn = jest.fn();
const mockCustomResolver = jest
.fn()
.mockImplementation((ctx, ...args) => ctx.resolveRequest(ctx, ...args));

const context = {
...createResolutionContext({}),
originModulePath: '/root/src/main.js',
doesFileExist: mockNeverCalledFn,
fileSystemLookup: mockNeverCalledFn,
redirectModulePath: mockNeverCalledFn,
resolveHasteModule: mockNeverCalledFn,
resolveHastePackage: mockNeverCalledFn,
resolveRequest: mockCustomResolver,
};

expect(() => Resolver.resolve(context, '#foo', null)).toThrow(
new Resolver.FailedToResolveUnsupportedError(
'Specifier starts with "#" but subpath imports are not currently supported.',
),
);

// Ensure any custom resolver *is* still called first.
expect(mockCustomResolver).toBeCalledTimes(1);
expect(mockCustomResolver).toBeCalledWith(
expect.objectContaining({
originModulePath: '/root/src/main.js',
}),
'#foo',
null,
);

// Ensure package imports precedes any other attempt at resolution for a '#' specifier.
expect(mockNeverCalledFn).not.toHaveBeenCalled();
expect(mockRedirectModulePath).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* 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
* @format
* @oncall react_native
*/

'use strict';

class FailedToResolveUnsupportedError extends Error {
constructor(message: string) {
super(message);
}
}

module.exports = FailedToResolveUnsupportedError;
1 change: 1 addition & 0 deletions packages/metro-resolver/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type {
const Resolver = {
FailedToResolveNameError: require('./errors/FailedToResolveNameError'),
FailedToResolvePathError: require('./errors/FailedToResolvePathError'),
FailedToResolveUnsupportedError: require('./errors/FailedToResolveUnsupportedError'),
formatFileCandidates: require('./errors/formatFileCandidates'),
InvalidPackageError: require('./errors/InvalidPackageError'),
resolve: require('./resolve'),
Expand Down
7 changes: 7 additions & 0 deletions packages/metro-resolver/src/resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {

import FailedToResolveNameError from './errors/FailedToResolveNameError';
import FailedToResolvePathError from './errors/FailedToResolvePathError';
import FailedToResolveUnsupportedError from './errors/FailedToResolveUnsupportedError';
import formatFileCandidates from './errors/formatFileCandidates';
import InvalidPackageConfigurationError from './errors/InvalidPackageConfigurationError';
import InvalidPackageError from './errors/InvalidPackageError';
Expand Down Expand Up @@ -57,6 +58,12 @@ function resolve(
return result.resolution;
}

if (moduleName.startsWith('#')) {
throw new FailedToResolveUnsupportedError(
'Specifier starts with "#" but subpath imports are not currently supported.',
);
}

const realModuleName = redirectModulePath(context, moduleName);

// exclude
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,14 @@ class ModuleResolver<TPackage: Packageish> {
dependency,
},
);
}
if (error instanceof Resolver.FailedToResolveNameError) {
} else if (error instanceof Resolver.FailedToResolveUnsupportedError) {
throw new UnableToResolveError(
fromModule.path,
dependency.name,
error.message,
{cause: error, dependency},
);
} else if (error instanceof Resolver.FailedToResolveNameError) {
const dirPaths = error.dirPaths;
const extraPaths = error.extraPaths;
const displayDirPaths = dirPaths
Expand Down

0 comments on commit c1c80c7

Please sign in to comment.