From 56dedec398f6175755a3dbbba6e41fd0fb49b5a5 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Fri, 18 Sep 2026 16:36:14 +0100 Subject: [PATCH] Resolver: Resolve the package of a directory once for all extension candidates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on https://github.com/react/metro/pull/2044. ## Summary `resolveFile` tries each candidate extension in turn, and for each one `resolveSourceFileForExt` calls `redirectModulePath`, which asks `context.getPackageForModule` for the package scope of the candidate path. Every candidate in a directory belongs to the same package, so that's the same question asked once per extension - and because Metro's `PackageCache` memoises by module path, each distinct candidate string misses the first time it's seen, mostly for files that don't exist. This asks once, up front, for the package of the files in the directory, and shares it with every candidate via the source file context, applying each candidate's `mainFields` redirections with `redirectPackageSubpath` from the PR beneath. The package of the files in a directory isn't always the package of the directory itself - a `node_modules` directory belongs to whatever contains it while the files in it belong to none, and a symlink can change which directory along the way is named `node_modules`. So `getCandidatePackageScope` asks about a stand-in file in the directory, `.metro-package-scope`, which gets the same answer any real candidate there would. If that name happens to exist as a package root, the answer is about that package instead, which shows in its package-relative path, so it tries another name, and throws if `getPackageForModule` keeps answering about something else. `package-scope-test.js` pins the sequence of `getPackageForModule` calls the resolver makes for the common specifier shapes, so that the call count is a tested property rather than incidental. Two `resolver-test.js` cases pin the package scope of files reached through a symlink to or from a `node_modules` directory, where the directory and the files in it disagree. This also removes the `@deprecated` tag from `getPackageForModule` - the resolver depends on it, and its doc now says what it returns for symlinked and missing paths. ## Benchmark This cuts package scope lookups 3.7x, and takes 28% off first-pass resolution time, measured in isolation on our benchmark app (described in the test plan). 7 interleaved rounds. Resolver output is byte-identical. | metric | #1944 | this diff | |---|---|---| | `getPackageForModule` calls per pass | 64k | **17k (3.7x fewer)** | | first pass after startup (ms) | 119.6 | **85.7 (−28.3%)** | | warm pass (ms) | 55.2 | **53.5 (−3.1%)** | | after a source change (ms) | 63.0 | **60.9 (−3.4%)** | | after a `package.json` change (ms) | 68.4 | **64.0 (−6.4%)** | The first pass is where this lands, because that's where every distinct candidate path is a `PackageCache` miss and a walk up the tree. ## Test plan ``` yarn jest packages/metro-resolver packages/metro-file-map packages/metro/src/node-haste packages/metro/src/DeltaBundler/__tests__/resolver-test.js yarn flow check yarn verify-api-snapshots ``` **Benchmark app** - a synthetic mid-size RN 0.87 app: 23 common dependencies (Reanimated, React Navigation, TanStack Query, lodash, etc.) and ~1,000 generated first-party modules, 60% of them in a workspace package outside `projectRoot` with its own `node_modules`. The iOS dev bundle is 2.8k modules, from a file map of 34k files. Timings replay the resolutions a real build of it makes against a fresh `DependencyGraph` per process, in interleaved rounds. These timings isolate resolution - no transformation, serialisation or file reads are included. A real build of this app takes around 4s even with a warm transform cache, so resolution is about 2% of it. Changelog: Internal --- .../src/__tests__/package-scope-test.js | 226 ++++++++++++++++++ packages/metro-resolver/src/resolve.js | 75 +++++- packages/metro-resolver/src/types.js | 11 +- .../DeltaBundler/__tests__/resolver-test.js | 48 ++++ 4 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 packages/metro-resolver/src/__tests__/package-scope-test.js diff --git a/packages/metro-resolver/src/__tests__/package-scope-test.js b/packages/metro-resolver/src/__tests__/package-scope-test.js new file mode 100644 index 0000000000..953afe8dc1 --- /dev/null +++ b/packages/metro-resolver/src/__tests__/package-scope-test.js @@ -0,0 +1,226 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {PackageForModule, ResolutionContext} from '../types'; + +import * as Resolver from '../index'; +import { + createPackageAccessors, + createResolutionContext, + posixToSystemPath as p, +} from './utils'; + +// The resolver looks up package scopes through `getPackageForModule`. These +// tests pin down which paths it asks about for each kind of specifier, so +// that changes to the number of lookups per resolution are deliberate. Metro +// memoizes the answer per path, so a repeated path is cheap and a new one is +// not: in particular, the files in a directory are looked up once, through a +// stand-in file name, for all of their extension candidates. +describe('package scope lookups', () => { + // The path the resolver asks about for the files in a directory + const filesIn = (dirPath: string) => p(dirPath + '/.metro-package-scope'); + + const fileMap = { + [p('/root/package.json')]: JSON.stringify({name: 'root'}), + [p('/root/src/main.js')]: '', + [p('/root/src/foo.js')]: '', + [p('/root/src/lib/index.js')]: '', + [p('/root/node_modules/pkg/package.json')]: JSON.stringify({ + name: 'pkg', + main: 'lib/index', + browser: {'./lib/redirected.js': './lib/other.js'}, + }), + [p('/root/node_modules/pkg/lib/index.js')]: '', + [p('/root/node_modules/pkg/lib/sub.js')]: '', + [p('/root/node_modules/pkg/lib/other.js')]: '', + [p('/root/node_modules/nopkg/index.js')]: '', + }; + + let getPackageForModule: JestMockFn<[string], ?PackageForModule>; + let context: ResolutionContext; + + function useFileMap(files: typeof fileMap) { + const accessors = createPackageAccessors(files); + getPackageForModule = jest.fn(accessors.getPackageForModule); + context = { + ...createResolutionContext(files), + ...accessors, + getPackageForModule, + originModulePath: p('/root/src/main.js'), + }; + } + + beforeEach(() => { + useFileMap(fileMap); + }); + + const lookedUpPaths = () => + getPackageForModule.mock.calls.map(([modulePath]) => modulePath); + + test('relative file, found after trying several extensions', () => { + expect(Resolver.resolve(context, './foo', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/src/foo.js'), + }); + expect(lookedUpPaths()).toEqual([p('/root/src/foo'), filesIn('/root/src')]); + }); + + test('relative file, when the stand-in file name is a package root', () => { + useFileMap({ + ...fileMap, + // A package whose "browser" field must not apply to the files beside it + [p('/root/src/.metro-package-scope/package.json')]: JSON.stringify({ + name: 'stand-in', + browser: {'./foo.js': false}, + }), + }); + expect(Resolver.resolve(context, './foo', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/src/foo.js'), + }); + // The stand-in is retried with another name + expect(lookedUpPaths()).toEqual([ + p('/root/src/foo'), + filesIn('/root/src'), + filesIn('/root/src') + '_', + ]); + }); + + test('throws if the scope is never that of the stand-in file', () => { + getPackageForModule = jest.fn(() => ({ + rootPath: p('/root'), + packageJson: {name: 'root'}, + packageRelativePath: '', + })); + context = {...context, getPackageForModule}; + expect(() => Resolver.resolve(context, './foo', 'ios')).toThrow( + `getPackageForModule(${JSON.stringify(filesIn('/root/src') + '__')}) ` + + 'returned packageRelativePath "", which should end in ' + + '".metro-package-scope__".', + ); + expect(lookedUpPaths()).toEqual([ + p('/root/src/foo'), + filesIn('/root/src'), + filesIn('/root/src') + '_', + filesIn('/root/src') + '__', + ]); + }); + + test('relative file in the file system root', () => { + useFileMap({[p('/main.js')]: '', [p('/foo.js')]: ''}); + context = {...context, originModulePath: p('/main.js')}; + expect(Resolver.resolve(context, './foo', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/foo.js'), + }); + expect(lookedUpPaths()).toEqual([p('/foo'), filesIn('')]); + }); + + test('relative file with an explicit extension', () => { + expect(Resolver.resolve(context, './foo.js', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/src/foo.js'), + }); + expect(lookedUpPaths()).toEqual([ + p('/root/src/foo.js'), + filesIn('/root/src'), + ]); + }); + + test('relative directory, resolved to its index', () => { + expect(Resolver.resolve(context, './lib', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/src/lib/index.js'), + }); + expect(lookedUpPaths()).toEqual([ + p('/root/src/lib'), + filesIn('/root/src'), + filesIn('/root/src/lib'), + ]); + }); + + test('package entry point', () => { + expect(Resolver.resolve(context, 'pkg', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/pkg/lib/index.js'), + }); + // The origin, the package directory, the files in node_modules (`pkg` as + // a file), and the files in the directory of the entry point. + expect(lookedUpPaths()).toEqual([ + p('/root/src/main.js'), + p('/root/node_modules/pkg'), + p('/root/node_modules/pkg'), + filesIn('/root/node_modules'), + filesIn('/root/node_modules/pkg/lib'), + ]); + }); + + test('package subpath', () => { + expect(Resolver.resolve(context, 'pkg/lib/sub', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/pkg/lib/sub.js'), + }); + expect(lookedUpPaths()).toEqual([ + p('/root/src/main.js'), + p('/root/node_modules/pkg/lib/sub'), + p('/root/node_modules/pkg/lib/sub'), + filesIn('/root/node_modules/pkg/lib'), + ]); + }); + + test('package subpath redirected by the "browser" field', () => { + expect(Resolver.resolve(context, 'pkg/lib/redirected', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/pkg/lib/other.js'), + }); + // The redirected path may be redirected again by its own scope + expect(lookedUpPaths()).toEqual([ + p('/root/src/main.js'), + p('/root/node_modules/pkg/lib/redirected'), + p('/root/node_modules/pkg/lib/other.js'), + filesIn('/root/node_modules/pkg/lib'), + ]); + }); + + test('module under node_modules without a package.json', () => { + expect(Resolver.resolve(context, 'nopkg', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/nopkg/index.js'), + }); + expect(lookedUpPaths()).toEqual([ + p('/root/src/main.js'), + p('/root/node_modules/nopkg'), + p('/root/node_modules/nopkg'), + filesIn('/root/node_modules'), + filesIn('/root/node_modules/nopkg'), + ]); + }); + + test('package subpath with package exports', () => { + useFileMap({ + ...fileMap, + [p('/root/node_modules/pkg/package.json')]: JSON.stringify({ + name: 'pkg', + exports: {'./sub': './lib/sub.js'}, + }), + }); + context = {...context, unstable_enablePackageExports: true}; + expect(Resolver.resolve(context, 'pkg/sub', 'ios')).toEqual({ + type: 'sourceFile', + filePath: p('/root/node_modules/pkg/lib/sub.js'), + }); + expect(lookedUpPaths()).toEqual([ + p('/root/src/main.js'), + p('/root/node_modules/pkg/sub'), + p('/root/node_modules/pkg/sub'), + ]); + }); +}); diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index 689958f743..61de009490 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -12,6 +12,7 @@ import type { FileAndDirCandidates, FileCandidates, + PackageForModule, Resolution, ResolutionContext, Result, @@ -31,6 +32,7 @@ import { getPackageEntryPoint, matchSubpathFromMainFields, redirectModulePath, + redirectPackageSubpath, } from './PackageResolve'; import resolveAsset from './resolveAsset'; import isAssetFile from './utils/isAssetFile'; @@ -743,7 +745,12 @@ function resolveFile( const candidateExts: Array = []; const filePathPrefix = path.join(dirPath, fileName); - const sfContext = {...context, candidateExts, filePathPrefix}; + const sfContext = { + ...context, + candidateExts, + filePathPrefix, + candidatePackageScope: getCandidatePackageScope(context, dirPath, fileName), + }; const sourceFileResolution = resolveSourceFile(sfContext, platform); if (sourceFileResolution != null) { if (typeof sourceFileResolution === 'string') { @@ -758,6 +765,9 @@ type SourceFileContext = Readonly<{ ...ResolutionContext, candidateExts: Array, filePathPrefix: string, + // The package scope of every candidate extension, with `packageRelativePath` + // that of `filePathPrefix`, or null if they belong to no package + candidatePackageScope: ?PackageForModule, }>; // Either a full path, or a restricted subset of Resolution. @@ -829,9 +839,17 @@ function resolveSourceFileForExt( extension: string, ): SourceFileResolution { const filePath = `${context.filePathPrefix}${extension}`; + const {candidatePackageScope} = context; const redirectedPath = // Any redirections for the bare path have already happened - extension !== '' ? redirectModulePath(context, filePath) : filePath; + extension !== '' && candidatePackageScope != null + ? redirectPackageSubpath( + context, + filePath, + candidatePackageScope, + candidatePackageScope.packageRelativePath + extension, + ) + : filePath; if (redirectedPath === false) { return {type: 'empty'}; } @@ -843,6 +861,59 @@ function resolveSourceFileForExt( return null; } +// A file name, which need not exist, that stands in for any file in a directory +// when asking for their package scope. While it names a package root, `_` is +// appended, up to MAX_ANY_FILE_NAME_ATTEMPTS names in all. +const ANY_FILE_NAME = '.metro-package-scope'; +const MAX_ANY_FILE_NAME_ATTEMPTS = 3; + +/** + * The package scope of the candidate files `fileName` + extension in `dirPath`, + * with the `packageRelativePath` of `fileName` in `dirPath`. Every file directly + * in a directory has the same package scope, which may differ from the scope of + * the directory, or of a directory named `fileName`: a package root is its own + * scope, and a `node_modules` directory is in the scope containing it, but the + * files directly in it are in none. + */ +function getCandidatePackageScope( + context: ResolutionContext, + dirPath: string, + fileName: string, +): ?PackageForModule { + const dirPrefix = dirPath.endsWith(path.sep) ? dirPath : dirPath + path.sep; + let name = ANY_FILE_NAME; + for (let attempt = 1; ; attempt++) { + const modulePath = dirPrefix + name; + const pkg = context.getPackageForModule(modulePath); + if (pkg == null) { + return null; + } + const {packageRelativePath} = pkg; + const dirRelativeLength = packageRelativePath.length - name.length; + if ( + packageRelativePath.endsWith(name) && + (dirRelativeLength === 0 || + packageRelativePath[dirRelativeLength - 1] === path.sep) + ) { + return { + ...pkg, + packageRelativePath: + packageRelativePath.slice(0, dirRelativeLength) + fileName, + }; + } + // Otherwise this is the scope of something other than the files here, + // usually a package root named `name`. + if (attempt === MAX_ANY_FILE_NAME_ATTEMPTS) { + throw new Error( + `getPackageForModule(${JSON.stringify(modulePath)}) returned ` + + `packageRelativePath ${JSON.stringify(packageRelativePath)}, ` + + `which should end in ${JSON.stringify(name)}.`, + ); + } + name += '_'; + } +} + function isRelativeImport(filePath: string) { return /^[.][.]?(?:[/]|$)/.test(filePath); } diff --git a/packages/metro-resolver/src/types.js b/packages/metro-resolver/src/types.js index d9c2338ca4..fae297e1cd 100644 --- a/packages/metro-resolver/src/types.js +++ b/packages/metro-resolver/src/types.js @@ -167,7 +167,16 @@ export type ResolutionContext = Readonly<{ * for a given absolute candidate path (which need not exist), or null if * there is no package.json closer than the nearest node_modules directory. * - * @deprecated See https://github.com/react/metro/commit/29c77bff31e2475a086bc3f04073f485da8f9ff0 + * A path through a symlink is scoped by where the link is: the walk up + * checks the link target directory, then continues from the link's parent. + * A path that does not exist belongs to the package of its deepest existing + * ancestor, unless the first missing segment is `node_modules`. + * + * To learn the scope of every file in a directory at once, the resolver asks + * about a file named `.metro-package-scope` in it, which usually does not + * exist. + * + * The resolver calls this a few times per resolution, so it must be cheap. */ getPackageForModule: (absoluteModulePath: string) => ?PackageForModule, diff --git a/packages/metro/src/DeltaBundler/__tests__/resolver-test.js b/packages/metro/src/DeltaBundler/__tests__/resolver-test.js index 9cb47a37fe..6d30cb29ec 100644 --- a/packages/metro/src/DeltaBundler/__tests__/resolver-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/resolver-test.js @@ -1371,6 +1371,54 @@ function dep(name: string): TransformResultDependency { filePath: p('/root/node_modules/aPackage/main-custom.js'), }); }); + + // The "browser" field of the package containing a file applies to it. + // These use platform-specific files, which are redirected per candidate + // extension rather than with the extensionless path. + describe('package scope of files reached through a symlink', () => { + test('a node_modules symlink to a package root', async () => { + setMockFileSystem({ + 'index.js': '', + store: { + deps: { + 'package.json': JSON.stringify({ + name: 'deps', + browser: {'./foo.ios.js': './foo-browser.js'}, + }), + 'foo.ios.js': '', + 'foo-browser.js': '', + }, + }, + }); + fs.symlinkSync(p('/root/store/deps'), p('/root/node_modules')); + + resolver = await createResolver({}, 'ios'); + expect(resolver.resolve(p('/root/index.js'), dep('foo'))).toEqual({ + type: 'sourceFile', + filePath: p('/root/store/deps/foo-browser.js'), + }); + }); + + test('a symlink to a node_modules directory', async () => { + setMockFileSystem({ + 'package.json': JSON.stringify({ + name: 'app', + browser: {'./vendor/bar.ios.js': false}, + }), + 'index.js': '', + other: {node_modules: {'bar.ios.js': ''}}, + }); + fs.symlinkSync(p('/root/other/node_modules'), p('/root/vendor')); + + resolver = await createResolver({}, 'ios'); + expect( + resolver.resolve(p('/root/index.js'), dep('./vendor/bar')), + ).toEqual({ + type: 'sourceFile', + filePath: p('/root/other/node_modules/bar.ios.js'), + }); + }); + }); }); describe('platforms', () => {