Skip to content

Commit 933b744

Browse files
committed
fixup! module: add clearCache for CJS and ESM
1 parent 1410c00 commit 933b744

File tree

12 files changed

+405
-146
lines changed

12 files changed

+405
-146
lines changed

doc/api/module.md

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ const siblingModule = require('./sibling-module');
7272
added: REPLACEME
7373
-->
7474
75-
> Stability: 1.1 - Active development
75+
> Stability: 1.0 - Early development
7676
7777
* `specifier` {string|URL} The module specifier, as it would have been passed to
7878
`import()` or `require()`.
@@ -91,21 +91,61 @@ added: REPLACEME
9191
`resolver` is `'import'`.
9292
9393
Clears module resolution and/or module caches for a module. This enables
94-
reload patterns similar to deleting from `require.cache` in CommonJS, and is useful for HMR.
94+
reload patterns similar to deleting from `require.cache` in CommonJS, and is useful for
95+
hot module reload.
9596
9697
When `caches` is `'module'` or `'all'`, the specifier is resolved using the chosen `resolver`
9798
and the resolved module is removed from all internal caches (CommonJS `require` cache, ESM
9899
load cache, and ESM translators cache). When a `file:` URL is resolved, cached module jobs for
99-
the same file path are cleared even if they differ by search or hash.
100+
the same file path are cleared even if they differ by search or hash. This means clearing
101+
`'./mod.mjs?v=1'` will also clear `'./mod.mjs?v=2'` and any other query/hash variants that
102+
resolve to the same file.
100103
101104
When `caches` is `'resolution'` or `'all'` with `resolver` set to `'import'`, the ESM
102105
resolution cache entry for the given `(specifier, parentURL, importAttributes)` tuple is
103-
cleared. CJS does not maintain a separate resolution cache.
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, 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.
104112
105113
Clearing a module does not clear cached entries for its dependencies, and other specifiers
106114
that resolve to the same target may remain. Use consistent specifiers, or call `clearCache()`
107115
for each specifier you want to re-execute.
108116
117+
#### ECMA-262 spec considerations
118+
119+
Re-importing the exact same `(specifier, parentURL)` pair after clearing the module cache
120+
technically violates the idempotency invariant of the ECMA-262
121+
[`HostLoadImportedModule`][] host hook, which expects that the same module request always
122+
returns the same Module Record for a given referrer. For spec-compliant usage, use
123+
cache-busting search parameters so that each reload uses a distinct module request:
124+
125+
```mjs
126+
import { clearCache } from 'node:module';
127+
import { watch } from 'node:fs';
128+
129+
let version = 0;
130+
const base = new URL('./app.mjs', import.meta.url);
131+
132+
watch(base, async () => {
133+
// Clear the module cache for the previous version.
134+
clearCache(new URL(`${base.href}?v=${version}`), {
135+
parentURL: import.meta.url,
136+
resolver: 'import',
137+
caches: 'all',
138+
});
139+
version++;
140+
// Re-import with a new search parameter — this is a distinct module request
141+
// and does not violate the ECMA-262 invariant.
142+
const mod = await import(`${base.href}?v=${version}`);
143+
console.log('reloaded:', mod);
144+
});
145+
```
146+
147+
#### Examples
148+
109149
```mjs
110150
import { clearCache } from 'node:module';
111151

@@ -2082,6 +2122,7 @@ returned object contains the following keys:
20822122
[`--require`]: cli.md#-r---require-module
20832123
[`NODE_COMPILE_CACHE=dir`]: cli.md#node_compile_cachedir
20842124
[`NODE_COMPILE_CACHE_PORTABLE=1`]: cli.md#node_compile_cache_portable1
2125+
[`HostLoadImportedModule`]: https://tc39.es/ecma262/#sec-HostLoadImportedModule
20852126
[`NODE_DISABLE_COMPILE_CACHE=1`]: cli.md#node_disable_compile_cache1
20862127
[`NODE_V8_COVERAGE=dir`]: cli.md#node_v8_coveragedir
20872128
[`SourceMap`]: #class-modulesourcemap

lib/internal/modules/cjs/loader.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ module.exports = {
119119
kModuleCircularVisited,
120120
initializeCJS,
121121
Module,
122+
clearCJSResolutionCaches,
122123
findLongestRegisteredExtension,
123124
resolveForCJSWithHooks,
124125
loadSourceForCJSWithHooks: loadSource,
@@ -225,6 +226,30 @@ const onRequire = getLazy(() => tracingChannel('module.require'));
225226

226227
const relativeResolveCache = { __proto__: null };
227228

229+
/**
230+
* Clear all entries in the CJS relative resolve cache and _pathCache
231+
* that map to a given filename. This is needed by clearCache() to
232+
* prevent stale resolution results after a module is removed.
233+
* @param {string} filename The resolved filename to purge.
234+
*/
235+
function clearCJSResolutionCaches(filename) {
236+
// Clear from relativeResolveCache (keyed by parent.path + '\x00' + request).
237+
const relKeys = ObjectKeys(relativeResolveCache);
238+
for (let i = 0; i < relKeys.length; i++) {
239+
if (relativeResolveCache[relKeys[i]] === filename) {
240+
delete relativeResolveCache[relKeys[i]];
241+
}
242+
}
243+
244+
// Clear from Module._pathCache (keyed by request + '\x00' + paths).
245+
const pathKeys = ObjectKeys(Module._pathCache);
246+
for (let i = 0; i < pathKeys.length; i++) {
247+
if (Module._pathCache[pathKeys[i]] === filename) {
248+
delete Module._pathCache[pathKeys[i]];
249+
}
250+
}
251+
}
252+
228253
let requireDepth = 0;
229254
let isPreloading = false;
230255
let statCache = null;

lib/internal/modules/clear.js

Lines changed: 64 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ const {
1010
StringPrototypeStartsWith,
1111
} = primordials;
1212

13-
const { Module, resolveForCJSWithHooks } = require('internal/modules/cjs/loader');
13+
const { Module, resolveForCJSWithHooks, clearCJSResolutionCaches } = require('internal/modules/cjs/loader');
1414
const { fileURLToPath, isURL, URLParse, pathToFileURL } = require('internal/url');
15-
const { kEmptyObject, isWindows } = require('internal/util');
15+
const { emitExperimentalWarning, kEmptyObject, isWindows } = require('internal/util');
1616
const { validateObject, validateOneOf, validateString } = require('internal/validators');
1717
const {
1818
codes: {
@@ -87,6 +87,10 @@ function createParentModuleForClearCache(parentPath) {
8787

8888
/**
8989
* Resolve a cache filename for CommonJS.
90+
* Always goes through resolveForCJSWithHooks so that registered hooks
91+
* are respected. For file: URLs, search/hash are stripped before resolving
92+
* since CJS operates on file paths. For non-file URLs, the specifier is
93+
* passed as-is to let hooks handle it.
9094
* @param {string|URL} specifier
9195
* @param {string|undefined} parentPath
9296
* @returns {string|null}
@@ -99,44 +103,47 @@ function resolveClearCacheFilename(specifier, parentPath) {
99103
const parsedURL = getURLFromClearCacheSpecifier(specifier);
100104
let request = specifier;
101105
if (parsedURL) {
102-
if (parsedURL.protocol !== 'file:' || parsedURL.search !== '' || parsedURL.hash !== '') {
103-
return null;
106+
if (parsedURL.protocol === 'file:') {
107+
// Strip search/hash - CJS operates on file paths.
108+
if (parsedURL.search !== '' || parsedURL.hash !== '') {
109+
parsedURL.search = '';
110+
parsedURL.hash = '';
111+
}
112+
request = fileURLToPath(parsedURL);
113+
} else {
114+
// Non-file URLs (e.g. virtual://) — pass the href as-is
115+
// so that registered hooks can resolve them.
116+
request = parsedURL.href;
104117
}
105-
request = fileURLToPath(parsedURL);
106118
}
107119

108120
const parent = parentPath ? createParentModuleForClearCache(parentPath) : null;
109-
const { filename, format } = resolveForCJSWithHooks(request, parent, false, false);
110-
if (format === 'builtin') {
121+
try {
122+
const { filename, format } = resolveForCJSWithHooks(request, parent, false, false);
123+
if (format === 'builtin') {
124+
return null;
125+
}
126+
return filename;
127+
} catch {
128+
// Resolution can fail for non-file specifiers without hooks — return null
129+
// to silently skip clearing rather than throwing.
111130
return null;
112131
}
113-
return filename;
114132
}
115133

116134
/**
117135
* Resolve a cache URL for ESM.
136+
* Always goes through the loader's resolveSync so that registered hooks
137+
* (e.g. hooks that redirect specifiers) are respected.
118138
* @param {string|URL} specifier
119-
* @param {string|undefined} parentURL
139+
* @param {string} parentURL
120140
* @returns {string}
121141
*/
122142
function resolveClearCacheURL(specifier, parentURL) {
123-
const parsedURL = getURLFromClearCacheSpecifier(specifier);
124-
if (parsedURL != null) {
125-
return parsedURL.href;
126-
}
127-
128-
if (path.isAbsolute(specifier)) {
129-
return pathToFileURL(specifier).href;
130-
}
131-
132-
if (parentURL === undefined) {
133-
throw new ERR_INVALID_ARG_VALUE('options.parentURL', parentURL,
134-
'must be provided for non-URL ESM specifiers');
135-
}
136-
137143
const cascadedLoader =
138144
require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
139-
const request = { specifier, __proto__: null };
145+
const specifierStr = isURL(specifier) ? specifier.href : specifier;
146+
const request = { specifier: specifierStr, __proto__: null };
140147
return cascadedLoader.resolveSync(parentURL, request).url;
141148
}
142149

@@ -253,6 +260,8 @@ function isRelative(pathToCheck) {
253260
* }} options
254261
*/
255262
function clearCache(specifier, options) {
263+
emitExperimentalWarning('module.clearCache');
264+
256265
const isSpecifierURL = isURL(specifier);
257266
if (!isSpecifierURL) {
258267
validateString(specifier, 'specifier');
@@ -273,13 +282,13 @@ function clearCache(specifier, options) {
273282
const clearResolution = caches === 'resolution' || caches === 'all';
274283
const clearModule = caches === 'module' || caches === 'all';
275284

276-
// Resolve the specifier when module cache clearing is needed.
285+
// Resolve the specifier when module or resolution cache clearing is needed.
277286
// Must be done BEFORE clearing resolution caches since resolution
278287
// may rely on the resolve cache.
279288
let resolvedFilename = null;
280289
let resolvedURL = null;
281290

282-
if (clearModule) {
291+
if (clearModule || clearResolution) {
283292
if (resolver === 'require') {
284293
resolvedFilename = resolveClearCacheFilename(specifier, parentPath);
285294
if (resolvedFilename) {
@@ -293,13 +302,31 @@ function clearCache(specifier, options) {
293302
}
294303
}
295304

296-
// Clear resolution cache. Only ESM has a structured resolution cache;
297-
// CJS resolution results are not separately cached.
298-
if (clearResolution && resolver === 'import') {
299-
const specifierStr = isSpecifierURL ? specifier.href : specifier;
300-
const cascadedLoader =
301-
require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
302-
cascadedLoader.deleteResolveCacheEntry(specifierStr, parentURL, importAttributes);
305+
// Clear resolution caches.
306+
if (clearResolution) {
307+
// ESM has a structured resolution cache keyed by (specifier, parentURL,
308+
// importAttributes).
309+
if (resolver === 'import') {
310+
const specifierStr = isSpecifierURL ? specifier.href : specifier;
311+
const cascadedLoader =
312+
require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
313+
cascadedLoader.deleteResolveCacheEntry(specifierStr, parentURL, importAttributes);
314+
}
315+
316+
// CJS has relativeResolveCache and Module._pathCache that map
317+
// specifiers to filenames. Clear entries pointing to the resolved file.
318+
if (resolvedFilename) {
319+
clearCJSResolutionCaches(resolvedFilename);
320+
321+
// Clear package.json caches for the resolved module's package so that
322+
// updated exports/imports conditions are picked up on re-resolution.
323+
const { getNearestParentPackageJSON, clearPackageJSONCache } =
324+
require('internal/modules/package_json_reader');
325+
const pkg = getNearestParentPackageJSON(resolvedFilename);
326+
if (pkg?.path) {
327+
clearPackageJSONCache(pkg.path);
328+
}
329+
}
303330
}
304331

305332
// Clear module caches everywhere in Node.js.
@@ -311,6 +338,10 @@ function clearCache(specifier, options) {
311338
delete Module._cache[resolvedFilename];
312339
deleteModuleFromParents(cachedModule);
313340
}
341+
// Also clear CJS resolution caches that point to this filename,
342+
// even if only 'module' was requested, to avoid stale resolution
343+
// results pointing to a purged module.
344+
clearCJSResolutionCaches(resolvedFilename);
314345
}
315346

316347
// ESM load cache and translators cjsCache

lib/internal/modules/esm/loader.js

Lines changed: 9 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ const {
66
ArrayPrototypeReduce,
77
FunctionPrototypeCall,
88
JSONStringify,
9-
ObjectKeys,
109
ObjectSetPrototypeOf,
1110
Promise,
1211
PromisePrototypeThen,
@@ -31,7 +30,7 @@ const {
3130
ERR_UNKNOWN_MODULE_FORMAT,
3231
} = require('internal/errors').codes;
3332
const { getOptionValue } = require('internal/options');
34-
const { isURL, pathToFileURL, fileURLToPath, URLParse } = require('internal/url');
33+
const { isURL, pathToFileURL } = require('internal/url');
3534
const { kEmptyObject } = require('internal/util');
3635
const {
3736
compileSourceTextModule,
@@ -182,47 +181,15 @@ class ModuleLoader {
182181
}
183182

184183
/**
185-
* Delete cached resolutions that resolve to a file path.
186-
* @param {string} filename
187-
* @returns {boolean} true if any entries were deleted.
184+
* Check if a cached resolution exists for a specific request.
185+
* @param {string} specifier
186+
* @param {string|undefined} parentURL
187+
* @param {Record<string, string>} importAttributes
188+
* @returns {boolean} true if an entry exists.
188189
*/
189-
deleteResolveCacheByFilename(filename) {
190-
let deleted = false;
191-
for (const entry of this.#resolveCache) {
192-
const parentURL = entry[0];
193-
const entries = entry[1];
194-
const keys = ObjectKeys(entries);
195-
for (let i = 0; i < keys.length; i++) {
196-
const key = keys[i];
197-
const resolvedURL = entries[key]?.url;
198-
if (!resolvedURL) {
199-
continue;
200-
}
201-
const parsedURL = URLParse(resolvedURL);
202-
if (!parsedURL || parsedURL.protocol !== 'file:') {
203-
continue;
204-
}
205-
if (parsedURL.search !== '' || parsedURL.hash !== '') {
206-
parsedURL.search = '';
207-
parsedURL.hash = '';
208-
}
209-
let resolvedFilename;
210-
try {
211-
resolvedFilename = fileURLToPath(parsedURL);
212-
} catch {
213-
continue;
214-
}
215-
if (resolvedFilename === filename) {
216-
delete entries[key];
217-
deleted = true;
218-
}
219-
}
220-
221-
if (ObjectKeys(entries).length === 0) {
222-
this.#resolveCache.delete(parentURL);
223-
}
224-
}
225-
return deleted;
190+
hasResolveCacheEntry(specifier, parentURL, importAttributes = { __proto__: null }) {
191+
const serializedKey = this.#resolveCache.serializeKey(specifier, importAttributes);
192+
return this.#resolveCache.has(serializedKey, parentURL);
226193
}
227194

228195
/**

lib/internal/modules/package_json_reader.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,8 +353,32 @@ function findPackageJSON(specifier, base = 'data:') {
353353
return pkg?.path;
354354
}
355355

356+
/**
357+
* Clear all package.json caches for a given package directory.
358+
* This removes entries from:
359+
* - The C++ native package_configs_ cache (via the binding)
360+
* - The JS deserializedPackageJSONCache
361+
* - The JS moduleToParentPackageJSONCache
362+
* @param {string} packageJSONPath Absolute path to the package.json file.
363+
*/
364+
function clearPackageJSONCache(packageJSONPath) {
365+
// Clear the native C++ cache.
366+
modulesBinding.clearPackageJSONCache(packageJSONPath);
367+
368+
// Clear the JS-level deserialized cache.
369+
deserializedPackageJSONCache.delete(packageJSONPath);
370+
371+
// Clear moduleToParentPackageJSONCache entries that point to this package.json.
372+
for (const { 0: key, 1: value } of moduleToParentPackageJSONCache) {
373+
if (value === packageJSONPath) {
374+
moduleToParentPackageJSONCache.delete(key);
375+
}
376+
}
377+
}
378+
356379
module.exports = {
357380
read,
381+
clearPackageJSONCache,
358382
getNearestParentPackageJSON,
359383
getPackageScopeConfig,
360384
getPackageType,

0 commit comments

Comments
 (0)