diff --git a/packages/metro-resolver/src/__tests__/package-scope-test.js b/packages/metro-resolver/src/__tests__/package-scope-test.js new file mode 100644 index 0000000000..953afe8dc1 --- /dev/null +++ b/packages/metro-resolver/src/__tests__/package-scope-test.js @@ -0,0 +1,226 @@ +/** + * 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 type {PackageForModule, ResolutionContext} from '../types'; + +import * as Resolver from '../index'; +import { + createPackageAccessors, + createResolutionContext, + posixToSystemPath as p, +} from './utils'; + +// The resolver looks up package scopes through `getPackageForModule`. These +// tests pin down which paths it asks about for each kind of specifier, so +// that changes to the number of lookups per resolution are deliberate. Metro +// memoizes the answer per path, so a repeated path is cheap and a new one is +// not: in particular, the files in a directory are looked up once, through a +// stand-in file name, for all of their extension candidates. +describe('package scope lookups', () => { + // The path the resolver asks about for the files in a directory + const filesIn = (dirPath: string) => p(dirPath + '/.metro-package-scope'); + + const fileMap = { + [p('/root/package.json')]: JSON.stringify({name: 'root'}), + [p('/root/src/main.js')]: '', + [p('/root/src/foo.js')]: '', + [p('/root/src/lib/index.js')]: '', + [p('/root/node_modules/pkg/package.json')]: JSON.stringify({ + name: 'pkg', + main: 'lib/index', + browser: {'./lib/redirected.js': './lib/other.js'}, + }), + [p('/root/node_modules/pkg/lib/index.js')]: '', + [p('/root/node_modules/pkg/lib/sub.js')]: '', + [p('/root/node_modules/pkg/lib/other.js')]: '', + [p('/root/node_modules/nopkg/index.js')]: '', + }; + + let getPackageForModule: JestMockFn<[string], ?PackageForModule>; + let context: ResolutionContext; + + function useFileMap(files: typeof fileMap) { + const accessors = createPackageAccessors(files); + getPackageForModule = jest.fn(accessors.getPackageForModule); + context = { + ...createResolutionContext(files), + ...accessors, + getPackageForModule, + originModulePath: p('/root/src/main.js'), + }; + } + + beforeEach(() => { + useFileMap(fileMap); + }); + + const lookedUpPaths = () => + getPackageForModule.mock.calls.map(([modulePath]) => modulePath); + + test('relative file, found after trying several extensions', () => { + expect(Resolver.resolve(context, './foo', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/src/foo.js'), + }); + expect(lookedUpPaths()).toEqual([p('/root/src/foo'), filesIn('/root/src')]); + }); + + test('relative file, when the stand-in file name is a package root', () => { + useFileMap({ + ...fileMap, + // A package whose "browser" field must not apply to the files beside it + [p('/root/src/.metro-package-scope/package.json')]: JSON.stringify({ + name: 'stand-in', + browser: {'./foo.js': false}, + }), + }); + expect(Resolver.resolve(context, './foo', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/src/foo.js'), + }); + // The stand-in is retried with another name + expect(lookedUpPaths()).toEqual([ + p('/root/src/foo'), + filesIn('/root/src'), + filesIn('/root/src') + '_', + ]); + }); + + test('throws if the scope is never that of the stand-in file', () => { + getPackageForModule = jest.fn(() => ({ + rootPath: p('/root'), + packageJson: {name: 'root'}, + packageRelativePath: '', + })); + context = {...context, getPackageForModule}; + expect(() => Resolver.resolve(context, './foo', 'ios')).toThrow( + `getPackageForModule(${JSON.stringify(filesIn('/root/src') + '__')}) ` + + 'returned packageRelativePath "", which should end in ' + + '".metro-package-scope__".', + ); + expect(lookedUpPaths()).toEqual([ + p('/root/src/foo'), + filesIn('/root/src'), + filesIn('/root/src') + '_', + filesIn('/root/src') + '__', + ]); + }); + + test('relative file in the file system root', () => { + useFileMap({[p('/main.js')]: '', [p('/foo.js')]: ''}); + context = {...context, originModulePath: p('/main.js')}; + expect(Resolver.resolve(context, './foo', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/foo.js'), + }); + expect(lookedUpPaths()).toEqual([p('/foo'), filesIn('')]); + }); + + test('relative file with an explicit extension', () => { + expect(Resolver.resolve(context, './foo.js', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/src/foo.js'), + }); + expect(lookedUpPaths()).toEqual([ + p('/root/src/foo.js'), + filesIn('/root/src'), + ]); + }); + + test('relative directory, resolved to its index', () => { + expect(Resolver.resolve(context, './lib', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/src/lib/index.js'), + }); + expect(lookedUpPaths()).toEqual([ + p('/root/src/lib'), + filesIn('/root/src'), + filesIn('/root/src/lib'), + ]); + }); + + test('package entry point', () => { + expect(Resolver.resolve(context, 'pkg', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/pkg/lib/index.js'), + }); + // The origin, the package directory, the files in node_modules (`pkg` as + // a file), and the files in the directory of the entry point. + expect(lookedUpPaths()).toEqual([ + p('/root/src/main.js'), + p('/root/node_modules/pkg'), + p('/root/node_modules/pkg'), + filesIn('/root/node_modules'), + filesIn('/root/node_modules/pkg/lib'), + ]); + }); + + test('package subpath', () => { + expect(Resolver.resolve(context, 'pkg/lib/sub', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/pkg/lib/sub.js'), + }); + expect(lookedUpPaths()).toEqual([ + p('/root/src/main.js'), + p('/root/node_modules/pkg/lib/sub'), + p('/root/node_modules/pkg/lib/sub'), + filesIn('/root/node_modules/pkg/lib'), + ]); + }); + + test('package subpath redirected by the "browser" field', () => { + expect(Resolver.resolve(context, 'pkg/lib/redirected', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/pkg/lib/other.js'), + }); + // The redirected path may be redirected again by its own scope + expect(lookedUpPaths()).toEqual([ + p('/root/src/main.js'), + p('/root/node_modules/pkg/lib/redirected'), + p('/root/node_modules/pkg/lib/other.js'), + filesIn('/root/node_modules/pkg/lib'), + ]); + }); + + test('module under node_modules without a package.json', () => { + expect(Resolver.resolve(context, 'nopkg', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/nopkg/index.js'), + }); + expect(lookedUpPaths()).toEqual([ + p('/root/src/main.js'), + p('/root/node_modules/nopkg'), + p('/root/node_modules/nopkg'), + filesIn('/root/node_modules'), + filesIn('/root/node_modules/nopkg'), + ]); + }); + + test('package subpath with package exports', () => { + useFileMap({ + ...fileMap, + [p('/root/node_modules/pkg/package.json')]: JSON.stringify({ + name: 'pkg', + exports: {'./sub': './lib/sub.js'}, + }), + }); + context = {...context, unstable_enablePackageExports: true}; + expect(Resolver.resolve(context, 'pkg/sub', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/pkg/lib/sub.js'), + }); + expect(lookedUpPaths()).toEqual([ + p('/root/src/main.js'), + p('/root/node_modules/pkg/sub'), + p('/root/node_modules/pkg/sub'), + ]); + }); +}); diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index 689958f743..61de009490 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -12,6 +12,7 @@ import type { FileAndDirCandidates, FileCandidates, + PackageForModule, Resolution, ResolutionContext, Result, @@ -31,6 +32,7 @@ import { getPackageEntryPoint, matchSubpathFromMainFields, redirectModulePath, + redirectPackageSubpath, } from './PackageResolve'; import resolveAsset from './resolveAsset'; import isAssetFile from './utils/isAssetFile'; @@ -743,7 +745,12 @@ function resolveFile( const candidateExts: Array = []; const filePathPrefix = path.join(dirPath, fileName); - const sfContext = {...context, candidateExts, filePathPrefix}; + const sfContext = { + ...context, + candidateExts, + filePathPrefix, + candidatePackageScope: getCandidatePackageScope(context, dirPath, fileName), + }; const sourceFileResolution = resolveSourceFile(sfContext, platform); if (sourceFileResolution != null) { if (typeof sourceFileResolution === 'string') { @@ -758,6 +765,9 @@ type SourceFileContext = Readonly<{ ...ResolutionContext, candidateExts: Array, filePathPrefix: string, + // The package scope of every candidate extension, with `packageRelativePath` + // that of `filePathPrefix`, or null if they belong to no package + candidatePackageScope: ?PackageForModule, }>; // Either a full path, or a restricted subset of Resolution. @@ -829,9 +839,17 @@ function resolveSourceFileForExt( extension: string, ): SourceFileResolution { const filePath = `${context.filePathPrefix}${extension}`; + const {candidatePackageScope} = context; const redirectedPath = // Any redirections for the bare path have already happened - extension !== '' ? redirectModulePath(context, filePath) : filePath; + extension !== '' && candidatePackageScope != null + ? redirectPackageSubpath( + context, + filePath, + candidatePackageScope, + candidatePackageScope.packageRelativePath + extension, + ) + : filePath; if (redirectedPath === false) { return {type: 'empty'}; } @@ -843,6 +861,59 @@ function resolveSourceFileForExt( return null; } +// A file name, which need not exist, that stands in for any file in a directory +// when asking for their package scope. While it names a package root, `_` is +// appended, up to MAX_ANY_FILE_NAME_ATTEMPTS names in all. +const ANY_FILE_NAME = '.metro-package-scope'; +const MAX_ANY_FILE_NAME_ATTEMPTS = 3; + +/** + * The package scope of the candidate files `fileName` + extension in `dirPath`, + * with the `packageRelativePath` of `fileName` in `dirPath`. Every file directly + * in a directory has the same package scope, which may differ from the scope of + * the directory, or of a directory named `fileName`: a package root is its own + * scope, and a `node_modules` directory is in the scope containing it, but the + * files directly in it are in none. + */ +function getCandidatePackageScope( + context: ResolutionContext, + dirPath: string, + fileName: string, +): ?PackageForModule { + const dirPrefix = dirPath.endsWith(path.sep) ? dirPath : dirPath + path.sep; + let name = ANY_FILE_NAME; + for (let attempt = 1; ; attempt++) { + const modulePath = dirPrefix + name; + const pkg = context.getPackageForModule(modulePath); + if (pkg == null) { + return null; + } + const {packageRelativePath} = pkg; + const dirRelativeLength = packageRelativePath.length - name.length; + if ( + packageRelativePath.endsWith(name) && + (dirRelativeLength === 0 || + packageRelativePath[dirRelativeLength - 1] === path.sep) + ) { + return { + ...pkg, + packageRelativePath: + packageRelativePath.slice(0, dirRelativeLength) + fileName, + }; + } + // Otherwise this is the scope of something other than the files here, + // usually a package root named `name`. + if (attempt === MAX_ANY_FILE_NAME_ATTEMPTS) { + throw new Error( + `getPackageForModule(${JSON.stringify(modulePath)}) returned ` + + `packageRelativePath ${JSON.stringify(packageRelativePath)}, ` + + `which should end in ${JSON.stringify(name)}.`, + ); + } + name += '_'; + } +} + function isRelativeImport(filePath: string) { return /^[.][.]?(?:[/]|$)/.test(filePath); } diff --git a/packages/metro-resolver/src/types.js b/packages/metro-resolver/src/types.js index d9c2338ca4..fae297e1cd 100644 --- a/packages/metro-resolver/src/types.js +++ b/packages/metro-resolver/src/types.js @@ -167,7 +167,16 @@ export type ResolutionContext = Readonly<{ * for a given absolute candidate path (which need not exist), or null if * there is no package.json closer than the nearest node_modules directory. * - * @deprecated See https://github.com/react/metro/commit/29c77bff31e2475a086bc3f04073f485da8f9ff0 + * A path through a symlink is scoped by where the link is: the walk up + * checks the link target directory, then continues from the link's parent. + * A path that does not exist belongs to the package of its deepest existing + * ancestor, unless the first missing segment is `node_modules`. + * + * To learn the scope of every file in a directory at once, the resolver asks + * about a file named `.metro-package-scope` in it, which usually does not + * exist. + * + * The resolver calls this a few times per resolution, so it must be cheap. */ getPackageForModule: (absoluteModulePath: string) => ?PackageForModule, diff --git a/packages/metro/src/DeltaBundler/__tests__/resolver-test.js b/packages/metro/src/DeltaBundler/__tests__/resolver-test.js index 9cb47a37fe..6d30cb29ec 100644 --- a/packages/metro/src/DeltaBundler/__tests__/resolver-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/resolver-test.js @@ -1371,6 +1371,54 @@ function dep(name: string): TransformResultDependency { filePath: p('/root/node_modules/aPackage/main-custom.js'), }); }); + + // The "browser" field of the package containing a file applies to it. + // These use platform-specific files, which are redirected per candidate + // extension rather than with the extensionless path. + describe('package scope of files reached through a symlink', () => { + test('a node_modules symlink to a package root', async () => { + setMockFileSystem({ + 'index.js': '', + store: { + deps: { + 'package.json': JSON.stringify({ + name: 'deps', + browser: {'./foo.ios.js': './foo-browser.js'}, + }), + 'foo.ios.js': '', + 'foo-browser.js': '', + }, + }, + }); + fs.symlinkSync(p('/root/store/deps'), p('/root/node_modules')); + + resolver = await createResolver({}, 'ios'); + expect(resolver.resolve(p('/root/index.js'), dep('foo'))).toEqual({ + type: 'sourceFile', + filePath: p('/root/store/deps/foo-browser.js'), + }); + }); + + test('a symlink to a node_modules directory', async () => { + setMockFileSystem({ + 'package.json': JSON.stringify({ + name: 'app', + browser: {'./vendor/bar.ios.js': false}, + }), + 'index.js': '', + other: {node_modules: {'bar.ios.js': ''}}, + }); + fs.symlinkSync(p('/root/other/node_modules'), p('/root/vendor')); + + resolver = await createResolver({}, 'ios'); + expect( + resolver.resolve(p('/root/index.js'), dep('./vendor/bar')), + ).toEqual({ + type: 'sourceFile', + filePath: p('/root/other/node_modules/bar.ios.js'), + }); + }); + }); }); describe('platforms', () => {