Skip to content

Commit 3021478

Browse files
committed
fixup! module: add clearCache for CJS and ESM
1 parent be1f4a5 commit 3021478

12 files changed

+278
-238
lines changed

doc/api/module.md

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,6 @@ added: REPLACEME
8787
* `'module'` — clear the cached module everywhere in Node.js (not counting
8888
JS-level references).
8989
* `'all'` — clear both resolution and module caches.
90-
* `importAttributes` {Object} Optional import attributes. Only meaningful when
91-
`resolver` is `'import'`.
9290
9391
Clears module resolution and/or module caches for a module. This enables
9492
reload patterns similar to deleting from `require.cache` in CommonJS, and is useful for
@@ -101,27 +99,25 @@ the same file path are cleared even if they differ by search or hash. This means
10199
`'./mod.mjs?v=1'` will also clear `'./mod.mjs?v=2'` and any other query/hash variants that
102100
resolve to the same file.
103101
104-
When `caches` is `'resolution'` or `'all'` with `resolver` set to `'import'`, the ESM
105-
resolution cache entry for the given `(specifier, parentURL, importAttributes)` tuple is
106-
cleared. When `resolver` is `'require'`, internal CJS resolution caches (including the
107-
relative resolve cache and path cache) are also cleared for the resolved filename.
108-
When `importAttributes` are provided for `'import'` resolution, they are used to construct the cache key; if a module
109-
was loaded with multiple different import attribute combinations, only the matching entry
110-
is cleared from the resolution cache. The module cache itself (`caches: 'module'`) clears
111-
all attribute variants for the URL.
102+
When `caches` is `'resolution'` or `'all'` with `resolver` set to `'import'`, all ESM
103+
resolution cache entries for the given `(specifier, parentURL)` pair are cleared regardless
104+
of import attributes. When `resolver` is `'require'`, the specific CJS relative resolve
105+
cache entry for the `(parentDir, specifier)` pair is cleared, together with any cached
106+
`package.json` data for the resolved module's package.
112107
113108
Clearing a module does not clear cached entries for its dependencies. When using
114109
`resolver: 'import'`, resolution cache entries for other specifiers that resolve to the
115-
same target are not cleared — only the exact `(specifier, parentURL, importAttributes)`
116-
entry is removed. The module cache itself is cleared by resolved file path, so all
117-
specifiers pointing to the same file will see a fresh execution on next import.
110+
same target are not cleared — only entries for the exact `(specifier, parentURL)` pair
111+
are removed. The module cache itself is cleared by resolved file path, so all specifiers
112+
pointing to the same file will see a fresh execution on next import.
118113
119114
#### ECMA-262 spec considerations
120115
121-
Re-importing the exact same `(specifier, parentURL, importAttribtues)` tuple after clearing the module cache
116+
Re-importing the exact same `(specifier, parentURL, importAttributes)` tuple after clearing the module cache
122117
technically violates the idempotency invariant of the ECMA-262
123118
[`HostLoadImportedModule`][] host hook, which expects that the same module request always
124-
returns the same Module Record for a given referrer. For spec-compliant usage, use
119+
returns the same Module Record for a given referrer. The result of violating this requirement
120+
is undefined — e.g. it can lead to crashes. For spec-compliant usage, use
125121
cache-busting search parameters so that each reload uses a distinct module request:
126122
127123
```mjs
@@ -163,11 +159,12 @@ await import('./mod.mjs'); // re-executes the module
163159
164160
```cjs
165161
const { clearCache } = require('node:module');
162+
const { pathToFileURL } = require('node:url');
166163

167164
require('./mod.js');
168165

169166
clearCache('./mod.js', {
170-
parentURL: __filename,
167+
parentURL: pathToFileURL(__filename),
171168
resolver: 'require',
172169
caches: 'module',
173170
});

lib/internal/modules/cjs/loader.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ module.exports = {
122122
initializeCJS,
123123
Module,
124124
clearCJSResolutionCaches,
125+
deleteCJSRelativeResolveCacheEntry,
125126
findLongestRegisteredExtension,
126127
resolveForCJSWithHooks,
127128
loadSourceForCJSWithHooks: loadSource,
@@ -250,6 +251,23 @@ function clearCJSResolutionCaches(filename) {
250251
}
251252
}
252253

