diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js index 736d2fe375..702e7adc4e 100644 --- a/packages/metro-file-map/src/lib/TreeFS.js +++ b/packages/metro-file-map/src/lib/TreeFS.js @@ -581,9 +581,9 @@ export default class TreeFS implements MutableFileSystem { // traversal, useful when adding files. Will throw if an expected // directory is already present as a file. makeDirectories?: boolean, - startPathIdx?: number, - startNode?: DirectoryNode, - start?: { + // Resume traversal from a known directory node, whose normal path is + // the prefix of the requested path ending at pathIdx - 1. + start?: ?{ ancestorOfRootIdx: ?number, node: DirectoryNode, pathIdx: number, @@ -844,6 +844,10 @@ export default class TreeFS implements MutableFileSystem { * X = dirname(X) * while X !== dirname(X) * + * Each candidate is checked from its own node: a subpath that is a single + * path segment, such as `package.json`, with one map lookup, and a deeper + * subpath by traversing only the subpath. + * * If `observations` is given, records the canonical paths this result * depends upon. * @@ -863,6 +867,12 @@ export default class TreeFS implements MutableFileSystem { absolutePath: string, containerRelativePath: string, } { + // Only a single path segment can be looked up on a candidate's own node. + const isBasename = + subpath !== '' && + subpath !== '.' && + subpath !== '..' && + !subpath.includes(path.sep); const ancestorsOfInput: Array<{ ancestorOfRootIdx: ?number, node: DirectoryNode, @@ -881,7 +891,11 @@ export default class TreeFS implements MutableFileSystem { subpath, opts.subpathType, observations, - null, + { + ancestorOfRootIdx: closestLookup.ancestorOfRootIdx, + node: closestLookup.node, + }, + isBasename, ); if (maybeAbsolutePathMatch != null) { return { @@ -954,14 +968,8 @@ export default class TreeFS implements MutableFileSystem { subpath, opts.subpathType, observations, - { - ancestorOfRootIdx: candidate.ancestorOfRootIdx, - node: candidate.node, - pathIdx: - candidate.normalPath.length > 0 - ? candidate.normalPath.length + 1 - : 0, - }, + candidate, + isBasename, ); if (maybeAbsolutePathMatch != null) { // Determine the input path relative to the current candidate. Note @@ -1001,7 +1009,11 @@ export default class TreeFS implements MutableFileSystem { subpath, opts.subpathType, observations, - null, + { + ancestorOfRootIdx: commonRootDepth + depthBelowCommonRoot, + node: nextNode, + }, + isBasename, ); if (maybeAbsolutePathMatch != null) { const rootDirParts = this.#pathUtils.getParts(); @@ -1030,24 +1042,74 @@ export default class TreeFS implements MutableFileSystem { return null; } + /** + * Whether the real directory at `normalCandidatePath` has `subpath` of the + * given type, returning the absolute real path of the match. + * + * `candidate` is the directory node at `normalCandidatePath`. When + * `isBasename` and the candidate is the root or a descendant of it, the + * child is checked with one map lookup. An ancestor of the root is + * traversed instead, because its node does not hold the segment leading + * back towards the root. Otherwise, traversal starts from the candidate. + */ #checkCandidateHasSubpath( normalCandidatePath: string, subpath: string, subpathType: 'f' | 'd', observations: ?Observations, - start: ?{ - ancestorOfRootIdx: ?number, - node: DirectoryNode, - pathIdx: number, + candidate: { + readonly ancestorOfRootIdx: ?number, + readonly node: DirectoryNode, + ... }, + isBasename: boolean, ): ?string { - const lookupResult = this.#lookupByNormalPath( - this.#pathUtils.joinNormalToRelative(normalCandidatePath, subpath) - .normalPath, - { - observations, - }, - ); + if ( + isBasename && + (candidate.ancestorOfRootIdx == null || candidate.ancestorOfRootIdx === 0) + ) { + const childNode = candidate.node.get(subpath); + // A symlink must be traversed to learn its type and real path, so only + // a missing child, a directory or a regular file is answered here. + if ( + childNode == null || + isDirectory(childNode) || + isRegularFile(childNode) + ) { + const isMatch = + childNode != null && isDirectory(childNode) === (subpathType === 'd'); + if (!isMatch && observations == null) { + return null; + } + const childNormalPath = + normalCandidatePath === '' + ? subpath + : normalCandidatePath + path.sep + subpath; + if (observations) { + observations.existence.add(childNormalPath); + } + return isMatch + ? this.#pathUtils.normalToAbsolute(childNormalPath) + : null; + } + } + const {normalPath: normalSubpath, collapsedSegments} = + this.#pathUtils.joinNormalToRelative(normalCandidatePath, subpath); + const lookupResult = this.#lookupByNormalPath(normalSubpath, { + observations, + // Traverse only the subpath, starting from the candidate node. If the + // join collapsed segments (e.g. '..' + 'root/package.json'), the + // candidate path is no longer a prefix, so start from the root. + start: + collapsedSegments === 0 + ? { + ancestorOfRootIdx: candidate.ancestorOfRootIdx, + node: candidate.node, + pathIdx: + normalCandidatePath === '' ? 0 : normalCandidatePath.length + 1, + } + : null, + }); if ( lookupResult.exists && // Should be a Map iff subpathType is directory diff --git a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js index a429a7eb05..ebbbafe5e8 100644 --- a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js +++ b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js @@ -528,6 +528,67 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { }); }); + test('finds the closest directory with a single-segment subpath', () => { + const observations = emptyObservations(); + expect( + tfs.hierarchicalLookup( + p('/A/B/C/a/n_m/pkg/subpath/deep'), + 'n_m', + {breakOnSegment: null, subpathType: 'd'}, + observations, + ), + ).toEqual({ + absolutePath: p('/A/B/C/a/n_m/pkg/n_m'), + containerRelativePath: p('subpath/deep'), + }); + expect(observations).toEqual({ + content: new Set(), + existence: new Set([ + p('a/n_m/pkg/subpath/deep/n_m'), + p('a/n_m/pkg/subpath/n_m'), + p('a/n_m/pkg/n_m'), + ]), + }); + }); + + test('finds the closest match for a deep subpath', () => { + const observations = emptyObservations(); + expect( + tfs.hierarchicalLookup( + p('/A/B/C/a/b/c'), + p('n_m/pkg'), + {breakOnSegment: null, subpathType: 'd'}, + observations, + ), + ).toEqual({ + absolutePath: p('/A/B/C/a/n_m/pkg'), + containerRelativePath: p('b/c'), + }); + expect(observations).toEqual({ + content: new Set(), + existence: new Set([p('a/b/c/n_m'), p('a/b/n_m'), p('a/n_m/pkg')]), + }); + }); + + test('finds a subpath re-entering the root from an ancestor of the root', () => { + const observations = emptyObservations(); + expect( + tfs.hierarchicalLookup( + p('/A/foo'), + p('B/C/a/package.json'), + {breakOnSegment: null, subpathType: 'f'}, + observations, + ), + ).toEqual({ + absolutePath: p('/A/B/C/a/package.json'), + containerRelativePath: 'foo', + }); + expect(observations).toEqual({ + content: new Set(), + existence: new Set([p('../../foo'), p('a/package.json')]), + }); + }); + // The fourth column is paths, besides the match itself, whose addition or // removal changes the result, the fifth those whose modification does too - // the symlinks traversed. @@ -662,6 +723,8 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { ) => { const pathMap = (normalPosixPath: string) => mockPathModule.resolve(p('/A/B/C'), p(normalPosixPath)); + const toCanonical = (posixPath: string) => + canonicalTo(p('/A/B/C'))(pathMap(posixPath)); const observations = emptyObservations(); expect( tfs.hierarchicalLookup( @@ -681,8 +744,6 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { containerRelativePath: p(expectedRelativeSubpath), }, ); - const toCanonical = (posixPath: string) => - canonicalTo(p('/A/B/C'))(pathMap(posixPath)); expect(observations).toEqual({ content: new Set(expectedContent.map(toCanonical)), existence: new Set( diff --git a/packages/metro-resolver/src/PackageResolve.js b/packages/metro-resolver/src/PackageResolve.js index 568d5f2aee..f327610a55 100644 --- a/packages/metro-resolver/src/PackageResolve.js +++ b/packages/metro-resolver/src/PackageResolve.js @@ -106,8 +106,6 @@ export function redirectModulePath( return modulePath; } - let redirectedPath; - if (modulePath.startsWith('.') || isModulePathAbsolute) { const packageRelativeModulePath = isModulePathAbsolute ? // If the module path is absolute, containingPackage is relative to it @@ -120,33 +118,23 @@ export function redirectModulePath( path.dirname(containingPackage.packageRelativePath), modulePath, ); - - redirectedPath = matchSubpathFromMainFields( - // Use prefixed POSIX path for lookup in package.json - './' + systemToPosixPath(packageRelativeModulePath), - containingPackage.packageJson, - mainFields, - ); - - if (typeof redirectedPath === 'string') { - // BRITTLE ASSUMPTION: This is always treated as a package-relative path - // and is converted back, even if the redirected path is a specifier - // referring to another package. - redirectedPath = path.isAbsolute(redirectedPath) - ? path.normalize(redirectedPath) - : path.join(containingPackage.rootPath, redirectedPath); - } - } else { - // Otherwise, `modulePath` may be an unprefixed relative path or a bare - // specifier (can also be an absolute specifier prefixed with a URL scheme). - // This is used only by the "browser" spec. - redirectedPath = matchSubpathFromMainFields( + return redirectPackageSubpath( + context, modulePath, - containingPackage.packageJson, - mainFields, + containingPackage, + packageRelativeModulePath, ); } + // Otherwise, `modulePath` may be an unprefixed relative path or a bare + // specifier (can also be an absolute specifier prefixed with a URL scheme). + // This is used only by the "browser" spec. + const redirectedPath = matchSubpathFromMainFields( + modulePath, + containingPackage.packageJson, + mainFields, + ); + if (redirectedPath != null) { return redirectedPath; } @@ -154,6 +142,44 @@ export function redirectModulePath( return modulePath; } +/** + * `redirectModulePath` for a module path whose containing package is already + * known. `packageRelativePath` is the path of the module relative to the + * package root, with system separators. Returns `false` if the module should + * be ignored, and `modulePath` if no `package.json` mapping is matched. + */ +export function redirectPackageSubpath( + context: Readonly<{ + mainFields: ResolutionContext['mainFields'], + ... + }>, + modulePath: string, + containingPackage: Readonly<{ + packageJson: PackageInfo['packageJson'], + rootPath: PackageInfo['rootPath'], + ... + }>, + packageRelativePath: string, +): string | false { + const redirectedPath = matchSubpathFromMainFields( + // Use prefixed POSIX path for lookup in package.json + './' + systemToPosixPath(packageRelativePath), + containingPackage.packageJson, + context.mainFields, + ); + + if (typeof redirectedPath === 'string') { + // BRITTLE ASSUMPTION: This is always treated as a package-relative path + // and is converted back, even if the redirected path is a specifier + // referring to another package. + return path.isAbsolute(redirectedPath) + ? path.normalize(redirectedPath) + : path.join(containingPackage.rootPath, redirectedPath); + } + + return redirectedPath === false ? false : modulePath; +} + /** * Get the mapped replacement for the given subpath defined by matching * `mainFields` entries in the passed `package.json` 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 95e0170e80..7c3086a13b 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', () => { @@ -2849,6 +2897,190 @@ function dep(name: string): TransformResultDependency { }); }); + describe('unstable_incrementalResolution', () => { + const incremental: InputConfigT = { + resolver: {unstable_incrementalResolution: true}, + }; + // Observed paths are canonical: relative to the root, with system + // separators. + const canonical = (...posixPaths: Array) => + new Set(posixPaths.map(posixPath => joinPath(...posixPath.split('/')))); + + test('resolutions carry no observations by default', async () => { + setMockFileSystem({'index.js': '', 'a.js': ''}); + resolver = await createResolver(); + expect( + resolver.resolve(p('/root/index.js'), dep('./a')), + ).not.toHaveProperty('unstable_observations'); + }); + + test('records every candidate probed for a relative import', async () => { + setMockFileSystem({'index.js': '', 'a.js': ''}); + resolver = await createResolver(incremental, 'ios'); + expect(resolver.resolve(p('/root/index.js'), dep('./a'))).toEqual({ + type: 'sourceFile', + filePath: p('/root/a.js'), + unstable_observations: { + // A more specific candidate appearing changes the result + existence: canonical( + 'a', + 'a.ios.js', + 'a.native.js', + 'a.js', + 'package.json', + // The stand-in for the files in the directory, which would + // change their package if it appeared as a package root + '.metro-package-scope', + ), + content: new Set(), + }, + }); + }); + + test('records each level probed for a package', async () => { + setMockFileSystem({ + deep: {dir: {'index.js': ''}}, + node_modules: { + pkg: { + 'package.json': JSON.stringify({name: 'pkg', main: 'main.js'}), + 'main.js': '', + }, + }, + }); + resolver = await createResolver(incremental, 'ios'); + expect( + resolver.resolve(p('/root/deep/dir/index.js'), dep('pkg')), + ).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/pkg/main.js'), + unstable_observations: { + existence: canonical( + // The closest package to the origin + 'deep/dir/index.js', + 'deep/dir/package.json', + 'deep/package.json', + 'package.json', + // Each node_modules on the way up. A missing one is one entry, + // however many packages are looked up beneath it. + 'deep/dir/node_modules', + 'deep/node_modules', + 'node_modules', + // A file is preferred to a package of the same name + 'node_modules/.metro-package-scope', + 'node_modules/pkg.ios.js', + 'node_modules/pkg.native.js', + 'node_modules/pkg.js', + 'node_modules/pkg.ios.json', + 'node_modules/pkg.native.json', + 'node_modules/pkg.json', + 'node_modules/pkg', + 'node_modules/pkg/package.json', + 'node_modules/pkg/.metro-package-scope', + 'node_modules/pkg/main.js', + ), + content: new Set(), + }, + }); + }); + + test('records a traversed symlink as content, and lookups by real path', async () => { + setMockFileSystem({'index.js': '', real: {'x.js': ''}}); + fs.symlinkSync(p('/root/real'), p('/root/link')); + resolver = await createResolver(incremental, 'ios'); + expect(resolver.resolve(p('/root/index.js'), dep('./link/x'))).toEqual({ + type: 'sourceFile', + filePath: p('/root/real/x.js'), + unstable_observations: { + existence: canonical( + 'real/x', + 'real/x.ios.js', + 'real/x.native.js', + 'real/x.js', + 'real/.metro-package-scope', + 'real/package.json', + 'package.json', + ), + // Retargeting the link changes what every path through it means + content: canonical('link'), + }, + }); + }); + + test('records every asset variant probed', async () => { + setMockFileSystem({ + 'index.js': '', + 'asset.png': '', + 'asset@2x.png': '', + }); + resolver = await createResolver(incremental, 'ios'); + expect( + resolver.resolve(p('/root/index.js'), dep('./asset.png')) + .unstable_observations, + ).toEqual({ + existence: canonical( + 'asset.png', + 'asset@1x.png', + 'asset@1.5x.png', + 'asset@2x.png', + 'asset@3x.png', + 'asset@4x.png', + 'package.json', + ), + content: new Set(), + }); + }); + + test('a resolution landing on the empty module includes what resolving the empty module observed', async () => { + setMockFileSystem({ + 'empty.js': '', + withBrowser: { + 'package.json': JSON.stringify({ + name: 'with-browser', + browser: {'./gone.js': false}, + }), + 'index.js': '', + 'gone.js': '', + }, + }); + resolver = await createResolver( + { + resolver: { + emptyModulePath: p('/root/empty.js'), + unstable_incrementalResolution: true, + }, + }, + 'ios', + ); + expect( + resolver.resolve(p('/root/withBrowser/index.js'), dep('./gone')), + ).toEqual({ + type: 'sourceFile', + filePath: p('/root/empty.js'), + unstable_observations: { + existence: canonical( + // What led to the empty module + 'withBrowser/gone', + 'withBrowser/package.json', + // What resolving the empty module observed + 'empty.js', + '.metro-package-scope', + 'package.json', + ), + content: new Set(), + }, + }); + }); + + test('origins sharing a cached resolution share its observations', async () => { + setMockFileSystem({'index.js': '', 'other.js': '', 'a.js': ''}); + resolver = await createResolver(incremental, 'ios'); + const first = resolver.resolve(p('/root/index.js'), dep('./a')); + const second = resolver.resolve(p('/root/other.js'), dep('./a')); + expect(second).toBe(first); + expect(second.unstable_observations?.existence.size).toBeGreaterThan(0); + }); + }); + describe('schemeResolvers', () => { test('config schemeResolvers are applied to scheme-prefixed specifiers', async () => { setMockFileSystem({'index.js': '', 'a.js': ''}); diff --git a/packages/metro/src/DeltaBundler/types.js b/packages/metro/src/DeltaBundler/types.js index ac000fa07a..d114a6dc21 100644 --- a/packages/metro/src/DeltaBundler/types.js +++ b/packages/metro/src/DeltaBundler/types.js @@ -151,9 +151,21 @@ export type AllowOptionalDependenciesWithOptions = { export type AllowOptionalDependencies = boolean | AllowOptionalDependenciesWithOptions; +// What a resolution observed of the file system: canonical paths whose +// addition or removal can change its result, and those whose modification +// can too. Satisfies `metro-file-map`'s `Observations`, which populates it. +export type ResolutionObservations = Readonly<{ + existence: Set, + content: Set, +}>; + export type BundlerResolution = Readonly<{ type: 'sourceFile', filePath: string, + // What this resolution observed of the file system, if + // `resolver.unstable_incrementalResolution` is enabled. It is shared by + // every caller given this resolution, so must not be mutated. + unstable_observations?: ResolutionObservations, }>; export type Options = Readonly<{ diff --git a/packages/metro/src/node-haste/DependencyGraph.js b/packages/metro/src/node-haste/DependencyGraph.js index ee4a63f708..8d5cf1b549 100644 --- a/packages/metro/src/node-haste/DependencyGraph.js +++ b/packages/metro/src/node-haste/DependencyGraph.js @@ -11,6 +11,7 @@ import type { BundlerResolution, + ResolutionObservations, TransformResultDependency, } from '../DeltaBundler/types'; import type {DynamicRoot} from '../lib/dynamicRoots'; @@ -135,8 +136,8 @@ export default class DependencyGraph extends EventEmitter { ); this._resolutionCache = new Map(); this.#packageCache = new PackageCache({ - getClosestPackage: absoluteModulePath => - this._getClosestPackage(absoluteModulePath), + getClosestPackage: (absoluteModulePath, observations) => + this._getClosestPackage(absoluteModulePath, observations), }); this._createModuleResolver(); }); @@ -193,6 +194,7 @@ export default class DependencyGraph extends EventEmitter { _getClosestPackage( absoluteModulePath: string, + observations?: ?ResolutionObservations, ): ?{packageJsonPath: string, packageRelativePath: string} { const result = this._fileSystem.hierarchicalLookup( absoluteModulePath, @@ -201,6 +203,7 @@ export default class DependencyGraph extends EventEmitter { breakOnSegment: 'node_modules', subpathType: 'f', }, + observations, ); return result ? { diff --git a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js index d48a00125d..305cf3dd86 100644 --- a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js +++ b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js @@ -11,13 +11,13 @@ import type { BundlerResolution, + ResolutionObservations, TransformResultDependency, } from '../../DeltaBundler/types'; import type {Reporter} from '../../lib/reporting'; import type {ResolverInputOptions} from '../../shared/types'; import type { CustomResolver, - DoesFileExist, FileCandidates, FileSystemLookup, Resolution, @@ -36,20 +36,34 @@ import util from 'node:util'; type Options = Readonly<{ assetExts: ReadonlySet, disableHierarchicalLookup: boolean, - doesFileExist: DoesFileExist, + doesFileExist: ( + filePath: string, + observations?: ?ResolutionObservations, + ) => boolean, emptyModulePath: string, extraNodeModules: ?Object, - fileSystemLookup: FileSystemLookup, + fileSystemLookup: ( + filePath: string, + observations?: ?ResolutionObservations, + ) => ReturnType, getHasteModulePath: (name: string, platform: ?string) => ?string, getHastePackagePath: (name: string, platform: ?string) => ?string, mainFields: ReadonlyArray, getPackage: (packageJsonPath: string) => ?PackageJson, - getPackageForModule: (absolutePath: string) => ?PackageForModule, + getPackageForModule: ( + absolutePath: string, + observations?: ?ResolutionObservations, + ) => ?PackageForModule, nodeModulesPaths: ReadonlyArray, preferNativePlatform: boolean, projectRoot: string, reporter: Reporter, - resolveAsset: ResolveAsset, + resolveAsset: ( + dirPath: string, + assetName: string, + extension: string, + observations?: ?ResolutionObservations, + ) => ReturnType, resolveRequest: ?CustomResolver, schemeResolvers: Readonly<{[scheme: string]: CustomResolver}>, sourceExts: ReadonlyArray, @@ -61,6 +75,12 @@ type Options = Readonly<{ unstable_incrementalResolution: boolean, }>; +// Every record is created here, with the same properties in the same order, +// so that reading them stays monomorphic on the lookup hot path. +function createObservations(): ResolutionObservations { + return {existence: new Set(), content: new Set()}; +} + export class ModuleResolver { _options: Options; // A module representing the project root, used as the origin when resolving `emptyModulePath`. @@ -125,6 +145,14 @@ export class ModuleResolver { unstable_incrementalResolution, } = this._options; + // Everything this resolution observes of the file system is recorded here. + // The capabilities given to the resolver are bound to it, so that the + // resolution context keeps its shape and a custom resolver records what + // it looks up without having to know about it. + const observations: ?ResolutionObservations = unstable_incrementalResolution + ? createObservations() + : null; + try { const result = Resolver.resolve( createDefaultContext( @@ -134,17 +162,31 @@ export class ModuleResolver { customResolverOptions: resolverOptions.customResolverOptions ?? {}, dev: resolverOptions.dev, disableHierarchicalLookup, - doesFileExist, + doesFileExist: + observations == null + ? doesFileExist + : filePath => doesFileExist(filePath, observations), extraNodeModules, - fileSystemLookup, + fileSystemLookup: + observations == null + ? fileSystemLookup + : filePath => fileSystemLookup(filePath, observations), getPackage, - getPackageForModule, + getPackageForModule: + observations == null + ? getPackageForModule + : absolutePath => + getPackageForModule(absolutePath, observations), isESMImport: dependency.data.isESMImport, mainFields, nodeModulesPaths, originModulePath, preferNativePlatform, - resolveAsset, + resolveAsset: + observations == null + ? resolveAsset + : (dirPath, assetName, extension) => + resolveAsset(dirPath, assetName, extension, observations), resolveHasteModule: (name: string) => this._options.getHasteModulePath(name, platform), resolveHastePackage: (name: string) => @@ -163,7 +205,7 @@ export class ModuleResolver { dependency.name, platform, ); - return this._getFileResolvedModule(result); + return this._getFileResolvedModule(result, observations); } catch (error) { if (error instanceof Resolver.FailedToResolvePathError) { const {candidates} = error; @@ -229,18 +271,53 @@ export class ModuleResolver { /** * TODO: Return Resolution instead of coercing to BundlerResolution here */ - _getFileResolvedModule(resolution: Resolution): BundlerResolution { + _getFileResolvedModule( + resolution: Resolution, + observations: ?ResolutionObservations, + ): BundlerResolution { switch (resolution.type) { case 'sourceFile': - return resolution; + return observations == null + ? resolution + : { + filePath: resolution.filePath, + type: 'sourceFile', + unstable_observations: observations, + }; case 'assetFiles': // FIXME: we should forward ALL the paths/metadata, // not just an arbitrary item! const arbitrary = getArrayLowestItem(resolution.filePaths); invariant(arbitrary != null, 'invalid asset resolution'); - return {filePath: arbitrary, type: 'sourceFile'}; + return observations == null + ? {filePath: arbitrary, type: 'sourceFile'} + : { + filePath: arbitrary, + type: 'sourceFile', + unstable_observations: observations, + }; case 'empty': - return this._getEmptyModule(); + const emptyModule = this._getEmptyModule(); + if (observations == null) { + return emptyModule; + } + // The empty module is resolved once and cached, so a resolution that + // lands on it depends on what that resolution observed as well as on + // what led here. + const emptyObservations = emptyModule.unstable_observations; + if (emptyObservations != null) { + for (const canonicalPath of emptyObservations.existence) { + observations.existence.add(canonicalPath); + } + for (const canonicalPath of emptyObservations.content) { + observations.content.add(canonicalPath); + } + } + return { + filePath: emptyModule.filePath, + type: 'sourceFile', + unstable_observations: observations, + }; case 'virtualModule': // Reserved for future implementation. throw new Error('Virtual modules are not yet implemented.'); diff --git a/packages/metro/src/node-haste/DependencyGraph/createModuleResolver.js b/packages/metro/src/node-haste/DependencyGraph/createModuleResolver.js index fa2db68a69..85efa30ab4 100644 --- a/packages/metro/src/node-haste/DependencyGraph/createModuleResolver.js +++ b/packages/metro/src/node-haste/DependencyGraph/createModuleResolver.js @@ -9,6 +9,7 @@ * @oncall react_native */ +import type {ResolutionObservations} from '../../DeltaBundler/types'; import type {PackageCache} from '../PackageCache'; import type {ConfigT} from 'metro-config'; import type {FileSystem, HasteMap} from 'metro-file-map'; @@ -31,8 +32,11 @@ export default function createModuleResolver({ hasteMap, packageCache, }: CreateModuleResolverOptions): ModuleResolver { - const fileSystemLookup = (filePath: string): ReturnType => { - const result = fileSystem.lookup(filePath); + const fileSystemLookup = ( + filePath: string, + observations?: ?ResolutionObservations, + ): ReturnType => { + const result = fileSystem.lookup(filePath, observations); if (result.exists) { return { exists: true, @@ -46,7 +50,18 @@ export default function createModuleResolver({ return new ModuleResolver({ assetExts: new Set(config.resolver.assetExts), disableHierarchicalLookup: config.resolver.disableHierarchicalLookup, - doesFileExist: (filePath: string) => fileSystem.exists(filePath), + doesFileExist: ( + filePath: string, + observations?: ?ResolutionObservations, + ) => { + if (observations == null) { + return fileSystem.exists(filePath); + } + // `exists` cannot record what it observed, and is true for exactly the + // paths that `lookup` finds to be a file. + const result = fileSystem.lookup(filePath, observations); + return result.exists && result.type === 'f'; + }, emptyModulePath: config.resolver.emptyModulePath, extraNodeModules: config.resolver.extraNodeModules, fileSystemLookup, @@ -62,14 +77,21 @@ export default function createModuleResolver({ return null; } }, - getPackageForModule: (absolutePath: string) => - packageCache.getPackageForModule(absolutePath), + getPackageForModule: ( + absolutePath: string, + observations?: ?ResolutionObservations, + ) => packageCache.getPackageForModule(absolutePath, observations), mainFields: config.resolver.resolverMainFields, nodeModulesPaths: config.resolver.nodeModulesPaths, preferNativePlatform: true, projectRoot: config.projectRoot, reporter: config.reporter, - resolveAsset: (dirPath: string, assetName: string, extension: string) => { + resolveAsset: ( + dirPath: string, + assetName: string, + extension: string, + observations?: ?ResolutionObservations, + ) => { const basePath = dirPath + path.sep + assetName; const assets = [ basePath + extension, @@ -77,7 +99,7 @@ export default function createModuleResolver({ resolution => basePath + '@' + resolution + 'x' + extension, ), ] - .map(assetPath => fileSystemLookup(assetPath).realPath) + .map(assetPath => fileSystemLookup(assetPath, observations).realPath) .filter(Boolean); return assets.length ? assets : null; diff --git a/packages/metro/src/node-haste/PackageCache.js b/packages/metro/src/node-haste/PackageCache.js index 8043365bee..c4fcf806a9 100644 --- a/packages/metro/src/node-haste/PackageCache.js +++ b/packages/metro/src/node-haste/PackageCache.js @@ -9,12 +9,16 @@ * @oncall react_native */ +import type {ResolutionObservations} from '../DeltaBundler/types'; import type {PackageJson} from 'metro-resolver/private/types'; import {readFileSync} from 'node:fs'; -import {dirname, sep} from 'node:path'; +import {dirname} from 'node:path'; -type GetClosestPackageFn = (absoluteFilePath: string) => ?{ +type GetClosestPackageFn = ( + absoluteFilePath: string, + observations?: ?ResolutionObservations, +) => ?{ packageJsonPath: string, packageRelativePath: string, }; @@ -40,12 +44,6 @@ export class PackageCache { packageJson: PackageJson, }, >; - // Single cache: module path → pre-built result object, or null (no allocation on hit) - #resultByModulePath: Map; - // Reverse index for invalidation: package.json path → set of module paths - #modulePathsByPackagePath: Map>; - // Module paths that resolved to no package.json (null), for invalidation - #modulePathsWithNoPackage: Set; constructor(options: { getClosestPackage: GetClosestPackageFn, @@ -55,9 +53,6 @@ export class PackageCache { this.#getClosestPackage = options.getClosestPackage; this.#readPackageJson = options.readPackageJson ?? readPackageJsonSync; this.#packageCache = new Map(); - this.#resultByModulePath = new Map(); - this.#modulePathsByPackagePath = new Map(); - this.#modulePathsWithNoPackage = new Set(); } getPackage(filePath: string): Readonly<{ @@ -75,86 +70,27 @@ export class PackageCache { return cached; } - getPackageForModule(absoluteModulePath: string): ?PackageForModule { - const cached = this.#resultByModulePath.get(absoluteModulePath); - - // Distinguish between `null` (positively no closest package) and - // `undefined` (no cached result yet) - // eslint-disable-next-line lint/strictly-null - if (cached !== undefined) { - return cached; - } - - const closest = this.#getClosestPackage(absoluteModulePath); + /** + * The closest package is looked up on every call rather than remembered per + * module path. Only the parsed contents of each `package.json` are cached. + */ + getPackageForModule( + absoluteModulePath: string, + observations?: ?ResolutionObservations, + ): ?PackageForModule { + const closest = this.#getClosestPackage(absoluteModulePath, observations); if (closest == null) { - this.#resultByModulePath.set(absoluteModulePath, null); - this.#modulePathsWithNoPackage.add(absoluteModulePath); - return null; - } - - const packagePath = closest.packageJsonPath; - - // Track module→package for invalidation - let modulePaths = this.#modulePathsByPackagePath.get(packagePath); - if (modulePaths == null) { - modulePaths = new Set(); - this.#modulePathsByPackagePath.set(packagePath, modulePaths); - } - modulePaths.add(absoluteModulePath); - - const pkg = this.getPackage(packagePath); - if (pkg == null) { return null; } - - // Cache the pre-built result object — no allocation on future hits - const result: PackageForModule = { + const pkg = this.getPackage(closest.packageJsonPath); + return { packageJson: pkg.packageJson, packageRelativePath: closest.packageRelativePath, rootPath: pkg.rootPath, }; - this.#resultByModulePath.set(absoluteModulePath, result); - return result; } invalidate(filePath: string) { this.#packageCache.delete(filePath); - - // Clean up any cached result for this module path (including null). - // Derive the package.json path from the cached result to clean up the - // reverse index. - const cachedResult = this.#resultByModulePath.get(filePath); - this.#resultByModulePath.delete(filePath); - this.#modulePathsWithNoPackage.delete(filePath); - - if (cachedResult != null) { - const packagePath = cachedResult.rootPath + sep + 'package.json'; - const modules = this.#modulePathsByPackagePath.get(packagePath); - if (modules != null) { - modules.delete(filePath); - if (modules.size === 0) { - this.#modulePathsByPackagePath.delete(packagePath); - } - } - } - - // If filePath is a package.json, invalidate all module lookups pointing to it - const modulePaths = this.#modulePathsByPackagePath.get(filePath); - if (modulePaths != null) { - for (const modulePath of modulePaths) { - this.#resultByModulePath.delete(modulePath); - } - this.#modulePathsByPackagePath.delete(filePath); - } - - // If a package.json was created, modified, or deleted, invalidate all - // null-cached module results, since modules that previously had no - // enclosing package.json may now resolve to this one. - if (filePath.endsWith(sep + 'package.json')) { - for (const modulePath of this.#modulePathsWithNoPackage) { - this.#resultByModulePath.delete(modulePath); - } - this.#modulePathsWithNoPackage.clear(); - } } } diff --git a/packages/metro/src/node-haste/__tests__/PackageCache-test.js b/packages/metro/src/node-haste/__tests__/PackageCache-test.js index d83be20744..33f261141e 100644 --- a/packages/metro/src/node-haste/__tests__/PackageCache-test.js +++ b/packages/metro/src/node-haste/__tests__/PackageCache-test.js @@ -39,21 +39,6 @@ function mockPackageJson(filePath: string, json: {name: string, ...}) { }); } -function mockMultiplePackageJsons( - packages: Array<[string, {name: string, ...}]>, -) { - mockReadFileSync.mockImplementation((path, encoding) => { - if (encoding === 'utf8') { - for (const [filePath, json] of packages) { - if (path === filePath) { - return JSON.stringify(json); - } - } - } - throw new Error(`ENOENT: no such file: ${String(path)}`); - }); -} - beforeEach(() => { mockReadFileSync.mockReset(); }); @@ -63,343 +48,63 @@ const PKG_PATH = PKG_ROOT + sep + 'package.json'; const MODULE_A = PKG_ROOT + sep + 'moduleA.js'; const MODULE_B = PKG_ROOT + sep + 'moduleB.js'; -const PKG2_ROOT = sep + ['project', 'lib'].join(sep); -const PKG2_PATH = PKG2_ROOT + sep + 'package.json'; -const MODULE_C = PKG2_ROOT + sep + 'moduleC.js'; - -const MODULE_NO_PKG = sep + ['orphan', 'module.js'].join(sep); - describe('PackageCache', () => { - describe('invalidate', () => { - test('invalidates a package.json and clears module lookups pointing to it', () => { - const closestPackages: ClosestPackageMap = new Map([ - [ - MODULE_A, - {packageJsonPath: PKG_PATH, packageRelativePath: 'moduleA.js'}, - ], - [ - MODULE_B, - {packageJsonPath: PKG_PATH, packageRelativePath: 'moduleB.js'}, - ], - ]); - const cache = createPackageCache(closestPackages); - mockPackageJson(PKG_PATH, {name: 'test-pkg'}); - - // Populate cache - const resultA1 = cache.getPackageForModule(MODULE_A); - const resultB1 = cache.getPackageForModule(MODULE_B); - expect(resultA1?.packageJson.name).toBe('test-pkg'); - expect(resultB1?.packageJson.name).toBe('test-pkg'); - - // Invalidate the package.json - cache.invalidate(PKG_PATH); - - // Update the mock to return new content - mockPackageJson(PKG_PATH, {name: 'updated-pkg'}); - - // Both modules should now return the updated package - const resultA2 = cache.getPackageForModule(MODULE_A); - const resultB2 = cache.getPackageForModule(MODULE_B); - expect(resultA2?.packageJson.name).toBe('updated-pkg'); - expect(resultB2?.packageJson.name).toBe('updated-pkg'); - }); - - test('invalidates a module file and clears its cached result', () => { - const closestPackages: ClosestPackageMap = new Map([ - [ - MODULE_A, - {packageJsonPath: PKG_PATH, packageRelativePath: 'moduleA.js'}, - ], - ]); - const cache = createPackageCache(closestPackages); - mockPackageJson(PKG_PATH, {name: 'test-pkg'}); - - // Populate cache - cache.getPackageForModule(MODULE_A); - expect(mockReadFileSync).toHaveBeenCalledTimes(1); - - // Module result is cached - no new reads - cache.getPackageForModule(MODULE_A); - expect(mockReadFileSync).toHaveBeenCalledTimes(1); - - // Invalidate the module - cache.invalidate(MODULE_A); - - // Next lookup should re-resolve - cache.getPackageForModule(MODULE_A); - // package.json is still cached, so no additional readFileSync - expect(mockReadFileSync).toHaveBeenCalledTimes(1); - }); - - test('invalidating a module cleans up the reverse index to prevent memory leaks', () => { - const closestPackages: ClosestPackageMap = new Map([ - [ - MODULE_A, - {packageJsonPath: PKG_PATH, packageRelativePath: 'moduleA.js'}, - ], - [ - MODULE_B, - {packageJsonPath: PKG_PATH, packageRelativePath: 'moduleB.js'}, - ], - ]); - const cache = createPackageCache(closestPackages); - mockPackageJson(PKG_PATH, {name: 'test-pkg'}); - - // Populate both modules - cache.getPackageForModule(MODULE_A); - cache.getPackageForModule(MODULE_B); - - // Invalidate module A - cache.invalidate(MODULE_A); - - // Invalidate the package.json - only module B should need re-resolution - cache.invalidate(PKG_PATH); - mockPackageJson(PKG_PATH, {name: 'updated-pkg'}); - - // Module B should re-resolve to the updated package - const resultB = cache.getPackageForModule(MODULE_B); - expect(resultB?.packageJson.name).toBe('updated-pkg'); - - // Module A should also re-resolve (it was already invalidated) - const resultA = cache.getPackageForModule(MODULE_A); - expect(resultA?.packageJson.name).toBe('updated-pkg'); - }); - - test('null-cached results are invalidated when a new package.json is created', () => { - const closestPackages = new Map< - string, - ?{packageJsonPath: string, packageRelativePath: string}, - >([[MODULE_NO_PKG, null]]); - const cache = createPackageCache(closestPackages); - - // Module resolves to null (no enclosing package.json) - expect(cache.getPackageForModule(MODULE_NO_PKG)).toBe(null); - - // Cached null is returned - expect(cache.getPackageForModule(MODULE_NO_PKG)).toBe(null); - - // Simulate creation of a new package.json that now covers this module - const newPkgRoot = sep + 'orphan'; - const newPkgPath = newPkgRoot + sep + 'package.json'; - closestPackages.set(MODULE_NO_PKG, { - packageJsonPath: newPkgPath, - packageRelativePath: 'module.js', - }); - mockPackageJson(newPkgPath, {name: 'new-pkg'}); - - // Invalidate the new package.json (file watcher would trigger this) - cache.invalidate(newPkgPath); - - // Module should now resolve to the new package, not stale null - const result = cache.getPackageForModule(MODULE_NO_PKG); - expect(result?.packageJson.name).toBe('new-pkg'); - expect(result?.packageRelativePath).toBe('module.js'); - }); - - test('null-cached results are not invalidated by non-package.json file changes', () => { - const closestPackages: ClosestPackageMap = new Map< - string, - ?{packageJsonPath: string, packageRelativePath: string}, - >([[MODULE_NO_PKG, null]]); - const cache = createPackageCache(closestPackages); - - // Module resolves to null - expect(cache.getPackageForModule(MODULE_NO_PKG)).toBe(null); - - // Invalidate an unrelated file (not a package.json) - cache.invalidate(sep + ['orphan', 'other.js'].join(sep)); - - // Null result should still be cached (closestPackages unchanged) - expect(cache.getPackageForModule(MODULE_NO_PKG)).toBe(null); - }); - - test('invalidating a module with null result cleans up null-tracking set', () => { - const closestPackages = new Map< - string, - ?{packageJsonPath: string, packageRelativePath: string}, - >([ - [MODULE_NO_PKG, null], - [ - MODULE_A, - {packageJsonPath: PKG_PATH, packageRelativePath: 'moduleA.js'}, - ], - ]); - const cache = createPackageCache(closestPackages); - mockPackageJson(PKG_PATH, {name: 'test-pkg'}); - - // Populate caches - expect(cache.getPackageForModule(MODULE_NO_PKG)).toBe(null); - expect(cache.getPackageForModule(MODULE_A)?.packageJson.name).toBe( - 'test-pkg', - ); - - // Invalidate the null-cached module directly - cache.invalidate(MODULE_NO_PKG); - - // Re-resolve - still null since closestPackages hasn't changed - expect(cache.getPackageForModule(MODULE_NO_PKG)).toBe(null); - }); - - test('modules across different packages are independently invalidated', () => { - const closestPackages: ClosestPackageMap = new Map([ - [ - MODULE_A, - {packageJsonPath: PKG_PATH, packageRelativePath: 'moduleA.js'}, - ], - [ - MODULE_C, - {packageJsonPath: PKG2_PATH, packageRelativePath: 'moduleC.js'}, - ], - ]); - const cache = createPackageCache(closestPackages); - mockMultiplePackageJsons([ - [PKG_PATH, {name: 'pkg1'}], - [PKG2_PATH, {name: 'pkg2'}], - ]); - - // Populate cache - expect(cache.getPackageForModule(MODULE_A)?.packageJson.name).toBe( - 'pkg1', - ); - expect(cache.getPackageForModule(MODULE_C)?.packageJson.name).toBe( - 'pkg2', - ); - - // Invalidate only pkg1's package.json - cache.invalidate(PKG_PATH); - mockMultiplePackageJsons([ - [PKG_PATH, {name: 'pkg1-updated'}], - [PKG2_PATH, {name: 'pkg2'}], - ]); - - // Module A should re-resolve to updated pkg1 - expect(cache.getPackageForModule(MODULE_A)?.packageJson.name).toBe( - 'pkg1-updated', - ); - - // Module C should still return cached pkg2 (unchanged) - expect(cache.getPackageForModule(MODULE_C)?.packageJson.name).toBe( - 'pkg2', - ); - }); - - test('package.json deletion invalidates associated modules and null results', () => { - const closestPackages = new Map< - string, - ?{packageJsonPath: string, packageRelativePath: string}, - >([ - [ - MODULE_A, - {packageJsonPath: PKG_PATH, packageRelativePath: 'moduleA.js'}, - ], - [MODULE_NO_PKG, null], - ]); - const cache = createPackageCache(closestPackages); - mockPackageJson(PKG_PATH, {name: 'test-pkg'}); - - // Populate both caches - cache.getPackageForModule(MODULE_A); - expect(cache.getPackageForModule(MODULE_NO_PKG)).toBe(null); - - // Simulate package.json deletion - module A's closest package changes - closestPackages.set(MODULE_A, null); - - // Invalidate the deleted package.json - cache.invalidate(PKG_PATH); - - // Module A should re-resolve (now to null) - expect(cache.getPackageForModule(MODULE_A)).toBe(null); - - // The orphan module's null cache was also cleared (package.json invalidation) - // so it re-resolves, still to null - expect(cache.getPackageForModule(MODULE_NO_PKG)).toBe(null); - }); - - test('invalidating the same file twice is safe', () => { - const closestPackages: ClosestPackageMap = new Map([ - [ - MODULE_A, - {packageJsonPath: PKG_PATH, packageRelativePath: 'moduleA.js'}, - ], - ]); - const cache = createPackageCache(closestPackages); - mockPackageJson(PKG_PATH, {name: 'test-pkg'}); - - cache.getPackageForModule(MODULE_A); - - // Double invalidation should not throw - cache.invalidate(MODULE_A); - cache.invalidate(MODULE_A); - - cache.invalidate(PKG_PATH); - cache.invalidate(PKG_PATH); - - mockPackageJson(PKG_PATH, {name: 'updated-pkg'}); - expect(cache.getPackageForModule(MODULE_A)?.packageJson.name).toBe( - 'updated-pkg', - ); - }); - - test('invalidating a file not in any cache is a no-op', () => { - const closestPackages: ClosestPackageMap = new Map([ - [ - MODULE_A, - {packageJsonPath: PKG_PATH, packageRelativePath: 'moduleA.js'}, - ], - ]); - const cache = createPackageCache(closestPackages); - mockPackageJson(PKG_PATH, {name: 'test-pkg'}); - - cache.getPackageForModule(MODULE_A); - - // Invalidating an unrelated file should not affect anything - cache.invalidate(sep + ['unrelated', 'file.js'].join(sep)); - - // Cached result is still valid - const result = cache.getPackageForModule(MODULE_A); - expect(result?.packageJson.name).toBe('test-pkg'); - expect(mockReadFileSync).toHaveBeenCalledTimes(1); - }); - - test('multiple null-cached modules are all invalidated when package.json appears', () => { - const moduleD = sep + ['orphan', 'deep', 'moduleD.js'].join(sep); - const moduleE = sep + ['orphan', 'deep', 'moduleE.js'].join(sep); - const closestPackages: ClosestPackageMap = new Map< - string, - ?{packageJsonPath: string, packageRelativePath: string}, - >([ - [moduleD, null], - [moduleE, null], - ]); - const cache = createPackageCache(closestPackages); - - // Both resolve to null - expect(cache.getPackageForModule(moduleD)).toBe(null); - expect(cache.getPackageForModule(moduleE)).toBe(null); - - // New package.json created - const newPkgRoot = sep + ['orphan', 'deep'].join(sep); - const newPkgPath = newPkgRoot + sep + 'package.json'; - closestPackages.set(moduleD, { - packageJsonPath: newPkgPath, - packageRelativePath: 'moduleD.js', - }); - closestPackages.set(moduleE, { - packageJsonPath: newPkgPath, - packageRelativePath: 'moduleE.js', - }); - mockPackageJson(newPkgPath, {name: 'orphan-pkg'}); + test('reads each package.json once, however many modules it contains', () => { + const closestPackages: ClosestPackageMap = new Map([ + [ + MODULE_A, + {packageJsonPath: PKG_PATH, packageRelativePath: 'moduleA.js'}, + ], + [ + MODULE_B, + {packageJsonPath: PKG_PATH, packageRelativePath: 'moduleB.js'}, + ], + ]); + const cache = createPackageCache(closestPackages); + mockPackageJson(PKG_PATH, {name: 'test-pkg'}); + + expect(cache.getPackageForModule(MODULE_A)?.packageRelativePath).toBe( + 'moduleA.js', + ); + expect(cache.getPackageForModule(MODULE_B)?.packageRelativePath).toBe( + 'moduleB.js', + ); + cache.getPackageForModule(MODULE_A); - cache.invalidate(newPkgPath); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + }); - // Both modules should now resolve - expect(cache.getPackageForModule(moduleD)?.packageJson.name).toBe( - 'orphan-pkg', - ); - expect(cache.getPackageForModule(moduleE)?.packageJson.name).toBe( - 'orphan-pkg', - ); - }); + test('re-reads an invalidated package.json, for every module in the package', () => { + const closestPackages: ClosestPackageMap = new Map([ + [ + MODULE_A, + {packageJsonPath: PKG_PATH, packageRelativePath: 'moduleA.js'}, + ], + [ + MODULE_B, + {packageJsonPath: PKG_PATH, packageRelativePath: 'moduleB.js'}, + ], + ]); + const cache = createPackageCache(closestPackages); + mockPackageJson(PKG_PATH, {name: 'test-pkg'}); + + // Read and parse the package.json + const resultA1 = cache.getPackageForModule(MODULE_A); + const resultB1 = cache.getPackageForModule(MODULE_B); + expect(resultA1?.packageJson.name).toBe('test-pkg'); + expect(resultB1?.packageJson.name).toBe('test-pkg'); + + // Invalidate the package.json + cache.invalidate(PKG_PATH); + + // Update the mock to return new content + mockPackageJson(PKG_PATH, {name: 'updated-pkg'}); + + // Both modules should now return the updated package + const resultA2 = cache.getPackageForModule(MODULE_A); + const resultB2 = cache.getPackageForModule(MODULE_B); + expect(resultA2?.packageJson.name).toBe('updated-pkg'); + expect(resultB2?.packageJson.name).toBe('updated-pkg'); }); });