From 355de71ea4748d374d342e8d55d86c812ae84d18 Mon Sep 17 00:00:00 2001 From: aleclarson Date: Tue, 18 Sep 2018 14:37:44 -0400 Subject: [PATCH] feat: symlinks in node_modules - resolve symlinks inside "node_modules" directories - add `follow` function to `ResolutionContext` type - move "node_modules" logic into the `genModulePaths` method - feat: scope-only keys in `extraNodeModules` map - fix: always preserve scope when checking `extraNodeModules` map - fix: throw an error if `resolveRequest` fails to resolve a direct import - [BREAKING] refactor the `FailedToResolveNameError` class This commit adds support for symlinks in any `node_modules` directory. This includes scoped packages. Also, PNPM users rejoice! The `genModulePaths` method avoids extra work by lazily generating the potential module paths for indirect imports. Also, the resolver now skips over module paths that contain `/node_modules/node_modules/`. The `extraNodeModules` map now has support for keys like `@babel` to act as a fallback for all modules starting with `@babel/`. Previously, if the `resolveRequest` function failed to resolve a direct import, we would continue onto the "node_modules" search logic. The correct behavior is to throw an error. --- .../src/FailedToResolveNameError.js | 18 +--- packages/metro-resolver/src/resolve.js | 91 ++++++++++++------- packages/metro-resolver/src/types.js | 2 + .../src/ModuleGraph/node-haste/node-haste.js | 1 + .../metro/src/node-haste/DependencyGraph.js | 1 + .../DependencyGraph/ModuleResolution.js | 22 +---- packages/metro/src/node-haste/types.js | 1 + 7 files changed, 75 insertions(+), 61 deletions(-) diff --git a/packages/metro-resolver/src/FailedToResolveNameError.js b/packages/metro-resolver/src/FailedToResolveNameError.js index bf01dce3dc..67d24dd9cc 100644 --- a/packages/metro-resolver/src/FailedToResolveNameError.js +++ b/packages/metro-resolver/src/FailedToResolveNameError.js @@ -13,25 +13,17 @@ const path = require('path'); class FailedToResolveNameError extends Error { - dirPaths: $ReadOnlyArray; - extraPaths: $ReadOnlyArray; + modulePaths: $ReadOnlyArray; - constructor( - dirPaths: $ReadOnlyArray, - extraPaths: $ReadOnlyArray, - ) { - const displayDirPaths = dirPaths.concat(extraPaths); - const hint = displayDirPaths.length ? ' or in these directories:' : ''; + constructor(modulePaths: $ReadOnlyArray) { + 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; } } diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index 32bfeb4c7a..8023a9cc80 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -90,45 +90,74 @@ function resolve( return resolution; } } catch (error) {} + if (isDirectImport) { + throw new Error('Failed to resolve module: ' + realModuleName); + } } - 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 modulePaths = []; + for (let modulePath of genModulePaths(context, realModuleName)) { + modulePath = context.redirectModulePath(modulePath); - 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; +/** Generate the potential module paths */ +function* genModulePaths( + context: ResolutionContext, + toModuleName: string, +): Iterable { + 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; + do { + parent = path.dirname(parent); + if (path.basename(parent) !== 'node_modules') { + yield path.join( + follow(path.join(parent, 'node_modules', packageName)), + ...bits, + ); + } + } 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); } } - throw new FailedToResolveNameError(dirPaths, extraPaths); } /** diff --git a/packages/metro-resolver/src/types.js b/packages/metro-resolver/src/types.js index 56653a1793..cd5d866747 100644 --- a/packages/metro-resolver/src/types.js +++ b/packages/metro-resolver/src/types.js @@ -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 @@ -111,6 +112,7 @@ export type ResolutionContext = ModulePathContext & extraNodeModules: ?{[string]: string}, originModulePath: string, resolveRequest?: ?CustomResolver, + follow: FollowFn, }; export type CustomResolver = ( diff --git a/packages/metro/src/ModuleGraph/node-haste/node-haste.js b/packages/metro/src/ModuleGraph/node-haste/node-haste.js index 8253a48d9e..b860f06f81 100644 --- a/packages/metro/src/ModuleGraph/node-haste/node-haste.js +++ b/packages/metro/src/ModuleGraph/node-haste/node-haste.js @@ -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, diff --git a/packages/metro/src/node-haste/DependencyGraph.js b/packages/metro/src/node-haste/DependencyGraph.js index 15c2da8a55..3eb0515d3c 100644 --- a/packages/metro/src/node-haste/DependencyGraph.js +++ b/packages/metro/src/node-haste/DependencyGraph.js @@ -143,6 +143,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(); diff --git a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js index 1cbc5dded6..43e6c4765e 100644 --- a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js +++ b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js @@ -26,6 +26,7 @@ import type { ResolveAsset, } from 'metro-resolver'; +export type FollowFn = (filePath: string) => string; export type DirExistsFn = (filePath: string) => boolean; /** @@ -54,6 +55,7 @@ export type ModuleishCache = { }; type Options = {| + +follow: FollowFn, +dirExists: DirExistsFn, +doesFileExist: DoesFileExist, +extraNodeModules: ?Object, @@ -173,28 +175,14 @@ class ModuleResolver { ); } if (error instanceof Resolver.FailedToResolveNameError) { - const { - dirPaths, - extraPaths, - }: { - // $flowfixme these types are defined explicitly in FailedToResolveNameError but Flow refuses to recognize them here - dirPaths: $ReadOnlyArray, - extraPaths: $ReadOnlyArray, - } = error; - const displayDirPaths = dirPaths - .filter((dirPath: string) => this._options.dirExists(dirPath)) - .map(dirPath => path.relative(this._options.projectRoot, dirPath)) - .concat(extraPaths); - - const hint = displayDirPaths.length ? ' or in these directories:' : ''; + const {modulePaths} = error; + const hint = modulePaths.length ? ' or at these locations:' : ''; throw new UnableToResolveError( path.relative(this._options.projectRoot, fromModule.path), moduleName, [ `${moduleName} could not be found within the project${hint || '.'}`, - ...displayDirPaths.map( - (dirPath: string) => ` ${path.dirname(dirPath)}`, - ), + ...modulePaths.map(modulePath => ` ${modulePath}`), '\nIf you are sure the module exists, try these steps:', ' 1. Clear watchman watches: watchman watch-del-all', ' 2. Delete node_modules: rm -rf node_modules and run yarn install', diff --git a/packages/metro/src/node-haste/types.js b/packages/metro/src/node-haste/types.js index dd31464e7e..e3e0e04aa0 100644 --- a/packages/metro/src/node-haste/types.js +++ b/packages/metro/src/node-haste/types.js @@ -13,6 +13,7 @@ // TODO(cpojer): Create a jest-types repo. export type HasteFS = { exists(filePath: string): boolean, + follow(filePath: string): string, getAllFiles(): Array, getFileIterator(): Iterator, getModuleName(filePath: string): ?string,