Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: package deduplication #353

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
18 changes: 5 additions & 13 deletions packages/metro-resolver/src/FailedToResolveNameError.js
Expand Up @@ -13,25 +13,17 @@
const path = require('path');

class FailedToResolveNameError extends Error {
dirPaths: $ReadOnlyArray<string>;
extraPaths: $ReadOnlyArray<string>;
modulePaths: $ReadOnlyArray<string>;

constructor(
dirPaths: $ReadOnlyArray<string>,
extraPaths: $ReadOnlyArray<string>,
) {
const displayDirPaths = dirPaths.concat(extraPaths);
const hint = displayDirPaths.length ? ' or in these directories:' : '';
constructor(modulePaths: $ReadOnlyArray<string>) {
const hint = modulePaths.length ? ' or at these locations:' : '';
super(
`Module does not exist in the Haste module map${hint}\n` +
displayDirPaths
.map(dirPath => ` ${path.dirname(dirPath)}\n`)
.join(', ') +
modulePaths.map(modulePath => ` ${modulePath}\n`).join(', ') +
'\n',
);

this.dirPaths = dirPaths;
this.extraPaths = extraPaths;
this.modulePaths = modulePaths;
}
}

Expand Down
175 changes: 73 additions & 102 deletions packages/metro-resolver/src/resolve.js
Expand Up @@ -14,7 +14,6 @@ const FailedToResolveNameError = require('./FailedToResolveNameError');
const FailedToResolvePathError = require('./FailedToResolvePathError');
const InvalidPackageError = require('./InvalidPackageError');

const formatFileCandidates = require('./formatFileCandidates');
const isAbsolutePath = require('absolute-path');
const path = require('path');

Expand All @@ -26,14 +25,19 @@ import type {
FileContext,
FileOrDirContext,
FileResolution,
HasteContext,
ModulePathContext,
ResolutionContext,
Resolution,
ResolveAsset,
Result,
} from './types';

type ModuleParts = {
+package: string,
+scope: string,
+file: string,
};

function resolve(
context: ResolutionContext,
moduleName: string,
Expand All @@ -55,80 +59,102 @@ function resolve(
}

const {originModulePath} = context;

const normalizedName = normalizePath(realModuleName);
const isDirectImport =
isRelativeImport(realModuleName) || isAbsolutePath(realModuleName);
isRelativeImport(normalizedName) || isAbsolutePath(normalizedName);

// We disable the direct file loading to let the custom resolvers deal with it
if (!resolveRequest && isDirectImport) {
// derive absolute path /.../node_modules/originModuleDir/realModuleName
// derive absolute path /.../node_modules/originModuleDir/normalizedName
const fromModuleParentIdx =
originModulePath.lastIndexOf('node_modules' + path.sep) + 13;
const originModuleDir = originModulePath.slice(
0,
originModulePath.indexOf(path.sep, fromModuleParentIdx),
);
const absPath = path.join(originModuleDir, realModuleName);
const absPath = path.join(originModuleDir, normalizedName);
return resolveModulePath(context, absPath, platform);
}

// The Haste resolution must occur before the custom resolver because we want
// to allow overriding imports. It could be part of the custom resolver, but
// that's not the case right now.
if (context.allowHaste && !isDirectImport) {
const normalizedName = normalizePath(realModuleName);
const result = resolveHasteName(context, normalizedName, platform);
if (result.type === 'resolved') {
return result.resolution;
const modulePath = context.resolveHasteModule(normalizedName);
if (modulePath != null) {
return {type: 'sourceFile', filePath: modulePath};
}
}

if (resolveRequest) {
try {
const resolution = resolveRequest(context, realModuleName, platform);
const resolution = resolveRequest(context, normalizedName, platform);
if (resolution) {
return resolution;
}
} catch (error) {}
if (isDirectImport) {
throw new Error('Failed to resolve module: ' + normalizedName);
}
}

const dirPaths = [];
for (
let currDir = path.dirname(originModulePath);
currDir !== '.' && currDir !== path.parse(originModulePath).root;
currDir = path.dirname(currDir)
) {
const searchPath = path.join(currDir, 'node_modules');
dirPaths.push(path.join(searchPath, realModuleName));
}
const parsedName = parseModuleName(normalizedName);
const modulePaths = [];
for (let packagePath of genPackagePaths(context, parsedName)) {
packagePath = context.redirectPackage(packagePath);
const modulePath = parsedName.file
? context.redirectModulePath(path.join(packagePath, parsedName.file))
: packagePath;

const extraPaths = [];
const {extraNodeModules} = context;
if (extraNodeModules) {
let bits = path.normalize(moduleName).split(path.sep);
let packageName;
// Normalize packageName and bits for scoped modules
if (bits.length >= 2 && bits[0].startsWith('@')) {
packageName = bits.slice(0, 2).join('/');
bits = bits.slice(1);
} else {
packageName = bits[0];
}
if (extraNodeModules[packageName]) {
bits[0] = extraNodeModules[packageName];
extraPaths.push(path.join.apply(path, bits));
const result = resolveFileOrDir(context, modulePath, platform);
if (result.type === 'resolved') {
return result.resolution;
}
modulePaths.push(modulePath);
}
throw new FailedToResolveNameError(modulePaths);
}

const allDirPaths = dirPaths.concat(extraPaths);
for (let i = 0; i < allDirPaths.length; ++i) {
const realModuleName = context.redirectModulePath(allDirPaths[i]);
const result = resolveFileOrDir(context, realModuleName, platform);
if (result.type === 'resolved') {
return result.resolution;
function parseModuleName(moduleName: string): ModuleParts {
const parts = moduleName.split(path.sep);
const scope = parts[0].startsWith('@') ? parts[0] : '';
return {
scope,
package: parts.slice(0, scope ? 2 : 1).join(path.sep),
file: parts.slice(scope ? 2 : 1).join(path.sep),
};
}

function* genPackagePaths(
context: ResolutionContext,
parsedName: ModuleParts,
): Iterable<string> {
/**
* Find the nearest "node_modules" directory that contains
* the imported package.
*/
const {root} = path.parse(context.originModulePath);
let parent = context.originModulePath;
do {
parent = path.dirname(parent);
if (path.basename(parent) !== 'node_modules') {
yield path.join(parent, 'node_modules', parsedName.package);
}
} while (parent !== root);

/**
* Check the user-provided `extraNodeModules` module map for a
* direct mapping to a directory that contains the imported package.
*/
if (context.extraNodeModules) {
const extras = context.extraNodeModules;
if ((parent = extras[parsedName.package])) {
yield path.join(parent, parsedName.package);
}
if (parsedName.scope && (parent = extras[parsedName.scope])) {
yield path.join(parent, parsedName.package);
}
}
throw new FailedToResolveNameError(dirPaths, extraPaths);
}

/**
Expand Down Expand Up @@ -157,65 +183,6 @@ function resolveModulePath(
throw new FailedToResolvePathError(result.candidates);
}

/**
* Resolve a module as a Haste module or package. For example we might try to
* resolve `Foo`, that is provided by file `/smth/Foo.js`. Or, in the case of
* a Haste package, it could be `/smth/Foo/index.js`.
*/
function resolveHasteName(
context: HasteContext,
moduleName: string,
platform: string | null,
): Result<FileResolution, void> {
const modulePath = context.resolveHasteModule(moduleName);
if (modulePath != null) {
return resolvedAs({type: 'sourceFile', filePath: modulePath});
}
let packageName = moduleName;
let packageJsonPath = context.resolveHastePackage(packageName);
while (packageJsonPath == null && packageName && packageName !== '.') {
packageName = path.dirname(packageName);
packageJsonPath = context.resolveHastePackage(packageName);
}
if (packageJsonPath == null) {
return failedFor();
}
const packageDirPath = path.dirname(packageJsonPath);
const pathInModule = moduleName.substring(packageName.length + 1);
const potentialModulePath = path.join(packageDirPath, pathInModule);
const result = resolveFileOrDir(context, potentialModulePath, platform);
if (result.type === 'resolved') {
return result;
}
const {candidates} = result;
const opts = {moduleName, packageName, pathInModule, candidates};
throw new MissingFileInHastePackageError(opts);
}

class MissingFileInHastePackageError extends Error {
candidates: FileAndDirCandidates;
moduleName: string;
packageName: string;
pathInModule: string;

constructor(opts: {|
+candidates: FileAndDirCandidates,
+moduleName: string,
+packageName: string,
+pathInModule: string,
|}) {
super(
`While resolving module \`${opts.moduleName}\`, ` +
`the Haste package \`${opts.packageName}\` was found. However the ` +
`module \`${opts.pathInModule}\` could not be found within ` +
'the package. Indeed, none of these files exist:\n\n' +
` * \`${formatFileCandidates(opts.candidates.file)}\`\n` +
` * \`${formatFileCandidates(opts.candidates.dir)}\``,
);
Object.assign(this, opts);
}
}

/**
* In the NodeJS-style module resolution scheme we want to check potential
* paths both as directories and as files. For example, `/foo/bar` may resolve
Expand Down Expand Up @@ -455,12 +422,16 @@ function isRelativeImport(filePath: string) {
}

function normalizePath(modulePath) {
const wasRelative = isRelativeImport(modulePath);
if (path.sep === '/') {
modulePath = path.normalize(modulePath);
} else if (path.posix) {
modulePath = path.posix.normalize(modulePath);
}

// Ensure `normalize` cannot strip leading "./"
if (wasRelative && modulePath[0] !== '.') {
modulePath = './' + modulePath;
}
return modulePath.replace(/\/$/, '');
}

Expand Down
10 changes: 3 additions & 7 deletions packages/metro-resolver/src/types.js
Expand Up @@ -48,6 +48,7 @@ export type FileCandidates =
*/
export type DoesFileExist = (filePath: string) => boolean;
export type IsAssetFile = (fileName: string) => boolean;
export type FollowFn = (filePath: string) => string;

/**
* Given a directory path and the base asset name, return a list of all the
Expand Down Expand Up @@ -89,12 +90,6 @@ export type HasteContext = FileOrDirContext & {
* a Haste module of that name. Ex. for `Foo` it may return `/smth/Foo.js`.
*/
+resolveHasteModule: (name: string) => ?string,
/**
* Given a name, this should return the full path to the package manifest that
* provides a Haste package of that name. Ex. for `Foo` it may return
* `/smth/Foo/package.json`.
*/
+resolveHastePackage: (name: string) => ?string,
};

export type ModulePathContext = FileOrDirContext & {
Expand All @@ -109,8 +104,9 @@ export type ResolutionContext = ModulePathContext &
HasteContext & {
allowHaste: boolean,
extraNodeModules: ?{[string]: string},
originModulePath: string,
redirectPackage: (packagePath: string) => string,
resolveRequest?: ?CustomResolver,
follow: FollowFn,
};

export type CustomResolver = (
Expand Down
1 change: 1 addition & 0 deletions packages/metro/src/ModuleGraph/node-haste/node-haste.js
Expand Up @@ -143,6 +143,7 @@ exports.createResolveFn = function(options: ResolveOptions): ResolveFn {
dirExists: (filePath: string): boolean => hasteFS.dirExists(filePath),
doesFileExist: (filePath: string): boolean => hasteFS.exists(filePath),
extraNodeModules,
follow: (filePath: string): string => hasteFS.follow(filePath),
isAssetFile: (filePath: string): boolean => helpers.isAssetFile(filePath),
mainFields: options.mainFields,
moduleCache,
Expand Down
8 changes: 5 additions & 3 deletions packages/metro/src/node-haste/DependencyGraph.js
Expand Up @@ -90,6 +90,7 @@ class DependencyGraph extends EventEmitter {
resetCache: config.resetCache,
rootDir: config.projectRoot,
roots: config.watchFolders,
skipPackageJson: true,
throwOnModuleCollision: true,
useWatchman: config.resolver.useWatchman,
watch: true,
Expand Down Expand Up @@ -117,9 +118,9 @@ class DependencyGraph extends EventEmitter {
}

_getClosestPackage(filePath: string): ?string {
const parsedPath = path.parse(filePath);
const root = parsedPath.root;
let dir = parsedPath.dir;
const {root} = path.parse(filePath);
// The `filePath` may be a directory.
let dir = filePath;
do {
const candidate = path.join(dir, 'package.json');
if (this._hasteFS.exists(candidate)) {
Expand All @@ -143,6 +144,7 @@ class DependencyGraph extends EventEmitter {

_createModuleResolver() {
this._moduleResolver = new ModuleResolver({
follow: (filePath: string) => this._hasteFS.follow(filePath),
dirExists: (filePath: string) => {
try {
return fs.lstatSync(filePath).isDirectory();
Expand Down