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’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(metro-resolver-symlinks): experimental option to retry resolving from disk #2044

Merged
merged 6 commits into from
Nov 30, 2022
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
5 changes: 5 additions & 0 deletions .changeset/wet-cheetahs-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rnx-kit/metro-resolver-symlinks": patch
---

Add an experimental option for retrying module resolution from disk if not found in Haste map
7 changes: 4 additions & 3 deletions packages/metro-resolver-symlinks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,10 @@ Import and assign the resolver to `resolver.resolveRequest` in your

## Options

| Option | Type | Description |
| :---------- | :----------------------------- | :-------------------------------------------------- |
| remapModule | `(moduleId: string) => string` | A custom function for remapping additional modules. |
| Option | Type | Description |
| :------------------------------------ | :----------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `remapModule` | `(moduleId: string) => string` | A custom function for remapping additional modules. |
| `experimental_retryResolvingFromDisk` | boolean | [Experimental] Whether to retry module resolution on disk if not found in Haste map. This option is useful for scenarios where you want to reduce the number of watched files (and thus the initial time spent on crawling). Note that enabling this will likely be slower than having a warm cache. |

### `remapModule`

Expand Down
41 changes: 33 additions & 8 deletions packages/metro-resolver-symlinks/src/symlinkResolver.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,47 @@
import { normalizePath } from "@rnx-kit/tools-node";
import type { CustomResolver, ResolutionContext } from "metro-resolver";
import type {
CustomResolver,
Resolution,
ResolutionContext,
} from "metro-resolver";
import { requireModuleFromMetro } from "./helper";
import { remapReactNativeModule, resolveModulePath } from "./resolver";
import type { MetroResolver, Options } from "./types";
import { applyEnhancedResolver } from "./utils/enhancedResolve";
import {
patchMetro,
shouldEnableRetryResolvingFromDisk,
} from "./utils/patchMetro";
import { remapImportPath } from "./utils/remapImportPath";

function applyMetroResolver(
resolve: CustomResolver,
context: ResolutionContext,
moduleName: string,
platform: string
): Resolution {
const modifiedModuleName = resolveModulePath(context, moduleName, platform);
return resolve(context, normalizePath(modifiedModuleName), platform, null);
}

