Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 222 additions & 0 deletions packages/metro-resolver/src/__tests__/package-scope-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/

import type {PackageForModule, ResolutionContext} from '../types';

import * as Resolver from '../index';
import {
createPackageAccessors,
createResolutionContext,
posixToSystemPath as p,
} from './utils';

// These pin the paths the resolver passes to `getPackageForModule` for each
// kind of specifier, so that a change in lookups per resolution is deliberate.
describe('package scope lookups', () => {
// The path the resolver asks about for the files in a directory
const filesIn = (dirPath: string) => p(dirPath + '/package.json');

const fileMap = {
[p('/root/package.json')]: JSON.stringify({name: 'root'}),
[p('/root/src/main.js')]: '',
[p('/root/src/foo.js')]: '',
[p('/root/src/lib/index.js')]: '',
[p('/root/node_modules/pkg/package.json')]: JSON.stringify({
name: 'pkg',
main: 'lib/index',
browser: {'./lib/redirected.js': './lib/other.js'},
}),
[p('/root/node_modules/pkg/lib/index.js')]: '',
[p('/root/node_modules/pkg/lib/sub.js')]: '',
[p('/root/node_modules/pkg/lib/other.js')]: '',
[p('/root/node_modules/nopkg/index.js')]: '',
};

let getPackageForModule: JestMockFn<[string], ?PackageForModule>;
let context: ResolutionContext;

function useFileMap(files: typeof fileMap) {
const accessors = createPackageAccessors(files);
getPackageForModule = jest.fn(accessors.getPackageForModule);
context = {
...createResolutionContext(files),
...accessors,
getPackageForModule,
originModulePath: p('/root/src/main.js'),
};
}

beforeEach(() => {
useFileMap(fileMap);
});

const lookedUpPaths = () =>
getPackageForModule.mock.calls.map(([modulePath]) => modulePath);

test('relative file, found after trying several extensions', () => {
expect(Resolver.resolve(context, './foo', 'ios')).toEqual({
type: 'sourceFile',
filePath: p('/root/src/foo.js'),
});
expect(lookedUpPaths()).toEqual([p('/root/src/foo'), filesIn('/root/src')]);
});

test('relative file, when its package.json is a package root', () => {
useFileMap({
...fileMap,
// A package whose "browser" field must not apply to the files beside it
[p('/root/src/package.json/package.json')]: JSON.stringify({
name: 'stand-in',
browser: {'./foo.js': false},
}),
});
expect(Resolver.resolve(context, './foo', 'ios')).toEqual({
type: 'sourceFile',
filePath: p('/root/src/foo.js'),
});
// The stand-in is retried with another name
expect(lookedUpPaths()).toEqual([
p('/root/src/foo'),
filesIn('/root/src'),
filesIn('/root/src') + '_',
]);
});

test('throws if the scope is never that of the stand-in file', () => {
getPackageForModule = jest.fn(() => ({
rootPath: p('/root'),
packageJson: {name: 'root'},
packageRelativePath: '',
}));
context = {...context, getPackageForModule};
expect(() => Resolver.resolve(context, './foo', 'ios')).toThrow(
`getPackageForModule(${JSON.stringify(filesIn('/root/src') + '__')}) ` +
'returned packageRelativePath "", which should end in ' +
'"package.json__".',
);
expect(lookedUpPaths()).toEqual([
p('/root/src/foo'),
filesIn('/root/src'),
filesIn('/root/src') + '_',
filesIn('/root/src') + '__',
]);
});

test('relative file in the file system root', () => {
useFileMap({[p('/main.js')]: '', [p('/foo.js')]: ''});
context = {...context, originModulePath: p('/main.js')};
expect(Resolver.resolve(context, './foo', 'ios')).toEqual({
type: 'sourceFile',
filePath: p('/foo.js'),
});
expect(lookedUpPaths()).toEqual([p('/foo'), filesIn('')]);
});

test('relative file with an explicit extension', () => {
expect(Resolver.resolve(context, './foo.js', 'ios')).toEqual({
type: 'sourceFile',
filePath: p('/root/src/foo.js'),
});
expect(lookedUpPaths()).toEqual([
p('/root/src/foo.js'),
filesIn('/root/src'),
]);
});

test('relative directory, resolved to its index', () => {
expect(Resolver.resolve(context, './lib', 'ios')).toEqual({
type: 'sourceFile',
filePath: p('/root/src/lib/index.js'),
});
expect(lookedUpPaths()).toEqual([
p('/root/src/lib'),
filesIn('/root/src'),
filesIn('/root/src/lib'),
]);
});

test('package entry point', () => {
expect(Resolver.resolve(context, 'pkg', 'ios')).toEqual({
type: 'sourceFile',
filePath: p('/root/node_modules/pkg/lib/index.js'),
});
// The origin, the package directory (twice), `pkg` as a file in
// node_modules, and the entry point's directory.
expect(lookedUpPaths()).toEqual([
p('/root/src/main.js'),
p('/root/node_modules/pkg'),
p('/root/node_modules/pkg'),
filesIn('/root/node_modules'),
filesIn('/root/node_modules/pkg/lib'),
]);
});

test('package subpath', () => {
expect(Resolver.resolve(context, 'pkg/lib/sub', 'ios')).toEqual({
type: 'sourceFile',
filePath: p('/root/node_modules/pkg/lib/sub.js'),
});
expect(lookedUpPaths()).toEqual([
p('/root/src/main.js'),
p('/root/node_modules/pkg/lib/sub'),
p('/root/node_modules/pkg/lib/sub'),
filesIn('/root/node_modules/pkg/lib'),
]);
});

test('package subpath redirected by the "browser" field', () => {
expect(Resolver.resolve(context, 'pkg/lib/redirected', 'ios')).toEqual({
type: 'sourceFile',
filePath: p('/root/node_modules/pkg/lib/other.js'),
});
// The redirected path may be redirected again by its own scope
expect(lookedUpPaths()).toEqual([
p('/root/src/main.js'),
p('/root/node_modules/pkg/lib/redirected'),
p('/root/node_modules/pkg/lib/other.js'),
filesIn('/root/node_modules/pkg/lib'),
]);
});

test('module under node_modules without a package.json', () => {
expect(Resolver.resolve(context, 'nopkg', 'ios')).toEqual({
type: 'sourceFile',
filePath: p('/root/node_modules/nopkg/index.js'),
});
expect(lookedUpPaths()).toEqual([
p('/root/src/main.js'),
p('/root/node_modules/nopkg'),
p('/root/node_modules/nopkg'),
filesIn('/root/node_modules'),
filesIn('/root/node_modules/nopkg'),
]);
});

test('package subpath with package exports', () => {
useFileMap({
...fileMap,
[p('/root/node_modules/pkg/package.json')]: JSON.stringify({
name: 'pkg',
exports: {'./sub': './lib/sub.js'},
}),
});
context = {...context, unstable_enablePackageExports: true};
expect(Resolver.resolve(context, 'pkg/sub', 'ios')).toEqual({
type: 'sourceFile',
filePath: p('/root/node_modules/pkg/lib/sub.js'),
});
expect(lookedUpPaths()).toEqual([
p('/root/src/main.js'),
p('/root/node_modules/pkg/sub'),
p('/root/node_modules/pkg/sub'),
]);
});
});
75 changes: 73 additions & 2 deletions packages/metro-resolver/src/resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import type {
FileAndDirCandidates,
FileCandidates,
PackageForModule,
Resolution,
ResolutionContext,
Result,
Expand All @@ -31,6 +32,7 @@ import {
getPackageEntryPoint,
matchSubpathFromMainFields,
redirectModulePath,
redirectPackageSubpath,
} from './PackageResolve';
import resolveAsset from './resolveAsset';
import isAssetFile from './utils/isAssetFile';
Expand Down Expand Up @@ -743,7 +745,12 @@ function resolveFile(

const candidateExts: Array<string> = [];
const filePathPrefix = path.join(dirPath, fileName);
const sfContext = {...context, candidateExts, filePathPrefix};
const sfContext = {
...context,
candidateExts,
filePathPrefix,
candidatePackageScope: getCandidatePackageScope(context, dirPath, fileName),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A leaf symlink candidate was never scoped by its target. For a path that ends in a file symlink, hierarchicalLookup walks the ancestors of the link, not those of its target, so the per-candidate lookup on main never applied the target package's browser map either. The output is unchanged. I've added two resolver-test.js cases that pin this: "a file symlink into another package, mapped by its target package" (the target's map isn't applied) and "...mapped by the package of the link" (the link's package's map is). Both pass with main's resolver as well.

};
const sourceFileResolution = resolveSourceFile(sfContext, platform);
if (sourceFileResolution != null) {
if (typeof sourceFileResolution === 'string') {
Expand All @@ -758,6 +765,9 @@ type SourceFileContext = Readonly<{
...ResolutionContext,
candidateExts: Array<string>,
filePathPrefix: string,
// The package scope of all candidates, with `packageRelativePath` that of
// `filePathPrefix`, or null if they have none.
candidatePackageScope: ?PackageForModule,
}>;

// Either a full path, or a restricted subset of Resolution.
Expand Down Expand Up @@ -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'};
}
Expand All @@ -843,6 +861,59 @@ function resolveSourceFileForExt(
return null;
}

// Stands in for every file in a directory when looking up their package scope.
// Edge case: if `package.json` is itself a directory that is a package root,
// we append `_` and try again, up to MAX_ANY_FILE_NAME_ATTEMPTS names.
const ANY_FILE_NAME = 'package.json';
const MAX_ANY_FILE_NAME_ATTEMPTS = 3;

/**
* The package scope shared by the candidates `fileName` + extension in
* `dirPath`, with `packageRelativePath` that of `fileName`.
*
* Note that this can differ from the scope of `dirPath` itself, or of a
* directory named `fileName` - a package root is its own scope, and files
* directly in `node_modules` have none.
*/
function getCandidatePackageScope(
context: ResolutionContext,
dirPath: string,
fileName: string,
): ?PackageForModule {
const dirPrefix = dirPath.endsWith(path.sep) ? dirPath : dirPath + path.sep;
let name = ANY_FILE_NAME;
for (let attempt = 1; ; attempt++) {
const modulePath = dirPrefix + name;
const pkg = context.getPackageForModule(modulePath);
if (pkg == null) {
return null;
}
const {packageRelativePath} = pkg;
const dirRelativeLength = packageRelativePath.length - name.length;
if (
packageRelativePath.endsWith(name) &&
(dirRelativeLength === 0 ||
packageRelativePath[dirRelativeLength - 1] === path.sep)
) {
return {
...pkg,
packageRelativePath:
packageRelativePath.slice(0, dirRelativeLength) + fileName,
};
}
// Otherwise this is the scope of something other than the files here,
// usually a package root named `name`.
if (attempt === MAX_ANY_FILE_NAME_ATTEMPTS) {
throw new Error(
`getPackageForModule(${JSON.stringify(modulePath)}) returned ` +
`packageRelativePath ${JSON.stringify(packageRelativePath)}, ` +
`which should end in ${JSON.stringify(name)}.`,
Comment on lines +906 to +910

@robhogan robhogan Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stand-in is now the directory's package.json. It only collides when package.json is a directory, or a link to one, containing another package.json.

To compensate for that edge case we detect that and try package.json_ and package.json__, before throwing in the case that all three are directories containing package.json, which is obviously vanishingly unlikely.

);
}
name += '_';
}
}

function isRelativeImport(filePath: string) {
return /^[.][.]?(?:[/]|$)/.test(filePath);
}
Expand Down
12 changes: 12 additions & 0 deletions packages/metro-resolver/src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,18 @@ export type ResolutionContext = Readonly<{
* for a given absolute candidate path (which need not exist), or null if
* there is no package.json closer than the nearest node_modules directory.
*
* A path through a symlinked directory checks the link's target, then
* continues up from the link's parent. A symlinked file is scoped by where
* the link is.
*
* A missing path takes the scope of its deepest existing ancestor, or null
* if the first missing segment is `node_modules`.
*
* For the scope of every file in a directory, the resolver asks about its
* `package.json` (which needn't exist).
*
* The resolver calls this a few times per resolution, so it should be cheap.
*
* @deprecated See https://github.com/react/metro/commit/29c77bff31e2475a086bc3f04073f485da8f9ff0
*/
getPackageForModule: (absoluteModulePath: string) => ?PackageForModule,
Expand Down
Loading
Loading