From c1c80c7727e01951a148e9f5a3d06196e98eaf72 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Mon, 14 Oct 2024 06:12:48 -0700 Subject: [PATCH] Reserve import specifiers beginning '#' for future package imports support 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 --- docs/Resolution.md | 18 ++--- .../src/__tests__/package-imports-test.js | 65 +++++++++++++++++++ .../errors/FailedToResolveUnsupportedError.js | 20 ++++++ packages/metro-resolver/src/index.js | 1 + packages/metro-resolver/src/resolve.js | 7 ++ .../DependencyGraph/ModuleResolution.js | 10 ++- 6 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 packages/metro-resolver/src/__tests__/package-imports-test.js create mode 100644 packages/metro-resolver/src/errors/FailedToResolveUnsupportedError.js diff --git a/docs/Resolution.md b/docs/Resolution.md index 9cea0eb5be..7abf463e9a 100644 --- a/docs/Resolution.md +++ b/docs/Resolution.md @@ -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 diff --git a/packages/metro-resolver/src/__tests__/package-imports-test.js b/packages/metro-resolver/src/__tests__/package-imports-test.js new file mode 100644 index 0000000000..457066f4e0 --- /dev/null +++ b/packages/metro-resolver/src/__tests__/package-imports-test.js @@ -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(); + }); +}); diff --git a/packages/metro-resolver/src/errors/FailedToResolveUnsupportedError.js b/packages/metro-resolver/src/errors/FailedToResolveUnsupportedError.js new file mode 100644 index 0000000000..bfb268f644 --- /dev/null +++ b/packages/metro-resolver/src/errors/FailedToResolveUnsupportedError.js @@ -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; diff --git a/packages/metro-resolver/src/index.js b/packages/metro-resolver/src/index.js index b22ba1aaba..0ab3406c12 100644 --- a/packages/metro-resolver/src/index.js +++ b/packages/metro-resolver/src/index.js @@ -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'), diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index 485df78fb6..ff9691b754 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -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'; @@ -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 diff --git a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js index b0d481a80a..433cd2bc97 100644 --- a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js +++ b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js @@ -210,8 +210,14 @@ class ModuleResolver { 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