Skip to content

Commit

Permalink
feat: package deduplication
Browse files Browse the repository at this point in the history
Packages with the same name/version pair are deduplicated.

Closes facebook#350
  • Loading branch information
aleclarson committed Feb 1, 2019
1 parent 330ac90 commit 8a57f6d
Show file tree
Hide file tree
Showing 7 changed files with 108 additions and 150 deletions.
151 changes: 44 additions & 107 deletions packages/metro-resolver/src/resolve.js
Original file line number Diff line number Diff line change
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 makePnpResolver = require('./makePnpResolver');
const path = require('path');
Expand All @@ -27,14 +26,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 Down Expand Up @@ -62,108 +66,100 @@ function resolve(
return {type: 'empty'};
}

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 {originModulePath} = context;
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: ' + realModuleName);
throw new Error('Failed to resolve module: ' + normalizedName);
}
}

const parsedName = parseModuleName(normalizedName);
const modulePaths = [];
for (let modulePath of genModulePaths(context, realModuleName)) {
modulePath = context.redirectModulePath(modulePath);

for (let packagePath of genPackagePaths(context, parsedName)) {
packagePath = context.redirectPackage(packagePath);
const modulePath = context.redirectModulePath(
path.join(packagePath, parsedName.file),
);
const result = resolveFileOrDir(context, modulePath, platform);
if (result.type === 'resolved') {
return result.resolution;
}

modulePaths.push(modulePath);
}
throw new FailedToResolveNameError(modulePaths);
}

/** Generate the potential module paths */
function* genModulePaths(
function parseModuleName(moduleName: string): ModuleParts {
const parts = moduleName.split(path.sep);
const scope = parts[0].startsWith('@') ? parts[0] : '';
return {
scope,
package: parts[scope ? 1 : 0],
file: parts.slice(scope ? 2 : 1).join(path.sep),
};
}

function* genPackagePaths(
context: ResolutionContext,
toModuleName: string,
parsedName: ModuleParts,
): Iterable<string> {
const {extraNodeModules, follow, originModulePath} = context;

/**
* Extract the scope and package name from the module name.
*/
let bits = path.normalize(toModuleName).split(path.sep);
let packageName, scopeName;
if (bits.length >= 2 && bits[0].startsWith('@')) {
packageName = bits.slice(0, 2).join('/');
scopeName = bits[0];
bits = bits.slice(2);
} else {
packageName = bits.shift();
}

/**
* Find the nearest "node_modules" directory that contains
* the imported package.
*/
const {root} = path.parse(originModulePath);
let parent = originModulePath;
const {root} = path.parse(context.originModulePath);
let parent = context.originModulePath;
do {
parent = path.dirname(parent);
if (path.basename(parent) !== 'node_modules') {
yield path.join(
follow(path.join(parent, 'node_modules', packageName)),
...bits,
);
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 (extraNodeModules) {
parent =
extraNodeModules[packageName] ||
(scopeName ? extraNodeModules[scopeName] : void 0);

if (parent) {
yield path.join(follow(path.join(parent, packageName)), ...bits);
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);
}
}
}
Expand Down Expand Up @@ -194,65 +190,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
8 changes: 1 addition & 7 deletions packages/metro-resolver/src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,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 @@ -111,7 +105,7 @@ export type ResolutionContext = ModulePathContext &
allowPnp: boolean,
allowHaste: boolean,
extraNodeModules: ?{[string]: string},
originModulePath: string,
redirectPackage: (packagePath: string) => string,
resolveRequest?: ?CustomResolver,
follow: FollowFn,
};
Expand Down
2 changes: 1 addition & 1 deletion packages/metro/src/node-haste/DependencyGraph.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ class DependencyGraph extends EventEmitter {
resetCache: config.resetCache,
rootDir: config.projectRoot,
roots: config.watchFolders,
throwOnModuleCollision: true,
skipHastePackages: true,
useWatchman: config.resolver.useWatchman,
watch: true,
});
Expand Down
35 changes: 20 additions & 15 deletions packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ class ModuleResolver<TModule: Moduleish, TPackage: Packageish> {
const fromPackagePath =
'./' +
path.relative(
path.dirname(fromPackage.path),
fromPackage.root,
path.resolve(path.dirname(fromModule.path), modulePath),
);

Expand All @@ -108,19 +108,19 @@ class ModuleResolver<TModule: Moduleish, TPackage: Packageish> {
'./' +
path.relative(
path.dirname(fromModule.path),
path.resolve(path.dirname(fromPackage.path), redirectedPath),
path.resolve(fromPackage.root, redirectedPath),
);
}

return redirectedPath;
}
} else {
const pck = path.isAbsolute(modulePath)
const pack = path.isAbsolute(modulePath)
? moduleCache.getModule(modulePath).getPackage()
: fromModule.getPackage();

if (pck) {
return pck.redirectRequire(modulePath, this._options.mainFields);
if (pack) {
return pack.redirectRequire(modulePath, this._options.mainFields);
}
}
} catch (err) {
Expand All @@ -146,11 +146,21 @@ class ModuleResolver<TModule: Moduleish, TPackage: Packageish> {
this._redirectRequire(fromModule, modulePath),
allowHaste,
platform,
resolveHasteModule: name =>
this._options.moduleMap.getModule(name, platform, true),
resolveHastePackage: name =>
this._options.moduleMap.getPackage(name, platform, true),
getPackageMainPath: this._getPackageMainPath,
resolveHasteModule(name) {
return this.moduleMap.getModule(name, platform, true);
},
getPackageMainPath(packageJsonPath: string): string {
return this.moduleCache
.getPackage(packageJsonPath)
.getMain(this.mainFields);
},
redirectPackage(packagePath: string): string {
packagePath = this.follow(packagePath);
const packageJsonPath = path.join(packagePath, 'package.json');
return this.doesFileExist(packageJsonPath)
? this.moduleCache.getPackage(packageJsonPath).root
: packagePath;
},
},
moduleName,
platform,
Expand Down Expand Up @@ -194,11 +204,6 @@ class ModuleResolver<TModule: Moduleish, TPackage: Packageish> {
}
}

_getPackageMainPath = (packageJsonPath: string): string => {
const package_ = this._options.moduleCache.getPackage(packageJsonPath);
return package_.getMain(this._options.mainFields);
};

/**
* FIXME: get rid of this function and of the reliance on `TModule`
* altogether, return strongly typed resolutions at the top-level instead.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {ModuleResolver} from './ModuleResolution';

export type Packageish = {
path: string,
root: string,
redirectRequire(
toModuleName: string,
mainFields: $ReadOnlyArray<string>,
Expand Down
Loading

0 comments on commit 8a57f6d

Please sign in to comment.