Skip to content

Commit fd688b1

Browse files
perf: cache conditionalMapping + per-plugin description-file lookups (#556)
1 parent 26f15b0 commit fd688b1

12 files changed

Lines changed: 239 additions & 152 deletions

.changeset/fix-dos-device-paths.md

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,4 @@
22
"enhanced-resolve": patch
33
---
44

5-
fix: properly handle DOS device paths (`\\?\…` and `\\.\…`) on Windows
6-
7-
Requests and context paths using the Win32 file namespace (`\\?\C:\…`,
8-
`\\?\UNC\server\share\…`) or device namespace (`\\.\C:\…`) were not
9-
handled correctly:
10-
11-
- `getType()` classified them as `Normal`, so `normalize`, `dirname`,
12-
and `join` ran them through posix helpers and failed to collapse `..`
13-
segments or compute parents correctly.
14-
- `parseIdentifier()` split on the literal `?` inside `\\?\`, turning a
15-
valid absolute request into a bogus module lookup under
16-
`node_modules`.
17-
- `cdUp()` returned `\` from `\` (via `slice(0, i || 1)`), so
18-
`loadDescriptionFile` walked forever once it reached the UNC/device
19-
root.
20-
21-
These paths are now recognized as Windows-absolute, parsed without
22-
misinterpreting the prefix `?`, and the description-file walk
23-
terminates at a bare `\` root. Plain UNC (`\\server\share\…`) remains
24-
out of scope.
5+
Properly handle DOS device paths (`\\?\…` and `\\.\…`).

.changeset/fix-exports-field-parent-fallback.md

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,4 @@
22
"enhanced-resolve": patch
33
---
44

5-
fix: prevent fallback to parent node_modules when exports field target file is not found
6-
7-
When a package has an `exports` field that maps a request to a target file,
8-
but that target file does not exist on disk, enhanced-resolve was incorrectly
9-
falling back to search parent `node_modules` directories. This violated the
10-
Node.js ESM resolution spec, which requires resolution to fail with an error
11-
rather than continue searching up the directory tree.
12-
13-
This manifested in monorepos where the same package exists at multiple levels
14-
(e.g. `workspace/node_modules/pkg` and `root/node_modules/pkg`): if the
15-
workspace version's exports-mapped target was missing, the resolver would
16-
silently resolve to the root version instead.
17-
18-
Root cause: `ExportsFieldPlugin` was returning `null` on failure, which
19-
`Resolver.doResolve` converted to `undefined`, causing
20-
`ModulesInHierarchicalDirectoriesPlugin` to treat the lookup as "not found,
21-
try next directory" rather than a hard stop.
22-
23-
Fix: when the `exports` field is present and a match is found but no valid
24-
target file can be resolved, return an explicit error to stop directory
25-
traversal. Closes #399.
5+
Prevent fallback to parent node_modules when the `exports` field target file is not found.

.changeset/fix-join-cache-memory-leak.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,4 @@
22
"enhanced-resolve": patch
33
---
44

5-
Move `cachedJoin`/`cachedDirname`/`createCachedBasename` caches from module-level globals to per-Resolver instances.
6-
This prevents unbounded memory growth in long-running processes — when a Resolver is garbage collected, its join/dirname/basename caches are released with it.
7-
8-
Also export `createCachedJoin`, `createCachedDirname` and `createCachedBasename` factory functions from `util/path` for creating independent cache instances.
5+
Move `cachedJoin`/`cachedDirname`/`createCachedBasename` caches from module-level globals to per-Resolver instances. This prevents unbounded memory growth in long-running processes — when a Resolver is garbage collected, its join/dirname/basename caches are released with it.

.changeset/large-beds-sell.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"enhanced-resolve": patch
33
---
44

5-
Apply extensionAlias to imports-field resolutions.
5+
Apply the `extensionAlias` option to the `imports` field to be align with typescript resolution.

.changeset/neat-toys-serve.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"enhanced-resolve": patch
33
---
44

5-
Improved performance of the alias plugin.
5+
Improved performance of the many plugins.

.changeset/perf-tsconfig-strip-comments-cache.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,4 @@
22
"enhanced-resolve": patch
33
---
44

5-
Cache the result of `stripJsonComments` + `JSON.parse` in `readJson` using a `WeakMap` keyed by the raw file buffer.
6-
When `CachedInputFileSystem` serves the same buffer, the parsed result is reused; when the buffer is purged and garbage collected, the cache entry is automatically released.
7-
8-
This avoids redundant comment-stripping and JSON parsing on every resolve call that reads tsconfig.json files (via `stripComments: true`), improving TsconfigPathsPlugin warm performance by ~20-35% depending on the depth of the `extends` chain.
5+
Cache the result of `stripJsonComments` + `JSON.parse` in `readJson` using a `WeakMap` keyed by the raw file buffer. This avoids redundant comment-stripping and JSON parsing on every resolve call that reads tsconfig.json files (via `stripComments: true`), improving TsconfigPathsPlugin warm performance by ~20-35% depending on the depth of the `extends` chain.

lib/AliasFieldPlugin.js

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ const getInnerRequest = require("./getInnerRequest");
1313
/** @typedef {import("./Resolver").ResolveRequest} ResolveRequest */
1414
/** @typedef {import("./Resolver").ResolveStepHook} ResolveStepHook */
1515

16+
// Sentinel stored in `_fieldDataCache` when a description file does not
17+
// contain a usable alias field object. Lets us distinguish "not cached yet"
18+
// from "no valid field" without calling back into `getField`.
19+
const NO_FIELD_OBJECT = Symbol("NoFieldObject");
20+
1621
module.exports = class AliasFieldPlugin {
1722
/**
1823
* @param {string | ResolveStepHook} source source
@@ -23,6 +28,12 @@ module.exports = class AliasFieldPlugin {
2328
this.source = source;
2429
this.field = field;
2530
this.target = target;
31+
// `this.field` is fixed for the plugin's lifetime, so caching
32+
// per description-file content is safe. The cached value is either
33+
// the resolved alias-map object or the `NO_FIELD_OBJECT` sentinel
34+
// meaning "description file has no usable alias field".
35+
/** @type {WeakMap<import("./Resolver").JsonObject, { [k: string]: JsonPrimitive } | typeof NO_FIELD_OBJECT>} */
36+
this._fieldDataCache = new WeakMap();
2637
}
2738

2839
/**
@@ -37,11 +48,25 @@ module.exports = class AliasFieldPlugin {
3748
if (!request.descriptionFileData) return callback();
3849
const innerRequest = getInnerRequest(resolver, request);
3950
if (!innerRequest) return callback();
40-
const fieldData = DescriptionFileUtils.getField(
41-
request.descriptionFileData,
42-
this.field,
43-
);
44-
if (fieldData === null || typeof fieldData !== "object") {
51+
const { descriptionFileData } = request;
52+
let fieldData = this._fieldDataCache.get(descriptionFileData);
53+
if (fieldData === undefined) {
54+
const raw = DescriptionFileUtils.getField(
55+
descriptionFileData,
56+
this.field,
57+
);
58+
if (raw === null || typeof raw !== "object") {
59+
this._fieldDataCache.set(descriptionFileData, NO_FIELD_OBJECT);
60+
if (resolveContext.log) {
61+
resolveContext.log(
62+
`Field '${this.field}' doesn't contain a valid alias configuration`,
63+
);
64+
}
65+
return callback();
66+
}
67+
fieldData = /** @type {{ [k: string]: JsonPrimitive }} */ (raw);
68+
this._fieldDataCache.set(descriptionFileData, fieldData);
69+
} else if (fieldData === NO_FIELD_OBJECT) {
4570
if (resolveContext.log) {
4671
resolveContext.log(
4772
`Field '${this.field}' doesn't contain a valid alias configuration`,

lib/ExportsFieldPlugin.js

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,11 @@ module.exports = class ExportsFieldPlugin {
3333
this.target = target;
3434
this.conditionNames = conditionNames;
3535
this.fieldName = fieldNamePath;
36-
/** @type {WeakMap<JsonObject, FieldProcessor>} */
37-
this.fieldProcessorCache = new WeakMap();
36+
// `null` is cached for description files that have no exports field,
37+
// so subsequent resolves against the same package.json skip the
38+
// `DescriptionFileUtils.getField` walk entirely.
39+
/** @type {WeakMap<JsonObject, FieldProcessor | null>} */
40+
this._fieldProcessorCache = new WeakMap();
3841
}
3942

4043
/**
@@ -47,7 +50,7 @@ module.exports = class ExportsFieldPlugin {
4750
.getHook(this.source)
4851
.tapAsync("ExportsFieldPlugin", (request, resolveContext, callback) => {
4952
// When there is no description file, abort
50-
if (!request.descriptionFilePath) return callback();
53+
if (!request.descriptionFileData) return callback();
5154
if (
5255
// When the description file is inherited from parent, abort
5356
// (There is no description file inside of this package)
@@ -57,49 +60,56 @@ module.exports = class ExportsFieldPlugin {
5760
return callback();
5861
}
5962

63+
const { descriptionFileData } = request;
6064
const remainingRequest =
6165
request.query || request.fragment
6266
? (request.request === "." ? "./" : request.request) +
6367
request.query +
6468
request.fragment
6569
: request.request;
66-
const exportsField =
67-
/** @type {ExportsField | null | undefined} */
68-
(
69-
DescriptionFileUtils.getField(
70-
/** @type {JsonObject} */ (request.descriptionFileData),
71-
this.fieldName,
72-
)
73-
);
74-
if (!exportsField) return callback();
75-
76-
if (request.directory) {
77-
return callback(
78-
new Error(
79-
`Resolving to directories is not possible with the exports field (request was ${remainingRequest}/)`,
80-
),
81-
);
82-
}
8370

8471
/** @type {string[]} */
8572
let paths;
8673
/** @type {string | null} */
8774
let usedField;
8875

8976
try {
90-
// We attach the cache to the description file instead of the exportsField value
91-
// because we use a WeakMap and the exportsField could be a string too.
92-
// Description file is always an object when exports field can be accessed.
93-
let fieldProcessor = this.fieldProcessorCache.get(
94-
/** @type {JsonObject} */ (request.descriptionFileData),
95-
);
96-
if (fieldProcessor === undefined) {
97-
fieldProcessor = processExportsField(exportsField);
98-
this.fieldProcessorCache.set(
99-
/** @type {JsonObject} */ (request.descriptionFileData),
100-
fieldProcessor,
77+
// Look up the cached processor first. On a cache hit we
78+
// avoid re-walking the description file for the exports
79+
// field — and `null` is cached for description files that
80+
// have no exports field at all, so those skip the read
81+
// entirely. `processExportsField` can throw on a malformed
82+
// `exports` map (e.g. a key without a leading `.`), so
83+
// building the processor must stay inside this try/catch.
84+
let fieldProcessor =
85+
this._fieldProcessorCache.get(descriptionFileData);
86+
if (
87+
fieldProcessor === undefined &&
88+
!this._fieldProcessorCache.has(descriptionFileData)
89+
) {
90+
const exportsField =
91+
/** @type {ExportsField | null | undefined} */
92+
(
93+
DescriptionFileUtils.getField(
94+
descriptionFileData,
95+
this.fieldName,
96+
)
97+
);
98+
fieldProcessor = exportsField
99+
? processExportsField(exportsField)
100+
: null;
101+
this._fieldProcessorCache.set(descriptionFileData, fieldProcessor);
102+
}
103+
if (!fieldProcessor) return callback();
104+
105+
if (request.directory) {
106+
return callback(
107+
new Error(
108+
`Resolving to directories is not possible with the exports field (request was ${remainingRequest}/)`,
109+
),
101110
);
102111
}
112+
103113
[paths, usedField] = fieldProcessor(
104114
remainingRequest,
105115
this.conditionNames,

lib/ImportsFieldPlugin.js

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,11 @@ module.exports = class ImportsFieldPlugin {
4343
this.targetPackage = targetPackage;
4444
this.conditionNames = conditionNames;
4545
this.fieldName = fieldNamePath;
46-
/** @type {WeakMap<JsonObject, FieldProcessor>} */
47-
this.fieldProcessorCache = new WeakMap();
46+
// `null` is cached for description files that have no imports field,
47+
// so subsequent resolves against the same package.json skip the
48+
// `DescriptionFileUtils.getField` walk entirely.
49+
/** @type {WeakMap<JsonObject, FieldProcessor | null>} */
50+
this._fieldProcessorCache = new WeakMap();
4851
}
4952

5053
/**
@@ -59,49 +62,56 @@ module.exports = class ImportsFieldPlugin {
5962
.getHook(this.source)
6063
.tapAsync("ImportsFieldPlugin", (request, resolveContext, callback) => {
6164
// When there is no description file, abort
62-
if (!request.descriptionFilePath || request.request === undefined) {
65+
if (!request.descriptionFileData || request.request === undefined) {
6366
return callback();
6467
}
6568

69+
const { descriptionFileData } = request;
6670
const remainingRequest =
6771
request.request + request.query + request.fragment;
68-
const importsField =
69-
/** @type {ImportsField | null | undefined} */
70-
(
71-
DescriptionFileUtils.getField(
72-
/** @type {JsonObject} */ (request.descriptionFileData),
73-
this.fieldName,
74-
)
75-
);
76-
if (!importsField) return callback();
77-
78-
if (request.directory) {
79-
return callback(
80-
new Error(
81-
`Resolving to directories is not possible with the imports field (request was ${remainingRequest}/)`,
82-
),
83-
);
84-
}
8572

8673
/** @type {string[]} */
8774
let paths;
8875
/** @type {string | null} */
8976
let usedField;
9077

9178
try {
92-
// We attach the cache to the description file instead of the importsField value
93-
// because we use a WeakMap and the importsField could be a string too.
94-
// Description file is always an object when exports field can be accessed.
95-
let fieldProcessor = this.fieldProcessorCache.get(
96-
/** @type {JsonObject} */ (request.descriptionFileData),
97-
);
98-
if (fieldProcessor === undefined) {
99-
fieldProcessor = processImportsField(importsField);
100-
this.fieldProcessorCache.set(
101-
/** @type {JsonObject} */ (request.descriptionFileData),
102-
fieldProcessor,
79+
// Look up the cached processor first. On a cache hit we
80+
// avoid re-walking the description file for the imports
81+
// field — and `null` is cached for description files that
82+
// have no imports field at all, so those skip the read
83+
// entirely. `processImportsField` can throw on a
84+
// malformed `imports` map, so building the processor must
85+
// stay inside this try/catch.
86+
let fieldProcessor =
87+
this._fieldProcessorCache.get(descriptionFileData);
88+
if (
89+
fieldProcessor === undefined &&
90+
!this._fieldProcessorCache.has(descriptionFileData)
91+
) {
92+
const importsField =
93+
/** @type {ImportsField | null | undefined} */
94+
(
95+
DescriptionFileUtils.getField(
96+
descriptionFileData,
97+
this.fieldName,
98+
)
99+
);
100+
fieldProcessor = importsField
101+
? processImportsField(importsField)
102+
: null;
103+
this._fieldProcessorCache.set(descriptionFileData, fieldProcessor);
104+
}
105+
if (!fieldProcessor) return callback();
106+
107+
if (request.directory) {
108+
return callback(
109+
new Error(
110+
`Resolving to directories is not possible with the imports field (request was ${remainingRequest}/)`,
111+
),
103112
);
104113
}
114+
105115
[paths, usedField] = fieldProcessor(
106116
remainingRequest,
107117
this.conditionNames,

0 commit comments

Comments
 (0)