diff --git a/packages/metro-file-map/API.md b/packages/metro-file-map/API.md index 44a0f989d2..c173505cfb 100644 --- a/packages/metro-file-map/API.md +++ b/packages/metro-file-map/API.md @@ -199,6 +199,7 @@ interface FileSystem_2 { recursive?: boolean | undefined; rootDir?: Path | null | undefined; }): Iterable; + readonly openDir?: ((this: this, mixedPath: Path, observations?: null | undefined | Observations) => null | undefined | OpenedDir) | undefined; } export {FileSystem_2 as FileSystem}; @@ -297,6 +298,11 @@ export class NoopCacheManager implements CacheManager { write(): Promise; } +export type OpenedDir = { + realPath: string; + lookup(basename: string, observations?: null | undefined | Observations): LookupResult; +}; + export type WatcherStatus = | { type: 'watchman_slow_command'; diff --git a/packages/metro-file-map/src/flow-types.js b/packages/metro-file-map/src/flow-types.js index cfb4f35338..d9cd4c7e31 100644 --- a/packages/metro-file-map/src/flow-types.js +++ b/packages/metro-file-map/src/flow-types.js @@ -438,6 +438,25 @@ export interface FileSystem { */ lookup(mixedPath: Path, observations?: ?Observations): LookupResult; + /** + * Resolve `mixedPath` to a directory. Always follow symlinks, and return an + * `OpenedDir` when it exists, mirroring `lookup`. Returns `null` when the + * path is missing or not a directory. + * + * This is an optional method for optimization. The returned handle checks + * direct children without repeating the directory traversal. + * It must not be reused after filesystem mutations. + * + * Observations passed to `openDir` match `lookup` of the directory. + * Child lookups record sufficient observations to invalidate their results, + * including symlinks followed to reach the directory. + */ + readonly openDir?: ( + this: this, + mixedPath: Path, + observations?: ?Observations, + ) => ?OpenedDir; + matchFiles(opts: { /* Filter relative paths against a pattern. */ filter?: RegExp | null, @@ -492,6 +511,20 @@ export type LookupResult = metadata: FileMetadata, }; +/** + * A directory handle resolved by `FileSystem.openDir`. Valid only while the + * filesystem is unchanged during use. `lookup` checks one direct basename and + * returns the same result as `FileSystem.lookup` of that child through the + * original directory path. + * + * Observations include symlinks followed to reach the directory, but aren't + * necessarily identical to those of a full lookup. + */ +export type OpenedDir = { + realPath: string, + lookup(basename: string, observations?: ?Observations): LookupResult, +}; + /** * A mutable record of what a query observed of the file system, sufficient to * decide later whether its answer may have changed. diff --git a/packages/metro-file-map/src/index.js b/packages/metro-file-map/src/index.js index f0970e7c6d..618c57fa81 100644 --- a/packages/metro-file-map/src/index.js +++ b/packages/metro-file-map/src/index.js @@ -166,6 +166,7 @@ export type { DependencyExtractor, FileDataResult, FileMapRoot, + OpenedDir, WatcherStatus, } from './flow-types'; diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js index c0004699bc..58b6910604 100644 --- a/packages/metro-file-map/src/lib/TreeFS.js +++ b/packages/metro-file-map/src/lib/TreeFS.js @@ -17,6 +17,7 @@ import type { LookupResult, MutableFileSystem, Observations, + OpenedDir, Path, ProcessFileFunction, } from '../flow-types'; @@ -27,6 +28,10 @@ import {RootPathUtils} from './RootPathUtils'; import invariant from 'invariant'; import path from 'node:path'; +interface ContentObservations { + readonly content: Observations['content']; +} + type DirectoryNode = Map; type FileNode = FileMetadata; type MixedNode = FileNode | DirectoryNode; @@ -387,6 +392,134 @@ export default class TreeFS implements MutableFileSystem { return {exists: true, canonicalPath, realPath, type: 'f', metadata: node}; } + /** + * See `FileSystem.openDir`. The walk stops at the directory node so later + * basename checks do not repeat it. Symlinks followed to get here are + * replayed onto each child lookup: the child path is canonical, so a fresh + * `lookup` of that path would not traverse those links again. + */ + openDir( + this: TreeFS, + mixedPath: Path, + observations?: ?Observations, + ): ?OpenedDir { + const symlinkPaths = new Set(); + const normalPath = this.#normalizePath(mixedPath); + const result = this.#lookupByNormalPath(normalPath, { + observations: {content: symlinkPaths}, + followLeaf: true, + }); + if (observations) { + for (const symlinkPath of symlinkPaths) { + observations.content.add(symlinkPath); + } + } + if (!result.exists) { + if (observations) { + observations.existence.add(result.canonicalMissingPath); + } + return null; + } + const {canonicalPath, node, ancestorOfRootIdx} = result; + if (observations) { + observations.existence.add(canonicalPath); + } + if (!isDirectory(node)) { + return null; + } + const realPath = this.#pathUtils.normalToAbsolute(canonicalPath); + // Ancestor Maps omit the child leading back toward the project root. + // Use normal lookup for that child so normalization finds its node. + const childBasenameTowardRoot = + ancestorOfRootIdx != null && ancestorOfRootIdx > 0 + ? this.#pathUtils.getBasenameOfNthAncestor(ancestorOfRootIdx - 1) + : null; + return { + realPath, + lookup: ( + basename: string, + childObservations?: ?Observations, + ): LookupResult => { + if (childObservations != null && symlinkPaths.size > 0) { + for (const symlinkPath of symlinkPaths) { + childObservations.content.add(symlinkPath); + } + } + if (basename === childBasenameTowardRoot) { + return this.lookup( + canonicalPath === '' + ? basename + : canonicalPath + path.sep + basename, + childObservations, + ); + } + return this.#lookupInOpenedDir( + node, + canonicalPath, + basename, + childObservations, + ); + }, + }; + } + + #lookupInOpenedDir( + directoryNode: DirectoryNode, + canonicalDir: string, + basename: string, + observations: ?Observations, + ): LookupResult { + // Empty names, '.', '..', and nested paths are not Map keys in a directory. + // Fall back to preserve `lookup`'s normalization and '..' handling. + if ( + basename === '' || + basename === '.' || + basename === '..' || + basename.includes(path.sep) || + basename.includes('/') + ) { + return this.lookup( + canonicalDir === '' ? basename : canonicalDir + path.sep + basename, + observations, + ); + } + + const segmentNode = directoryNode.get(basename); + const canonicalPath = + canonicalDir === '' ? basename : canonicalDir + path.sep + basename; + if (segmentNode == null) { + if (observations) { + observations.existence.add(canonicalPath); + } + return {exists: false}; + } else if (isDirectory(segmentNode)) { + if (observations) { + observations.existence.add(canonicalPath); + } + return { + exists: true, + canonicalPath, + realPath: this.#pathUtils.normalToAbsolute(canonicalPath), + type: 'd', + }; + } else if (isRegularFile(segmentNode)) { + if (observations) { + observations.existence.add(canonicalPath); + } + return { + exists: true, + canonicalPath, + realPath: this.#pathUtils.normalToAbsolute(canonicalPath), + type: 'f', + metadata: segmentNode, + }; + } else { + // The full traversal handles cycles and nested links for symlink leaves. + // Directory symlinks were already recorded by the caller. + return this.lookup(canonicalPath, observations); + } + } + getAllFiles(): Array { return Array.from( this.metadataIterator({ @@ -627,7 +760,9 @@ export default class TreeFS implements MutableFileSystem { * fails, we automatically have the real path of the first non-existent node. * * Note that this code is extremely hot during resolution, being the most - * expensive part of a file existence check. Benchmark any modifications! + * expensive part of a file existence check. Callers that probe many + * basenames in one directory should use `openDir` so the walk happens + * once. Benchmark any modifications! */ #lookupByNormalPath( requestedNormalPath: string, @@ -640,7 +775,7 @@ export default class TreeFS implements MutableFileSystem { }>, // Record into which the canonical paths of traversed symlinks will be // added, as `content`. Omit for performance if not needed. - observations?: ?Observations, + observations?: ?ContentObservations, // Low-level callbacks called on mutations of TreeFS data. // Omit for performance if not needed. 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 1e3eeafdae..2187614f33 100644 --- a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js +++ b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js @@ -264,6 +264,173 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { }); }); + describe('openDir', () => { + test.each([ + p('/project/bar.js'), + p('/project/foo/another.js'), + p('/project/foo/link-to-bar.js'), + p('/project/foo/link-to-another.js'), + p('/project/link-to-foo/another.js'), + p('/project/link-to-foo/link-to-another.js'), + p('/project/link-to-foo/missing.js'), + p('/project/foo/missing.js'), + p('/outside/external.js'), + p('/project'), + p('/project/foo/..'), + ])('lookup(%s) matches openDir(dirname).lookup(basename)', fullPath => { + const directObservations = emptyObservations(); + const childObservations = emptyObservations(); + const direct = tfs.lookup(fullPath, directObservations); + const opened = tfs.openDir(mockPathModule.dirname(fullPath)); + expect(opened).not.toBeNull(); + if (opened == null) { + return; + } + expect( + opened.lookup(mockPathModule.basename(fullPath), childObservations), + ).toEqual(direct); + expect(childObservations).toEqual(directObservations); + }); + + test('child observations exclude unrelated observations from opening', () => { + const openObservations = emptyObservations(); + openObservations.content.add(p('unrelated-before')); + const opened = tfs.openDir(p('/project/link-to-foo'), openObservations); + openObservations.content.add(p('unrelated-after')); + const childObservations = emptyObservations(); + const directObservations = emptyObservations(); + expect(opened?.lookup('another.js', childObservations)).toEqual( + tfs.lookup(p('/project/link-to-foo/another.js'), directObservations), + ); + expect(childObservations).toEqual(directObservations); + expect(openObservations.content).toEqual( + new Set([ + p('unrelated-before'), + p('link-to-foo'), + p('unrelated-after'), + ]), + ); + }); + + test.each([ + [p('cycle-link'), '', p('/project/cycle.js')], + [p('foo/cycle.js'), 'bar.js', p('/project/bar.js')], + ])( + 'cycle observations track changes to %s', + (changedPath, target, realPath) => { + tfs.addOrModify(p('cycle.js'), [0, 0, 0, null, 0, null]); + tfs.addOrModify(p('cycle-link'), [0, 0, 0, null, 'foo', null]); + tfs.addOrModify(p('foo/cycle.js'), [ + 0, + 0, + 0, + null, + 'cycle-link/cycle.js', + null, + ]); + const directObservations = emptyObservations(); + const childObservations = emptyObservations(); + expect( + tfs.lookup(p('/project/cycle-link/cycle.js'), directObservations), + ).toEqual({exists: false}); + expect( + tfs + .openDir(p('/project/cycle-link')) + ?.lookup('cycle.js', childObservations), + ).toEqual({exists: false}); + // Cycle detection can record different missing paths. Both lookups must + // observe the symlinks whose modification can break the cycle. + for (const observations of [directObservations, childObservations]) { + expect(observations.content).toEqual( + new Set([p('cycle-link'), p('foo/cycle.js')]), + ); + expect(observations.content.has(changedPath)).toBe(true); + } + + tfs.addOrModify(changedPath, [0, 0, 0, null, target, null]); + expect(tfs.lookup(p('/project/cycle-link/cycle.js'))).toMatchObject({ + exists: true, + realPath, + }); + // Reopen after mutation; existing handles are no longer valid. + expect( + tfs.openDir(p('/project/cycle-link'))?.lookup('cycle.js'), + ).toEqual(tfs.lookup(p('/project/cycle-link/cycle.js'))); + }, + ); + + test.each([ + [p('/project/foo'), p('/project/foo')], + [p('/project/foo/'), p('/project/foo')], + [p('/project/link-to-foo'), p('/project/foo')], + [p('/project'), p('/project')], + ])('openDir(%s) agrees with lookup', (givenPath, realPath) => { + const lookupObservations = emptyObservations(); + const openObservations = emptyObservations(); + expect(tfs.lookup(givenPath, lookupObservations)).toMatchObject({ + exists: true, + type: 'd', + realPath, + }); + const opened = tfs.openDir(givenPath, openObservations); + expect(opened).not.toBeNull(); + expect(opened?.realPath).toBe(realPath); + expect(openObservations).toEqual(lookupObservations); + }); + + test.each([ + p('/project/bar.js'), + p('/project/link-to-nowhere'), + p('/project/not/exists'), + p('/project/missing.js'), + ])( + 'openDir(%s) is null and records the same observations as lookup', + givenPath => { + const lookupObservations = emptyObservations(); + const openObservations = emptyObservations(); + const lookedUp = tfs.lookup(givenPath, lookupObservations); + expect(lookedUp.exists && lookedUp.type === 'd').toBe(false); + expect(tfs.openDir(givenPath, openObservations)).toBeNull(); + expect(openObservations).toEqual(lookupObservations); + }, + ); + + test('root lookup finds a direct child', () => { + const opened = tfs.openDir(p('/project')); + expect(opened).not.toBeNull(); + expect(opened?.lookup('bar.js')).toEqual( + tfs.lookup(p('/project/bar.js')), + ); + expect(opened?.lookup('nope.js')).toEqual({exists: false}); + }); + + test.each([p('/data'), p('/data/nested')])( + 'ancestor handles can look back toward project root %s', + rootDir => { + const tree = new TreeFS({ + rootDir, + files: new Map([[p('file.js'), [0, 0, 0, null, 0, null]]]), + processFile: () => { + throw new Error('Not implemented'); + }, + }); + let childPath = rootDir; + while (mockPathModule.dirname(childPath) !== childPath) { + const parentPath = mockPathModule.dirname(childPath); + const directObservations = emptyObservations(); + const childObservations = emptyObservations(); + expect( + tree + .openDir(parentPath) + ?.lookup(mockPathModule.basename(childPath), childObservations), + ).toEqual(tree.lookup(childPath, directObservations)); + expect(childObservations).toEqual(directObservations); + childPath = parentPath; + } + }, + ); + }); + describe('symlinks to an ancestor of the project root', () => { beforeEach(() => { tfs.addOrModify(p('foo/link-up-2'), [0, 0, 0, null, '..', null]); diff --git a/packages/metro-resolver/API.md b/packages/metro-resolver/API.md index 7f2a36fd85..6490b3a9e3 100644 --- a/packages/metro-resolver/API.md +++ b/packages/metro-resolver/API.md @@ -46,8 +46,15 @@ export type FileCandidates = export type FileResolution = AssetResolution | SourceFileResolution; +export type FileSystemDir = { + realPath: string; + lookup(basename: string): {exists: false} | {exists: true; type: 'f' | 'd'; realPath: string}; +}; + export type FileSystemLookup = (absoluteOrProjectRelativePath: string) => {exists: false} | {exists: true; type: 'f' | 'd'; realPath: string}; +export type FileSystemOpenDir = (absoluteOrProjectRelativePath: string) => null | undefined | FileSystemDir; + export function formatFileCandidates(candidates: FileCandidates): string; export class InvalidPackageError extends Error { @@ -73,6 +80,7 @@ export type ResolutionContext = Readonly<{ dependency?: TransformResultDependency | undefined; isESMImport?: boolean | undefined; fileSystemLookup: FileSystemLookup; + fileSystemOpenDir: FileSystemOpenDir; mainFields: ReadonlyArray; originModulePath: string; nodeModulesPaths: ReadonlyArray; diff --git a/packages/metro-resolver/src/PackageResolve.js b/packages/metro-resolver/src/PackageResolve.js index f327610a55..31d3b8f110 100644 --- a/packages/metro-resolver/src/PackageResolve.js +++ b/packages/metro-resolver/src/PackageResolve.js @@ -142,6 +142,71 @@ export function redirectModulePath( return modulePath; } +/** + * Reuse the containing package for non-directory candidates in one directory. + * Initialize lazily so bare hits avoid a package lookup. + */ +export function createDirectoryRedirector( + context: ResolutionContext, + directory: string, +): (absolutePath: string) => string | false { + let redirect: ?(absolutePath: string) => string | false = null; + return (absolutePath: string): string | false => { + if (redirect == null) { + redirect = createCandidateRedirect(context, directory); + } + return redirect(absolutePath); + }; +} + +function createCandidateRedirect( + context: ResolutionContext, + directory: string, +): (absolutePath: string) => string | false { + // Probe a file in this directory, avoiding nested candidate packages. + const probe = directory.endsWith(path.sep) + ? directory + 'package.json' + : directory + path.sep + 'package.json'; + const owning = context.getPackageForModule(probe); + if ( + owning == null || + !hasReplacementMap(owning.packageJson, context.mainFields) + ) { + return (absolutePath: string) => absolutePath; + } + const rootPrefix = owning.rootPath.endsWith(path.sep) + ? owning.rootPath + : owning.rootPath + path.sep; + return (absolutePath: string): string | false => { + if (!absolutePath.startsWith(rootPrefix)) { + return redirectModulePath(context, absolutePath); + } + return redirectPackageSubpath( + context, + absolutePath, + owning, + absolutePath.slice(rootPrefix.length), + ); + }; +} + +function hasReplacementMap( + packageJson: PackageJson, + mainFields: ResolutionContext['mainFields'], +): boolean { + if (packageJson == null) { + return false; + } + for (let idx = 0; idx < mainFields.length; idx++) { + // $FlowFixMe[invalid-computed-prop] mainFields are package.json keys + const value = packageJson[mainFields[idx]]; + if (value != null && typeof value !== 'string') { + return true; + } + } + return false; +} + /** * `redirectModulePath` for a module path whose containing package is already * known. `packageRelativePath` is the path of the module relative to the diff --git a/packages/metro-resolver/src/__tests__/createDefaultContext-test.js b/packages/metro-resolver/src/__tests__/createDefaultContext-test.js new file mode 100644 index 0000000000..6122fd8d33 --- /dev/null +++ b/packages/metro-resolver/src/__tests__/createDefaultContext-test.js @@ -0,0 +1,42 @@ +/** + * 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 createDefaultContext from '../createDefaultContext'; +import {createResolutionContext, posixToSystemPath as p} from './utils'; + +const dependency = { + name: './file', + data: {key: 'file', asyncType: null, isESMImport: false, locs: []}, +}; + +const context = { + ...createResolutionContext({[p('/project/file.js')]: ''}), + originModulePath: p('/project/index.js'), +}; + +test('preserves a supplied directory opener', () => { + expect(createDefaultContext(context, dependency).fileSystemOpenDir).toBe( + context.fileSystemOpenDir, + ); +}); + +test('supplies directory lookups through fileSystemLookup', () => { + const {fileSystemOpenDir: _fileSystemOpenDir, ...withoutOpenDir} = context; + const normalized = createDefaultContext(withoutOpenDir, dependency); + const opened = normalized.fileSystemOpenDir(p('/project')); + expect(opened?.realPath).toBe(p('/project')); + expect(opened?.lookup('file.js')).toEqual( + context.fileSystemLookup(p('/project/file.js')), + ); + expect(opened?.lookup('missing.js')).toEqual({exists: false}); + expect(normalized.fileSystemOpenDir(p('/missing'))).toBeNull(); + expect(normalized.fileSystemOpenDir(p('/project/file.js'))).toBeNull(); +}); diff --git a/packages/metro-resolver/src/__tests__/platform-extensions-root-test.js b/packages/metro-resolver/src/__tests__/platform-extensions-root-test.js new file mode 100644 index 0000000000..cb4b849125 --- /dev/null +++ b/packages/metro-resolver/src/__tests__/platform-extensions-root-test.js @@ -0,0 +1,62 @@ +/** + * 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 + */ + +let mockPathModule; +jest.mock('node:path', () => mockPathModule); + +describe.each(['posix', 'win32'])('filesystem root on %s', platform => { + beforeEach(() => { + jest.resetModules(); + mockPathModule = jest.requireActual<{}>('node:path')[platform]; + }); + + test.each([true, false])( + 'resolves redirects in the root directory (native opener: %s)', + useOpenDir => { + const {resolve} = require('../index'); + const createDefaultContext = require('../createDefaultContext').default; + const {createResolutionContext} = require('./utils'); + const root = platform === 'win32' ? 'C:\\' : '/'; + const packageJson = { + browser: {'./foo.ios.js': './replacement.ios.js'}, + }; + const base = createResolutionContext({ + [root + 'origin.js']: '', + [root + 'package.json']: JSON.stringify(packageJson), + [root + 'replacement.ios.js']: '', + }); + const context = createDefaultContext( + { + ...base, + originModulePath: root + 'origin.js', + fileSystemOpenDir: useOpenDir ? base.fileSystemOpenDir : undefined, + // The shared mock package accessor stops before the filesystem root. + getPackageForModule: filePath => ({ + rootPath: root, + packageJson, + packageRelativePath: mockPathModule.relative(root, filePath), + }), + }, + { + name: './foo', + data: {key: 'foo', asyncType: null, isESMImport: false, locs: []}, + }, + ); + const fileSystemLookup = jest.fn(context.fileSystemLookup); + expect(resolve({...context, fileSystemLookup}, './foo', 'ios')).toEqual({ + type: 'sourceFile', + filePath: root + 'replacement.ios.js', + }); + // The redirect stays in the opened root, rather than using a full lookup. + expect(fileSystemLookup).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/metro-resolver/src/__tests__/platform-extensions-test.js b/packages/metro-resolver/src/__tests__/platform-extensions-test.js index 2b58eea543..b0b185c17f 100644 --- a/packages/metro-resolver/src/__tests__/platform-extensions-test.js +++ b/packages/metro-resolver/src/__tests__/platform-extensions-test.js @@ -10,12 +10,14 @@ */ import type {ResolutionContext} from '../index'; +import type {MockFileMap} from './utils'; +import createDefaultContext from '../createDefaultContext'; import FailedToResolvePathError from '../errors/FailedToResolvePathError'; import * as Resolver from '../index'; import {createResolutionContext, posixToSystemPath as p} from './utils'; -const fileMap = { +const fileMap: MockFileMap = { [p('/root/project/foo.js')]: '', [p('/root/project/bar.js')]: '', [p('/root/project/bar.ios.js')]: '', @@ -24,6 +26,11 @@ const fileMap = { [p('/root/project/baz.native.js')]: '', }; +const dependency = { + name: './bar', + data: {key: 'bar', asyncType: null, isESMImport: false, locs: []}, +}; + describe('preferNativePlatform: true', () => { const context: ResolutionContext = { ...createResolutionContext(fileMap), @@ -58,6 +65,201 @@ describe('preferNativePlatform: true', () => { filePath: p('/root/project/baz.native.js'), }); }); + + test('extension candidates in one directory do not repeat fileSystemLookup', () => { + const base = createResolutionContext(fileMap); + const fileSystemLookup = jest.fn(base.fileSystemLookup); + const fileSystemOpenDir = jest.fn(base.fileSystemOpenDir); + const contextWithCounts: ResolutionContext = { + ...base, + originModulePath: p('/root/project/foo.js'), + preferNativePlatform: true, + fileSystemLookup, + fileSystemOpenDir, + }; + + expect(Resolver.resolve(contextWithCounts, './bar', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/project/bar.ios.js'), + }); + expect(fileSystemOpenDir).toHaveBeenCalledTimes(1); + expect(fileSystemOpenDir).toHaveBeenCalledWith(p('/root/project')); + expect(fileSystemLookup).not.toHaveBeenCalled(); + }); + + test('falls back to fileSystemLookup when fileSystemOpenDir is omitted', () => { + expect( + Resolver.resolve( + createDefaultContext( + {...context, fileSystemOpenDir: undefined}, + dependency, + ), + './bar', + 'ios', + ), + ).toEqual({ + type: 'sourceFile', + filePath: p('/root/project/bar.ios.js'), + }); + }); + + test.each([ + ['./baz.ios.js', '/root/project/baz.ios.js', []], + ['../smth/beep.js', '/root/smth/beep.js', [[p('/root/smth/beep.js')]]], + ])( + 'looks up redirect %s in the appropriate directory', + (replacement, expectedPath, expectedLookups) => { + const testFileMap = { + ...fileMap, + [p('/root/project/package.json')]: JSON.stringify({ + name: 'project', + browser: { + './bar.ios.js': replacement, + }, + }), + [p('/root/smth/beep.js')]: '', + }; + const base = createResolutionContext(testFileMap); + const fileSystemLookup = jest.fn(base.fileSystemLookup); + const contextWithRedirect: ResolutionContext = { + ...base, + originModulePath: p('/root/project/foo.js'), + preferNativePlatform: true, + fileSystemLookup, + }; + + expect(Resolver.resolve(contextWithRedirect, './bar', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p(expectedPath), + }); + expect(fileSystemLookup.mock.calls).toEqual(expectedLookups); + }, + ); + + test.each([ + [null, '/root/project/only.tsx'], + [{main: 'index.js', browser: 'browser.js'}, '/root/project/only.tsx'], + [ + {browser: {'./only.tsx': './replacement.tsx'}}, + '/root/project/replacement.tsx', + ], + ])( + 'reuses the containing package across extensions (%j)', + (packageJson, expectedPath) => { + const testFileMap = { + ...fileMap, + ...(packageJson == null + ? {} + : { + [p('/root/project/package.json')]: JSON.stringify(packageJson), + }), + [p('/root/project/only.tsx')]: '', + [p('/root/project/replacement.tsx')]: '', + }; + const base = createResolutionContext(testFileMap); + const getPackageForModule = jest.fn(base.getPackageForModule); + const contextWithCount: ResolutionContext = { + ...base, + originModulePath: p('/root/project/foo.js'), + preferNativePlatform: true, + getPackageForModule, + }; + + expect(Resolver.resolve(contextWithCount, './only', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p(expectedPath), + }); + // Once for the bare specifier, once for all extension candidates. + expect(getPackageForModule).toHaveBeenCalledTimes(2); + }, + ); + + test('an exact filename does not initialize the extension redirector', () => { + const base = createResolutionContext({ + ...fileMap, + [p('/root/project/package.json')]: JSON.stringify({ + browser: {'./other.js': false}, + }), + }); + const getPackageForModule = jest.fn(base.getPackageForModule); + expect( + Resolver.resolve( + { + ...base, + originModulePath: context.originModulePath, + getPackageForModule, + }, + './bar.js', + 'ios', + ), + ).toEqual({type: 'sourceFile', filePath: p('/root/project/bar.js')}); + expect(getPackageForModule).toHaveBeenCalledTimes(1); + expect(getPackageForModule).toHaveBeenCalledWith(p('/root/project/bar.js')); + }); + + test('an extension mapped to false resolves to an empty module', () => { + const testFileMap = { + ...fileMap, + [p('/root/project/package.json')]: JSON.stringify({ + name: 'project', + browser: { + './bar.ios.js': false, + }, + }), + }; + const contextWithIgnore: ResolutionContext = { + ...createResolutionContext(testFileMap), + originModulePath: p('/root/project/foo.js'), + preferNativePlatform: false, + }; + + expect(Resolver.resolve(contextWithIgnore, './bar', 'ios')).toEqual({ + type: 'empty', + }); + }); + + describe.each([true, false])( + 'directory candidates (openDir: %s)', + useOpenDir => { + test.each([null, {browser: {'./other.js': false}}])( + 'uses the candidate package instead of the containing package %j', + parentPackage => { + const base = createResolutionContext({ + [p('/root/project/foo.js')]: '', + ...(parentPackage == null + ? {} + : { + [p('/root/project/package.json')]: + JSON.stringify(parentPackage), + }), + [p('/root/project/bar.js/package.json')]: JSON.stringify({ + browser: {'./': './replacement.js'}, + }), + [p('/root/project/bar.js/replacement.js')]: '', + }); + expect( + Resolver.resolve( + createDefaultContext( + { + ...base, + originModulePath: p('/root/project/foo.js'), + fileSystemOpenDir: useOpenDir + ? base.fileSystemOpenDir + : undefined, + }, + dependency, + ), + './bar', + null, + ), + ).toEqual({ + type: 'sourceFile', + filePath: p('/root/project/bar.js/replacement.js'), + }); + }, + ); + }, + ); }); describe('preferNativePlatform: false', () => { diff --git a/packages/metro-resolver/src/__tests__/symlinks-test.js b/packages/metro-resolver/src/__tests__/symlinks-test.js index b715323314..573c104d71 100644 --- a/packages/metro-resolver/src/__tests__/symlinks-test.js +++ b/packages/metro-resolver/src/__tests__/symlinks-test.js @@ -9,12 +9,15 @@ * @oncall react_native */ -import type {ResolutionContext} from '../index'; +import type {FileSystemLookup, ResolutionContext} from '../index'; import type {MockFileMap} from './utils'; +import createDefaultContext from '../createDefaultContext'; import FailedToResolvePathError from '../errors/FailedToResolvePathError'; import * as Resolver from '../index'; import {createResolutionContext, posixToSystemPath as p} from './utils'; +import TreeFS from 'metro-file-map/private/lib/TreeFS'; +import {PackageCache} from 'metro/private/node-haste/PackageCache'; const fileMap: MockFileMap = { [p('/root/project/foo.js')]: '', @@ -45,3 +48,82 @@ test('does not resolve to a broken symlink', () => { filePath: p('/root/project/baz/index.js'), }); }); + +test.each([true, false])( + 'uses the target package through a directory symlink (native opener: %s)', + useOpenDir => { + const packageJson = {browser: {'./foo.ios.js': './replacement.js'}}; + const base = createResolutionContext({ + [p('/project/target/package.json')]: JSON.stringify(packageJson), + }); + const tree = new TreeFS({ + rootDir: p('/project'), + files: new Map([ + [p('linked'), [0, 0, 0, null, 'target', null]], + [p('target/package.json'), [0, 0, 0, null, 0, null]], + [p('target/foo.ios.js'), [0, 0, 0, null, 0, null]], + [p('target/replacement.js'), [0, 0, 0, null, 0, null]], + ]), + processFile: () => { + throw new Error('Unexpected file processing'); + }, + }); + const packageCache = new PackageCache({ + getClosestPackage: filePath => { + const found = tree.hierarchicalLookup(filePath, 'package.json', { + breakOnSegment: 'node_modules', + subpathType: 'f', + }); + return found == null + ? null + : { + packageJsonPath: found.absolutePath, + packageRelativePath: found.containerRelativePath, + }; + }, + readPackageJson: () => packageJson, + }); + const fileSystemLookup: FileSystemLookup = filePath => { + const result = tree.lookup(filePath); + return result.exists + ? {exists: true, type: result.type, realPath: result.realPath} + : {exists: false}; + }; + const context: ResolutionContext = { + ...base, + originModulePath: p('/project/origin.js'), + fileSystemLookup, + fileSystemOpenDir: directory => { + const opened = tree.openDir(directory); + return opened == null + ? null + : { + realPath: opened.realPath, + lookup: basename => { + const result = opened.lookup(basename); + return result.exists + ? {exists: true, type: result.type, realPath: result.realPath} + : {exists: false}; + }, + }; + }, + getPackageForModule: filePath => + packageCache.getPackageForModule(filePath), + }; + // Exercise both the TreeFS handle and the compatibility fallback. + const normalized = createDefaultContext( + { + ...context, + fileSystemOpenDir: useOpenDir ? context.fileSystemOpenDir : undefined, + }, + { + name: './linked/foo', + data: {key: 'foo', asyncType: null, isESMImport: false, locs: []}, + }, + ); + expect(Resolver.resolve(normalized, './linked/foo', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/project/target/replacement.js'), + }); + }, +); diff --git a/packages/metro-resolver/src/__tests__/utils.js b/packages/metro-resolver/src/__tests__/utils.js index 8a529c6b48..0f95a375d5 100644 --- a/packages/metro-resolver/src/__tests__/utils.js +++ b/packages/metro-resolver/src/__tests__/utils.js @@ -10,7 +10,7 @@ */ import type {ResolutionContext} from '../index'; -import type {PackageJson} from '../types'; +import type {FileSystemLookup, PackageJson} from '../types'; import path from 'node:path'; @@ -41,6 +41,29 @@ export function createResolutionContext( } while (currentDir !== prevDir); } + const fileSystemLookup: FileSystemLookup = inputPath => { + // Normalise and remove any trailing slash. + const filePath = path.resolve(inputPath); + const candidate = fileMap[filePath]; + if (typeof candidate === 'string') { + return {exists: true, type: 'f', realPath: filePath}; + } + if (candidate == null) { + if (directorySet.has(filePath)) { + return {exists: true, type: 'd', realPath: filePath}; + } + return {exists: false}; + } + if (candidate.realPath == null) { + return {exists: false}; + } + return { + exists: true, + type: 'f', + realPath: candidate.realPath, + }; + }; + return { dev: true, allowHaste: true, @@ -54,26 +77,18 @@ export function createResolutionContext( (typeof fileMap[filePath] === 'string' || typeof fileMap[filePath].realPath === 'string'), extraNodeModules: null, - fileSystemLookup: inputPath => { - // Normalise and remove any trailing slash. - const filePath = path.resolve(inputPath); - const candidate = fileMap[filePath]; - if (typeof candidate === 'string') { - return {exists: true, type: 'f', realPath: filePath}; - } - if (candidate == null) { - if (directorySet.has(filePath)) { - return {exists: true, type: 'd', realPath: filePath}; - } - return {exists: false}; - } - if (candidate.realPath == null) { - return {exists: false}; + fileSystemLookup, + fileSystemOpenDir: inputPath => { + const opened = fileSystemLookup(inputPath); + if (!opened.exists || opened.type !== 'd') { + return null; } + const {realPath} = opened; return { - exists: true, - type: 'f', - realPath: candidate.realPath, + realPath, + lookup(basename) { + return fileSystemLookup(path.join(realPath, basename)); + }, }; }, isESMImport: false, diff --git a/packages/metro-resolver/src/createDefaultContext.js b/packages/metro-resolver/src/createDefaultContext.js index c16e3c26bf..986a3d4ea4 100644 --- a/packages/metro-resolver/src/createDefaultContext.js +++ b/packages/metro-resolver/src/createDefaultContext.js @@ -13,10 +13,12 @@ import type {ResolutionContext} from './types'; import type {TransformResultDependency} from 'metro/private/DeltaBundler/types'; import {redirectModulePath} from './PackageResolve'; +import path from 'node:path'; type PartialContext = Readonly<{ ...ResolutionContext, redirectModulePath?: ResolutionContext['redirectModulePath'], + fileSystemOpenDir?: ResolutionContext['fileSystemOpenDir'], }>; /** @@ -33,5 +35,21 @@ export default function createDefaultContext( redirectModulePath(context, modulePath), dependency, ...context, + // NOTE: `fileSystemOpenDir` is currently not required for backwards-compatibility, but + // can be cleanly implemented with a fallback performing individual fileSystemLookup calls again + fileSystemOpenDir: + context.fileSystemOpenDir ?? + (directory => { + const result = context.fileSystemLookup(directory); + if (!result.exists || result.type !== 'd') { + return null; + } + const {realPath} = result; + return { + realPath, + lookup: basename => + context.fileSystemLookup(path.join(realPath, basename)), + }; + }), }; } diff --git a/packages/metro-resolver/src/index.js b/packages/metro-resolver/src/index.js index f17cbbf4fa..60b3db57c2 100644 --- a/packages/metro-resolver/src/index.js +++ b/packages/metro-resolver/src/index.js @@ -18,7 +18,9 @@ export type { FileAndDirCandidates, FileCandidates, FileResolution, + FileSystemDir, FileSystemLookup, + FileSystemOpenDir, ResolutionContext, Resolution, ResolveAsset, diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index 689958f743..0debd65347 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -12,6 +12,8 @@ import type { FileAndDirCandidates, FileCandidates, + FileSystemDir, + FileSystemLookup, Resolution, ResolutionContext, Result, @@ -28,6 +30,7 @@ import PackagePathNotExportedError from './errors/PackagePathNotExportedError'; import {resolvePackageTargetFromExports} from './PackageExportsResolve'; import {resolvePackageTargetFromImports} from './PackageImportsResolve'; import { + createDirectoryRedirector, getPackageEntryPoint, matchSubpathFromMainFields, redirectModulePath, @@ -743,7 +746,17 @@ function resolveFile( const candidateExts: Array = []; const filePathPrefix = path.join(dirPath, fileName); - const sfContext = {...context, candidateExts, filePathPrefix}; + // Share one directory walk across platform and extension candidates + const directory = path.dirname(filePathPrefix); + const openedDir = context.fileSystemOpenDir(directory); + const sfContext: SourceFileContext = { + ...context, + candidateExts, + filePathPrefix, + openedDir, + openedFrom: directory, + redirectCandidate: createDirectoryRedirector(context, directory), + }; const sourceFileResolution = resolveSourceFile(sfContext, platform); if (sourceFileResolution != null) { if (typeof sourceFileResolution === 'string') { @@ -758,6 +771,9 @@ type SourceFileContext = Readonly<{ ...ResolutionContext, candidateExts: Array, filePathPrefix: string, + openedDir: ?FileSystemDir, + openedFrom: string, + redirectCandidate: (absolutePath: string) => string | false, }>; // Either a full path, or a restricted subset of Resolution. @@ -829,13 +845,21 @@ function resolveSourceFileForExt( extension: string, ): SourceFileResolution { const filePath = `${context.filePathPrefix}${extension}`; - const redirectedPath = - // Any redirections for the bare path have already happened - extension !== '' ? redirectModulePath(context, filePath) : filePath; - if (redirectedPath === false) { - return {type: 'empty'}; + let lookupResult = lookupCandidate(context, filePath); + // NOTE: Plain paths without an extension already had redirections handled, + // so only candidates with an extension need to check for redirections here + if (extension !== '') { + const redirectedPath = + lookupResult.exists && lookupResult.type === 'd' + ? redirectModulePath(context, filePath) + : context.redirectCandidate(filePath); + if (redirectedPath === false) { + return {type: 'empty'}; + } + if (redirectedPath !== filePath) { + lookupResult = lookupCandidate(context, redirectedPath); + } } - const lookupResult = context.fileSystemLookup(redirectedPath); if (lookupResult.exists && lookupResult.type === 'f') { return lookupResult.realPath; } @@ -843,6 +867,33 @@ function resolveSourceFileForExt( return null; } +/** + * Use the opened directory for direct children, falling back for other paths. + */ +function lookupCandidate( + context: SourceFileContext, + absolutePath: string, +): ReturnType { + // Browser-field redirects can leave this directory. Only direct children + // can use its opened handle; other paths need a full filesystem lookup. + const directory = context.openedFrom; + // A filesystem root already ends in a separator. + const prefix = directory.endsWith(path.sep) + ? directory + : directory + path.sep; + if (!absolutePath.startsWith(prefix)) { + return context.fileSystemLookup(absolutePath); + } + const basename = absolutePath.slice(prefix.length); + if (basename.length === 0 || basename.includes(path.sep)) { + return context.fileSystemLookup(absolutePath); + } else if (context.openedDir == null) { + return {exists: false}; + } else { + return context.openedDir.lookup(basename); + } +} + 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..9958c59c62 100644 --- a/packages/metro-resolver/src/types.js +++ b/packages/metro-resolver/src/types.js @@ -128,6 +128,25 @@ export type FileSystemLookup = ( absoluteOrProjectRelativePath: string, ) => {exists: false} | {exists: true, type: 'f' | 'd', realPath: string}; +/** + * A directory opened by `fileSystemOpenDir`. `lookup` checks one direct child + * and matches `fileSystemLookup` of `realPath` joined with that basename. + */ +export type FileSystemDir = { + realPath: string, + lookup( + basename: string, + ): {exists: false} | {exists: true, type: 'f' | 'd', realPath: string}, +}; + +/** + * Open a directory handle allowing many files in it to be checked without repeating + * filesystem traversal. Returns null when the path is not an existing directory. + */ +export type FileSystemOpenDir = ( + absoluteOrProjectRelativePath: string, +) => ?FileSystemDir; + /** * Given a directory path and the base asset name, return a list of all the * asset file names that match the given base name in that directory. Return @@ -196,6 +215,13 @@ export type ResolutionContext = Readonly<{ */ fileSystemLookup: FileSystemLookup, + /** + * Synchronously returns a handle to a directory, speeding up parallel file + * lookups by basename. `createDefaultContext` supplies a fallback using + * `fileSystemLookup` when omitted. + */ + fileSystemOpenDir: FileSystemOpenDir, + /** * The ordered list of fields to read in `package.json` to resolve a main * entry point based on the "browser" field spec. diff --git a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js index d8db716879..bebc2ee0cc 100644 --- a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js +++ b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js @@ -20,6 +20,7 @@ import type { DoesFileExist, FileCandidates, FileSystemLookup, + FileSystemOpenDir, Resolution, ResolveAsset, } from 'metro-resolver'; @@ -40,6 +41,7 @@ type Options = Readonly<{ emptyModulePath: string, extraNodeModules: ?any, fileSystemLookup: FileSystemLookup, + fileSystemOpenDir?: FileSystemOpenDir, getHasteModulePath: (name: string, platform: ?string) => ?string, getHastePackagePath: (name: string, platform: ?string) => ?string, mainFields: ReadonlyArray, @@ -110,6 +112,7 @@ export class ModuleResolver { doesFileExist, extraNodeModules, fileSystemLookup, + fileSystemOpenDir, getPackage, getPackageForModule, mainFields, @@ -137,6 +140,7 @@ export class ModuleResolver { doesFileExist, extraNodeModules, fileSystemLookup, + fileSystemOpenDir, getPackage, getPackageForModule, isESMImport: dependency.data.isESMImport, diff --git a/packages/metro/src/node-haste/DependencyGraph/createModuleResolver.js b/packages/metro/src/node-haste/DependencyGraph/createModuleResolver.js index 4483c3afa9..0de7d0755d 100644 --- a/packages/metro/src/node-haste/DependencyGraph/createModuleResolver.js +++ b/packages/metro/src/node-haste/DependencyGraph/createModuleResolver.js @@ -12,7 +12,7 @@ import type {PackageCache} from '../PackageCache'; import type {ConfigT} from 'metro-config'; import type {FileSystem, HasteMap} from 'metro-file-map'; -import type {FileSystemLookup} from 'metro-resolver'; +import type {FileSystemLookup, FileSystemOpenDir} from 'metro-resolver'; import getSchemeResolvers from '../../lib/getSchemeResolvers'; import {ModuleResolver} from './ModuleResolution'; @@ -43,6 +43,31 @@ export default function createModuleResolver({ return {exists: false}; }; + const openDir = fileSystem.openDir?.bind(fileSystem); + const fileSystemOpenDir: FileSystemOpenDir | void = + openDir != null + ? filePath => { + const opened = openDir(filePath); + if (opened == null) { + return null; + } + return { + realPath: opened.realPath, + lookup(basename) { + const result = opened.lookup(basename); + if (result.exists) { + return { + exists: true, + realPath: result.realPath, + type: result.type, + }; + } + return {exists: false}; + }, + }; + } + : undefined; + return new ModuleResolver({ assetExts: new Set(config.resolver.assetExts), disableHierarchicalLookup: config.resolver.disableHierarchicalLookup, @@ -50,6 +75,7 @@ export default function createModuleResolver({ emptyModulePath: config.resolver.emptyModulePath, extraNodeModules: config.resolver.extraNodeModules, fileSystemLookup, + fileSystemOpenDir, getHasteModulePath: (name, platform) => hasteMap.getModule(name, platform, true), getHastePackagePath: (name, platform) => @@ -70,21 +96,27 @@ export default function createModuleResolver({ projectRoot: config.projectRoot, reporter: config.reporter, resolveAsset: (dirPath: string, assetName: string, extension: string) => { - const basePath = dirPath + path.sep + assetName; - const assets = [ - basePath + extension, + const names = [ + assetName + extension, ...config.resolver.assetResolutions.map( - resolution => basePath + '@' + resolution + 'x' + extension, + resolution => assetName + '@' + resolution + 'x' + extension, ), - ] - .map(assetPath => { - const result = fileSystemLookup(assetPath); + ]; + const opened = fileSystemOpenDir?.(dirPath); + if (fileSystemOpenDir != null && opened == null) { + return null; + } + const assets = names + .map(name => { + const result = + opened != null + ? opened.lookup(name) + : fileSystemLookup(path.join(dirPath, name)); // A directory such as `node_modules/lodash.zip` is not an asset, and // must fall through to directory (package) resolution. return result.exists && result.type === 'f' ? result.realPath : null; }) .filter(Boolean); - return assets.length ? assets : null; }, resolveRequest: config.resolver.resolveRequest,