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..04ec5751d2 --- /dev/null +++ b/packages/metro-resolver/src/__tests__/package-scope-test.js @@ -0,0 +1,222 @@ +/** + * 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'; + +// These pin the paths the resolver passes to `getPackageForModule` for each +// kind of specifier, so that a change in lookups per resolution is deliberate. +describe('package scope lookups', () => { + // The path the resolver asks about for the files in a directory + const filesIn = (dirPath: string) => p(dirPath + '/package.json'); + + 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 its package.json is a package root', () => { + useFileMap({ + ...fileMap, + // A package whose "browser" field must not apply to the files beside it + [p('/root/src/package.json/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 ' + + '"package.json__".', + ); + 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 (twice), `pkg` as a file in + // node_modules, and the entry point's directory. + 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..dc6ef5e22d 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 all candidates, with `packageRelativePath` that of + // `filePathPrefix`, or null if they have none. + 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; } +// Stands in for every file in a directory when looking up their package scope. +// Edge case: if `package.json` is itself a directory that is a package root, +// we append `_` and try again, up to MAX_ANY_FILE_NAME_ATTEMPTS names. +const ANY_FILE_NAME = 'package.json'; +const MAX_ANY_FILE_NAME_ATTEMPTS = 3; + +/** + * The package scope shared by the candidates `fileName` + extension in + * `dirPath`, with `packageRelativePath` that of `fileName`. + * + * Note that this can differ from the scope of `dirPath` itself, or of a + * directory named `fileName` - a package root is its own scope, and files + * directly in `node_modules` have 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..33525db159 100644 --- a/packages/metro-resolver/src/types.js +++ b/packages/metro-resolver/src/types.js @@ -167,6 +167,18 @@ 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. * + * A path through a symlinked directory checks the link's target, then + * continues up from the link's parent. A symlinked file is scoped by where + * the link is. + * + * A missing path takes the scope of its deepest existing ancestor, or null + * if the first missing segment is `node_modules`. + * + * For the scope of every file in a directory, the resolver asks about its + * `package.json` (which needn't exist). + * + * The resolver calls this a few times per resolution, so it should be cheap. + * * @deprecated See https://github.com/react/metro/commit/29c77bff31e2475a086bc3f04073f485da8f9ff0 */ 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..152762d38b 100644 --- a/packages/metro/src/DeltaBundler/__tests__/resolver-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/resolver-test.js @@ -1371,6 +1371,116 @@ function dep(name: string): TransformResultDependency { filePath: p('/root/node_modules/aPackage/main-custom.js'), }); }); + + // These pin which package's "browser" field applies to files reached + // through a symlink. A symlinked directory's target is checked before + // continuing up from the link's parent, and a symlinked file is scoped + // by where the link is. This behavior isn't covered by any spec. + // + // The tests map `.ios.js` files because a "browser" key for a `.js` or + // `.json` file also matches the extensionless path, which is redirected + // separately. Platform-specific keys are only matched per candidate + // extension. + 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'), + }); + }); + + test('a file symlink into another package, mapped by its target package', async () => { + setMockFileSystem({ + 'package.json': JSON.stringify({name: 'app'}), + 'index.js': '', + other: { + pkg: { + 'package.json': JSON.stringify({ + name: 'pkg', + browser: {'./lib/foo.ios.js': './lib/foo-browser.js'}, + }), + lib: {'foo.ios.js': '', 'foo-browser.js': ''}, + }, + }, + }); + fs.symlinkSync( + p('/root/other/pkg/lib/foo.ios.js'), + p('/root/foo.ios.js'), + ); + + resolver = await createResolver({}, 'ios'); + expect(resolver.resolve(p('/root/index.js'), dep('./foo'))).toEqual({ + type: 'sourceFile', + filePath: p('/root/other/pkg/lib/foo.ios.js'), + }); + }); + + test('a file symlink into another package, mapped by the package of the link', async () => { + setMockFileSystem({ + 'package.json': JSON.stringify({ + name: 'app', + browser: {'./foo.ios.js': false}, + }), + 'index.js': '', + 'empty.js': '', + other: { + pkg: { + 'package.json': JSON.stringify({name: 'pkg'}), + lib: {'foo.ios.js': ''}, + }, + }, + }); + fs.symlinkSync( + p('/root/other/pkg/lib/foo.ios.js'), + p('/root/foo.ios.js'), + ); + + resolver = await createResolver( + {resolver: {emptyModulePath: p('/root/empty.js')}}, + 'ios', + ); + expect(resolver.resolve(p('/root/index.js'), dep('./foo'))).toEqual({ + type: 'sourceFile', + filePath: p('/root/empty.js'), + }); + }); + }); }); describe('platforms', () => {