Skip to content

Commit 6d79dc2

Browse files
committed
module: add clearCache for CJS and ESM
1 parent 5ff1eab commit 6d79dc2

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1834
-2
lines changed

doc/api/module.md

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,135 @@ const require = createRequire(import.meta.url);
6666
const siblingModule = require('./sibling-module');
6767
```
6868
69+
### `module.clearCache(specifier, options)`
70+
71+
<!-- YAML
72+
added: REPLACEME
73+
-->
74+
75+
> Stability: 1.0 - Early development
76+
77+
* `specifier` {string|URL} The module specifier, as it would have been passed to
78+
`import()` or `require()`.
79+
* `options` {Object}
80+
* `parentURL` {string|URL} The parent URL used to resolve the specifier. Parent identity
81+
is part of the resolution cache key. For CommonJS, pass `pathToFileURL(__filename)`.
82+
For ES modules, pass `import.meta.url`.
83+
* `resolver` {string} Specifies how resolution should be performed. Must be either
84+
`'import'` or `'require'`.
85+
* `importAttributes` {Object} Optional import attributes. Only meaningful when
86+
`resolver` is `'import'`.
87+
88+
Clears the module resolution and module caches for a module. This enables
89+
reload patterns similar to deleting from `require.cache` in CommonJS, and is useful for
90+
hot module reload.
91+
92+
The specifier is resolved using the chosen `resolver`, then the resolved module is removed
93+
from all internal caches (CommonJS `require` cache, CommonJS resolution caches, ESM resolve
94+
cache, ESM load cache, and ESM translators cache). When `resolver` is `'import'`,
95+
`importAttributes` are part of the ESM resolve-cache key, so only the exact
96+
`(specifier, parentURL, importAttributes)` resolution entry is removed. When a `file:` URL is
97+
resolved, cached module jobs for the same file path are cleared even if they differ by search
98+
or hash. This means clearing `'./mod.mjs?v=1'` will also clear `'./mod.mjs?v=2'` and any
99+
other query/hash variants that resolve to the same file.
100+
101+
When `resolver` is `'require'`, cached `package.json` data for the resolved module's package
102+
is also cleared so that updated exports/imports conditions are picked up on the next
103+
resolution.
104+
105+
Clearing a module does not clear cached entries for its dependencies. When using
106+
`resolver: 'import'`, resolution cache entries for other specifiers that resolve to the
107+
same target are not cleared — only the exact `(specifier, parentURL, importAttributes)`
108+
entry is removed. The module cache itself is cleared by resolved file path, so all
109+
specifiers pointing to the same file will see a fresh execution on next import.
110+
111+
#### Memory retention and static imports
112+
113+
`clearCache` only removes references from the Node.js **JavaScript-level** caches
114+
(the ESM load cache, resolve cache, CJS `require.cache`, and related structures).
115+
It does **not** affect V8-internal module graph references.
116+
117+
When a module M is **statically imported** by a live parent module P
118+
(i.e., via a top-level `importfrom ''` statement that has already been
119+
evaluated), V8's module instantiation creates a permanent internal strong
120+
reference from P's compiled module record to M's module record. Calling
121+
`clearCache(M)` cannot sever that link. Consequences:
122+
123+
* The old instance of M **stays alive in memory** for as long as P is alive,
124+
regardless of how many times M is cleared and re-imported.
125+
* A fresh `import(M)` after clearing will create a **separate** module instance
126+
that new importers see. P, however, continues to use the original instance —
127+
the two coexist simultaneously (sometimes called a "split-brain" state).
128+
* This is a **bounded** retention: one stale module instance per cleared module
129+
per live static parent. It does not grow unboundedly across clear/re-import
130+
cycles.
131+
132+
For **dynamically imported** modules (`await import('./M.mjs')` with no live
133+
static parent holding the result), the old `ModuleWrap` becomes eligible for
134+
garbage collection once `clearCache` removes it from Node.js caches and all
135+
JS-land references (e.g., stored namespace objects) are dropped.
136+
137+
The safest pattern for hot-reload of ES modules is to use cache-busting search
138+
parameters (so each version is a distinct module URL) and use dynamic imports for modules that need to be reloaded:
139+
140+
#### ECMA-262 spec considerations
141+
142+
Re-importing the exact same `(specifier, parentURL, importAttributes)` tuple after clearing the module cache
143+
technically violates the idempotency invariant of the ECMA-262
144+
[`HostLoadImportedModule`][] host hook, which expects that the same module request always
145+
returns the same Module Record for a given referrer. The result of violating this requirement
146+
is undefined — e.g. it can lead to crashes. For spec-compliant usage, use
147+
cache-busting search parameters so that each reload uses a distinct module request:
148+
149+
```mjs
150+
import { clearCache } from 'node:module';
151+
import { watch } from 'node:fs';
152+
153+
let version = 0;
154+
const base = new URL('./app.mjs', import.meta.url);
155+
156+
watch(base, async () => {
157+
// Clear the module cache for the previous version.
158+
clearCache(new URL(`${base.href}?v=${version}`), {
159+
parentURL: import.meta.url,
160+
resolver: 'import',
161+
});
162+
version++;
163+
// Re-import with a new search parameter — this is a distinct module request
164+
// and does not violate the ECMA-262 invariant.
165+
const mod = await import(`${base.href}?v=${version}`);
166+
console.log('reloaded:', mod);
167+
});
168+
```
169+
170+
#### Examples
171+
172+
```mjs
173+
import { clearCache } from 'node:module';
174+
175+
await import('./mod.mjs');
176+
177+
clearCache('./mod.mjs', {
178+
parentURL: import.meta.url,
179+
resolver: 'import',
180+
});
181+
await import('./mod.mjs'); // re-executes the module
182+
```
183+
184+
```cjs
185+
const { clearCache } = require('node:module');
186+
const { pathToFileURL } = require('node:url');
187+
188+
require('./mod.js');
189+
190+
clearCache('./mod.js', {
191+
parentURL: pathToFileURL(__filename),
192+
resolver: 'require',
193+
});
194+
require('./mod.js'); // eslint-disable-line node-core/no-duplicate-requires
195+
// re-executes the module
196+
```
197+
69198
### `module.findPackageJSON(specifier[, base])`
70199
71200
<!-- YAML
@@ -2040,6 +2169,7 @@ returned object contains the following keys:
20402169
[`--enable-source-maps`]: cli.md#--enable-source-maps
20412170
[`--import`]: cli.md#--importmodule
20422171
[`--require`]: cli.md#-r---require-module
2172+
[`HostLoadImportedModule`]: https://tc39.es/ecma262/#sec-HostLoadImportedModule
20432173
[`NODE_COMPILE_CACHE=dir`]: cli.md#node_compile_cachedir
20442174
[`NODE_COMPILE_CACHE_PORTABLE=1`]: cli.md#node_compile_cache_portable1
20452175
[`NODE_DISABLE_COMPILE_CACHE=1`]: cli.md#node_disable_compile_cache1

lib/internal/modules/cjs/loader.js

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ const kIsExecuting = Symbol('kIsExecuting');
111111
const kURL = Symbol('kURL');
112112
const kFormat = Symbol('kFormat');
113113

114+
const relativeResolveCache = { __proto__: null };
115+
114116
// Set first due to cycle with ESM loader functions.
115117
module.exports = {
116118
kModuleSource,
@@ -119,6 +121,7 @@ module.exports = {
119121
kModuleCircularVisited,
120122
initializeCJS,
121123
Module,
124+
clearCJSResolutionCaches,
122125
findLongestRegisteredExtension,
123126
resolveForCJSWithHooks,
124127
loadSourceForCJSWithHooks: loadSource,
@@ -223,7 +226,29 @@ let { startTimer, endTimer } = debugWithTimer('module_timer', (start, end) => {
223226
const { tracingChannel } = require('diagnostics_channel');
224227
const onRequire = getLazy(() => tracingChannel('module.require'));
225228

226-
const relativeResolveCache = { __proto__: null };
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+
}
227252

228253
let requireDepth = 0;
229254
let isPreloading = false;

0 commit comments

Comments
 (0)