254+
/**
255+
* Delete a single entry from the CJS relative resolve cache.
256+
* The cache key is `${parent.path}\x00${request}` where parent.path is the
257+
* directory containing the parent module (i.e. path.dirname(parentFilename)).
258+
* @param {string} parentDir Directory of the parent module (path.dirname of filename).
259+
* @param {string} request The specifier as originally passed to require().
260+
* @returns {boolean} true if the entry existed and was deleted.
261+
*/
262+
function deleteCJSRelativeResolveCacheEntry(parentDir, request) {
263+
const key = `${parentDir}\x00${request}`;
264+
if (relativeResolveCache[key] !== undefined) {
265+
delete relativeResolveCache[key];
266+
return true;
267+
}
268+
return false;
269+
}
270+
253271
let requireDepth = 0;
254272
let isPreloading = false;
255273
let statCache = null;

lib/internal/modules/clear.js

Lines changed: 30 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ const {
1414
Module,
1515
resolveForCJSWithHooks,
1616
clearCJSResolutionCaches,
17+
deleteCJSRelativeResolveCacheEntry,
1718
} = require('internal/modules/cjs/loader');
19+
const { getFilePathFromFileURL } = require('internal/modules/helpers');
1820
const { fileURLToPath, isURL, URLParse, pathToFileURL } = require('internal/url');
19-
const { emitExperimentalWarning, kEmptyObject, isWindows } = require('internal/util');
21+
const { emitExperimentalWarning, isWindows } = require('internal/util');
2022
const { validateObject, validateOneOf, validateString } = require('internal/validators');
2123
const {
2224
codes: {
@@ -75,29 +77,18 @@ function createParentModuleForClearCache(parentPath) {
7577
/**
7678
* Resolve a cache filename for CommonJS.
7779
* Always goes through resolveForCJSWithHooks so that registered hooks
78-
* are respected. CJS operates on file paths and bare specifiers. file:
79-
* URL objects or strings are converted to paths; non-file URLs are not
80-
* supported and will return null.
80+
* are respected. The specifier is passed as-is: if hooks are registered,
81+
* they handle any URL interpretation; if not, it is treated as a plain
82+
* path/identifier (matching how require() interprets its argument).
8183
* @param {string|URL} specifier
8284
* @param {string|undefined} parentPath
8385
* @returns {string|null}
8486
*/
8587
function resolveClearCacheFilename(specifier, parentPath) {
86-
let request;
87-
if (isURL(specifier)) {
88-
if (specifier.protocol !== 'file:') {
89-
return null;
90-
}
91-
request = fileURLToPath(specifier);
92-
} else if (typeof specifier === 'string' && StringPrototypeStartsWith(specifier, 'file:')) {
93-
const parsed = URLParse(specifier);
94-
if (!parsed || parsed.protocol !== 'file:') {
95-
return null;
96-
}
97-
request = fileURLToPath(parsed);
98-
} else {
99-
request = specifier;
100-
}
88+
// Pass the specifier through as-is. When hooks are registered they
89+
// receive the raw value; without hooks CJS resolution treats it as
90+
// a plain path or bare name, consistent with how require() behaves.
91+
const request = isURL(specifier) ? specifier.href : specifier;
10192

10293
if (!parentPath && isRelative(request)) {
10394
return null;
@@ -164,29 +155,6 @@ function deleteModuleFromParents(targetModule) {
164155
return deleted;
165156
}
166157

167-
/**
168-
* Resolve a file path for a file URL, stripping search/hash.
169-
* @param {string} url
170-
* @returns {string|null}
171-
*/
172-
function getFilePathFromClearCacheURL(url) {
173-
const parsedURL = URLParse(url);
174-
if (parsedURL?.protocol !== 'file:') {
175-
return null;
176-
}
177-
178-
if (parsedURL.search !== '' || parsedURL.hash !== '') {
179-
parsedURL.search = '';
180-
parsedURL.hash = '';
181-
}
182-
183-
try {
184-
return fileURLToPath(parsedURL);
185-
} catch {
186-
return null;
187-
}
188-
}
189-
190158
/**
191159
* Remove load cache entries for a URL and its file-path variants.
192160
* @param {import('internal/modules/esm/module_map').LoadCache} loadCache
@@ -195,7 +163,7 @@ function getFilePathFromClearCacheURL(url) {
195163
*/
196164
function deleteLoadCacheEntries(loadCache, url) {
197165
let deleted = loadCache.deleteAll(url);
198-
const filename = getFilePathFromClearCacheURL(url);
166+
const filename = getFilePathFromFileURL(url);
199167
if (!filename) {
200168
return deleted;
201169
}
@@ -210,7 +178,7 @@ function deleteLoadCacheEntries(loadCache, url) {
210178
if (cachedURL === url) {
211179
continue;
212180
}
213-
const cachedFilename = getFilePathFromClearCacheURL(cachedURL);
181+
const cachedFilename = getFilePathFromFileURL(cachedURL);
214182
if (cachedFilename === filename) {
215183
loadCache.deleteAll(cachedURL);
216184
deleted = true;
@@ -240,7 +208,6 @@ function isRelative(pathToCheck) {
240208
* @param {string|URL} specifier What would've been passed into import() or require().
241209
* @param {{
242210
* parentURL: string|URL,
243-
* importAttributes?: Record<string, string>,
244211
* resolver: 'import'|'require',
245212
* caches: 'resolution'|'module'|'all',
246213
* }} options
@@ -260,11 +227,6 @@ function clearCache(specifier, options) {
260227
validateOneOf(resolver, 'options.resolver', ['import', 'require']);
261228
validateOneOf(caches, 'options.caches', ['resolution', 'module', 'all']);
262229

263-
const importAttributes = options.importAttributes ?? kEmptyObject;
264-
if (options.importAttributes !== undefined) {
265-
validateObject(options.importAttributes, 'options.importAttributes');
266-
}
267-
268230
const clearResolution = caches === 'resolution' || caches === 'all';
269231
const clearModule = caches === 'module' || caches === 'all';
270232

@@ -283,38 +245,40 @@ function clearCache(specifier, options) {
283245
} else {
284246
resolvedURL = resolveClearCacheURL(specifier, parentURL);
285247
if (resolvedURL) {
286-
resolvedFilename = getFilePathFromClearCacheURL(resolvedURL);
248+
resolvedFilename = getFilePathFromFileURL(resolvedURL);
287249
}
288250
}
289251
}
290252

291253
// Clear resolution caches.
292254
if (clearResolution) {
293255
// ESM has a structured resolution cache keyed by (specifier, parentURL,
294-
// importAttributes).
256+
// importAttributes). Clear all attribute variants for the given
257+
// (specifier, parentURL) pair since attributes don't affect resolution
258+
// per spec and it avoids partial-clear surprises.
295259
if (resolver === 'import') {
296260
const specifierStr = isSpecifierURL ? specifier.href : specifier;
297261
const cascadedLoader =
298262
require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
299-
cascadedLoader.deleteResolveCacheEntry(specifierStr, parentURL, importAttributes);
263+
cascadedLoader.deleteAllResolveCacheEntries(specifierStr, parentURL);
300264
}
301265

302-
// CJS has relativeResolveCache and Module._pathCache that map
303-
// specifiers to filenames. Clear all entries pointing to the resolved
304-
// file. Module._pathCache keys are not easily reconstructable so a
305-
// value-scan is required.
306-
if (resolver === 'require' && resolvedFilename) {
307-
clearCJSResolutionCaches(resolvedFilename);
308-
}
266+
// CJS resolution caches are only relevant when the resolver is 'require'.
267+
if (resolver === 'require' && parentPath) {
268+
// Delete the specific relativeResolveCache entry for this
269+
// (parent-dir, request) pair. More targeted than a full value-scan.
270+
const requestStr = isSpecifierURL ? specifier.href : specifier;
271+
deleteCJSRelativeResolveCacheEntry(path.dirname(parentPath), requestStr);
309272

310-
if (resolvedFilename) {
311273
// Clear package.json caches for the resolved module's package so that
312274
// updated exports/imports conditions are picked up on re-resolution.
313-
const { getNearestParentPackageJSON, clearPackageJSONCache } =
314-
require('internal/modules/package_json_reader');
315-
const pkg = getNearestParentPackageJSON(resolvedFilename);
316-
if (pkg?.path) {
317-
clearPackageJSONCache(pkg.path);
275+
if (resolvedFilename) {
276+
const { getNearestParentPackageJSON, clearPackageJSONCache } =
277+
require('internal/modules/package_json_reader');
278+
const pkg = getNearestParentPackageJSON(resolvedFilename);
279+
if (pkg?.path) {
280+
clearPackageJSONCache(pkg.path);
281+
}
318282
}
319283
}
320284
}

lib/internal/modules/esm/loader.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,17 @@ class ModuleLoader {
180180
return this.#resolveCache.deleteBySpecifier(specifier, parentURL, importAttributes);
181181
}
182182

183+
/**
184+
* Delete all cached resolution entries for a specifier from a parent URL,
185+
* regardless of import attributes.
186+
* @param {string} specifier
187+
* @param {string|undefined} parentURL
188+
* @returns {boolean} true if at least one entry was deleted.
189+
*/
190+
deleteAllResolveCacheEntries(specifier, parentURL) {
191+
return this.#resolveCache.deleteAllBySpecifier(specifier, parentURL);
192+
}
193+
183194
/**
184195
* Check if a cached resolution exists for a specific request.
185196
* @param {string} specifier

lib/internal/modules/esm/module_map.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const {
88
ObjectKeys,
99
ObjectPrototypeHasOwnProperty,
1010
SafeMap,
11+
StringPrototypeStartsWith,
1112
} = primordials;
1213
const { kImplicitTypeAttribute } = require('internal/modules/esm/assert');
1314
let debug = require('internal/util/debuglog').debuglog('esm', (fn) => {
@@ -109,6 +110,34 @@ class ResolveCache extends SafeMap {
109110
}
110111
return true;
111112
}
113+
114+
/**
115+
* Delete all cached resolution entries for a specifier in a parent URL,
116+
* regardless of import attributes.
117+
* @param {string} specifier
118+
* @param {string|undefined} parentURL
119+
* @returns {boolean} true if at least one entry was deleted.
120+
*/
121+
deleteAllBySpecifier(specifier, parentURL) {
122+
const entries = super.get(parentURL);
123+
if (entries == null) {
124+
return false;
125+
}
126+
// Keys are serialized as `specifier + '::' + attributes`. Match all
127+
// entries whose prefix up to and including '::' matches this specifier.
128+
const prefix = specifier + '::';
129+
let deleted = false;
130+
for (const key of ObjectKeys(entries)) {
131+
if (key === prefix || StringPrototypeStartsWith(key, prefix)) {
132+
delete entries[key];
133+
deleted = true;
134+
}
135+
}
136+
if (deleted && ObjectKeys(entries).length === 0) {
137+
super.delete(parentURL);
138+
}
139+
return deleted;
140+
}
112141
}
113142

114143
/**

lib/internal/modules/esm/translators.js

Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const {
3030
stringify,
3131
stripBOM,
3232
urlToFilename,
33+
getFilePathFromFileURL,
3334
} = require('internal/modules/helpers');
3435
const { stripTypeScriptModuleTypes } = require('internal/modules/typescript');
3536
const {
@@ -44,7 +45,7 @@ const {
4445
loadSourceForCJSWithHooks,
4546
populateCJSExportsFromESM,
4647
} = require('internal/modules/cjs/loader');
47-
const { fileURLToPath, pathToFileURL, URL, URLParse } = require('internal/url');
48+
const { fileURLToPath, pathToFileURL, URL } = require('internal/url');
4849
let debug = require('internal/util/debuglog').debuglog('esm', (fn) => {
4950
debug = fn;
5051
});
@@ -184,38 +185,14 @@ function loadCJSModule(module, source, url, filename, isMain) {
184185
// TODO: can we use a weak map instead?
185186
const cjsCache = new SafeMap();
186187

187-
/**
188-
* Resolve a file path for a file URL, stripping search/hash.
189-
* @param {string} url
190-
* @returns {string|null}
191-
*/
192-
function getFilePathFromCjsCacheURL(url) {
193-
const parsedURL = URLParse(url);
194-
if (!parsedURL) {
195-
return null;
196-
}
197-
if (parsedURL.protocol !== 'file:') {
198-
return null;
199-
}
200-
if (parsedURL.search !== '' || parsedURL.hash !== '') {
201-
parsedURL.search = '';
202-
parsedURL.hash = '';
203-
}
204-
try {
205-
return fileURLToPath(parsedURL);
206-
} catch {
207-
return null;
208-
}
209-
}
210-
211188
/**
212189
* Remove cjsCache entries for a URL and its file-path variants.
213190
* @param {string} url
214191
* @returns {boolean} true if any entries were deleted.
215192
*/
216193
function clearCjsCache(url) {
217194
let deleted = cjsCache.delete(url);
218-
const filename = getFilePathFromCjsCacheURL(url);
195+
const filename = getFilePathFromFileURL(url);
219196
if (!filename) {
220197
return deleted;
221198
}
@@ -230,7 +207,7 @@ function clearCjsCache(url) {
230207
if (cachedURL === url) {
231208
continue;
232209
}
233-
const cachedFilename = getFilePathFromCjsCacheURL(cachedURL);
210+
const cachedFilename = getFilePathFromFileURL(cachedURL);
234211
if (cachedFilename === filename) {
235212
cjsCache.delete(cachedURL);
236213
deleted = true;

0 commit comments

Comments
 (0)