export function makeResolver(options: Options = {}): MetroResolver {
const { resolve: metroResolver } =
requireModuleFromMetro<typeof import("metro-resolver")>("metro-resolver");

const { remapModule = (_, moduleName, __) => moduleName } = options;

const remappers = [remapModule, remapReactNativeModule, resolveModulePath];
const enableRetryResolvingFromDisk =
shouldEnableRetryResolvingFromDisk(options);

const applyResolver = enableRetryResolvingFromDisk
? applyEnhancedResolver
: applyMetroResolver;

if (enableRetryResolvingFromDisk) {
patchMetro(options);
afoxman marked this conversation as resolved.
Show resolved Hide resolved
}

const remappers = [remapModule, remapReactNativeModule];
const symlinkResolver = (
context: ResolutionContext,
moduleName: string,
Expand Down Expand Up @@ -56,12 +86,7 @@ export function makeResolver(options: Options = {}): MetroResolver {
moduleName
);

return resolve(
context,
normalizePath(modifiedModuleName),
platform,
null
);
return applyResolver(resolve, context, modifiedModuleName, platform);
} finally {
if (!context.resolveRequest) {
// Restoring `resolveRequest` must happen last
Expand Down
6 changes: 5 additions & 1 deletion packages/metro-resolver-symlinks/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { ResolutionContext as MetroResolutionContext } from "metro-resolver";

type ExperimentalOptions = {
experimental_retryResolvingFromDisk?: boolean | "force";
};

export type MetroResolver = typeof import("metro-resolver").resolve;

export type ResolutionContext = Pick<
Expand All @@ -13,6 +17,6 @@ export type ModuleResolver = (
platform: string
) => string;

export type Options = {
export type Options = ExperimentalOptions & {
remapModule?: ModuleResolver;
};
68 changes: 68 additions & 0 deletions packages/metro-resolver-symlinks/src/utils/enhancedResolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { isPackageModuleRef, parseModuleRef } from "@rnx-kit/tools-node";
import { expandPlatformExtensions } from "@rnx-kit/tools-react-native/platform";
import type {
CustomResolver,
Resolution,
ResolutionContext,
} from "metro-resolver";
import * as path from "path";

type Resolver = (fromDir: string, moduleId: string) => string | false;

const getEnhancedResolver = (() => {
const resolvers: Record<string, Resolver> = {};
return (context: ResolutionContext, platform = "common") => {
if (!resolvers[platform]) {
// @ts-expect-error Property 'mainFields' does not exist on type 'ResolutionContext'
const { mainFields, sourceExts } = context;
const extensions = sourceExts.map((ext) => `.${ext}`);
resolvers[platform] = require("enhanced-resolve").create.sync({
aliasFields: ["browser"],
extensions:
platform === "common"
? extensions
: expandPlatformExtensions(platform, extensions),
mainFields,
});
}
return resolvers[platform];
};
})();

function getFromDir(context: ResolutionContext, moduleName: string): string {
const { extraNodeModules, originModulePath } = context;
if (extraNodeModules) {
const ref = parseModuleRef(moduleName);
if (isPackageModuleRef(ref)) {
const pkgName = ref.scope ? `${ref.scope}/${ref.name}` : ref.name;
const dir = extraNodeModules[pkgName];
if (dir) {
return dir;
}
}
}

return originModulePath ? path.dirname(originModulePath) : process.cwd();
}

export function applyEnhancedResolver(
_resolve: CustomResolver,
context: ResolutionContext,
moduleName: string,
platform: string
): Resolution {
if (!platform) {
return { type: "empty" };
}

const enhancedResolve = getEnhancedResolver(context, platform);
const filePath = enhancedResolve(getFromDir(context, moduleName), moduleName);
if (filePath === false) {
return { type: "empty" };
}

return {
type: "sourceFile",
filePath,
};
}
136 changes: 136 additions & 0 deletions packages/metro-resolver-symlinks/src/utils/patchMetro.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import * as fs from "fs";
import * as path from "path";
import { ensureResolveFrom, getMetroSearchPath } from "../helper";
import type { Options } from "../types";

function fileExists(path: string): boolean {
const stat = fs.statSync(path, { throwIfNoEntry: false });
afoxman marked this conversation as resolved.
Show resolved Hide resolved
return Boolean(stat && stat.isFile());
}

function importMetroModule(path: string) {
const metroPath = ensureResolveFrom("metro", getMetroSearchPath());
const modulePath = metroPath + path;
try {
return require(modulePath);
} catch (_) {
throw new Error(
`Cannot find '${modulePath}'. This probably means that ` +
"'experimental_retryResolvingFromDisk' is not compatible with the " +
"version of 'metro' that you are currently using. Please update to " +
"the latest version and try again. If the issue still persists after " +
"the update, please file a bug at " +
"https://github.com/microsoft/rnx-kit/issues."
);
}
}

function getDependencyGraph() {
return importMetroModule("/src/node-haste/DependencyGraph");
}

function supportsRetryResolvingFromDisk(): boolean {
const { version } = importMetroModule("/package.json");
const [major, minor] = version.split(".");
const v = major * 1000 + minor;
return v >= 64 && v <= 73;
}

export function shouldEnableRetryResolvingFromDisk({
experimental_retryResolvingFromDisk,
}: Options): boolean {
if (
!supportsRetryResolvingFromDisk() &&
experimental_retryResolvingFromDisk !== "force"
) {
console.warn(
"The version of Metro you're using has not been tested with " +
"`experimental_retryResolvingFromDisk`. If you still want to enable " +
"it, please set it to 'force'."
);
return false;
}

return Boolean(experimental_retryResolvingFromDisk);
}

/**
* Monkey-patches Metro to not use HasteFS as the only source for module
* resolution.
*
* Practically every file system operation in Metro must go through HasteFS,
* most notably watching for file changes and resolving node modules. If Metro
* cannot find a file in the Haste map, it does not exist. This means that for
* Metro to find a file, all folders must be declared in `watchFolders`,
* including `node_modules` and any dependency storage folders (e.g. pnpm)
* regardless of whether we need to watch them. In big monorepos, this can
* easily overwhelm file watchers, even with Watchman installed.
*
* There's no way to avoid the initial crawling of the file system. However, we
* can drastically reduce the number of files that needs to be crawled/watched
* by not relying solely on Haste for module resolution. This requires patching
* Metro to use `fs.existsSync` instead of `HasteFS.exists`. With this change,
* we can list only the folders that we care about in `watchFolders`. In some
* cases, like on CI, we can even set `watchFolders` to an empty array to limit
* watched files to the current package only.
*
* Why didn't we use `hasteImplModulePath`? Contrary to the name, it doesn't
* let you replace HasteFS. As of 0.73, it is only used to retrieve the path of
* a module. The default implementation returns
* `path.relative(projectRoot, filePath)` if the entry is not found in the map.
*
* @param options Options passed to Metro
*/
export function patchMetro(options: Options): void {
if (!shouldEnableRetryResolvingFromDisk(options)) {
return;
}

const DependencyGraph = getDependencyGraph();
afoxman marked this conversation as resolved.
Show resolved Hide resolved

// Patch `_createModuleResolver` and `_doesFileExist` to use `fs.existsSync`.
DependencyGraph.prototype.orig__createModuleResolver =
DependencyGraph.prototype._createModuleResolver;
DependencyGraph.prototype._createModuleResolver = function (): void {
this._doesFileExist = (filePath: string): boolean => {
return this._hasteFS.exists(filePath) || fileExists(filePath);
afoxman marked this conversation as resolved.
Show resolved Hide resolved
};

this.orig__createModuleResolver();
if (typeof this._moduleResolver._options.resolveAsset !== "function") {
throw new Error("Could not find `resolveAsset` in `ModuleResolver`");
}

this._moduleResolver._options.resolveAsset = (
dirPath: string,
assetName: string,
extension: string
) => {
const basePath = dirPath + path.sep + assetName;
const assets = [
basePath + extension,
...this._config.resolver.assetResolutions.map(
(resolution: string) => basePath + "@" + resolution + "x" + extension
),
].filter(this._doesFileExist);
return assets.length ? assets : null;
};
};

// Since we will be resolving files outside of `watchFolders`, their hashes
// will not be found. We'll return the `filePath` as they should be unique.
DependencyGraph.prototype.orig_getSha1 = DependencyGraph.prototype.getSha1;
DependencyGraph.prototype.getSha1 = function (filePath: string): string {
try {
return this.orig_getSha1(filePath);
} catch (e) {
// `ReferenceError` will always be thrown when Metro encounters a file
// that does not exist in the Haste map.
if (e instanceof ReferenceError) {
return filePath;
afoxman marked this conversation as resolved.
Show resolved Hide resolved
}

throw e;
}
};
}