Repository navigation
Resolver: Resolve the package of a directory once for all extension candidates #1945
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'), | ||
| ]); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string> = []; | ||
| 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<string>, | ||
| 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)}.`, | ||
|
Comment on lines
+906
to
+910
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The stand-in is now the directory's To compensate for that edge case we detect that and try |
||
| ); | ||
| } | ||
| name += '_'; | ||
| } | ||
| } | ||
|
|
||
| function isRelativeImport(filePath: string) { | ||
| return /^[.][.]?(?:[/]|$)/.test(filePath); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A leaf symlink candidate was never scoped by its target. For a path that ends in a file symlink,
hierarchicalLookupwalks the ancestors of the link, not those of its target, so the per-candidate lookup onmainnever applied the target package'sbrowsermap either. The output is unchanged. I've added tworesolver-test.jscases that pin this: "a file symlink into another package, mapped by its target package" (the target's map isn't applied) and "...mapped by the package of the link" (the link's package's map is). Both pass withmain's resolver as well.