Skip to content
Draft
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
226 changes: 226 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,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'),
]);
});
});
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),
};
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 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.
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;
}

// 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)}.`,
Comment on lines +906 to +910
);
}
name += '_';
}
}

function isRelativeImport(filePath: string) {
return /^[.][.]?(?:[/]|$)/.test(filePath);
}
Expand Down
11 changes: 10 additions & 1 deletion packages/metro-resolver/src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
Loading
Loading