Skip to content

Commit 8d8dc58

Browse files
anonrigcursoragent
authored andcommitted
module: add clearCache for CJS and ESM
Assisted-by: Cursor Authored-by: Yagiz Nizipli <yagiz@nizipli.com>
1 parent 57860ef commit 8d8dc58

46 files changed

Lines changed: 1895 additions & 8 deletions

Some content is hidden

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

doc/api/module.md

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,201 @@ 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()`. When `resolver` is `'require'`, this must be a
79+
string (the same kind of path or identifier `require()` accepts). Passing a
80+
`URL` object with `resolver: 'require'` throws `ERR_INVALID_ARG_TYPE`.
81+
* `options` {Object} Required.
82+
* `parentURL` {string|URL} Required. The parent URL used to resolve the
83+
specifier. Parent identity is part of the resolution cache key. For
84+
CommonJS, pass `pathToFileURL(__filename)`. For ES modules, pass
85+
`import.meta.url`.
86+
* `resolver` {string} Required. How resolution should be performed. Must be
87+
either `'import'` or `'require'`.
88+
* `importAttributes` {Object} Optional import attributes. Only meaningful when
89+
`resolver` is `'import'`.
90+
91+
Clears the module resolution and module caches for a module. This enables
92+
reload patterns similar to deleting from `require.cache` in CommonJS, and is
93+
useful for hot module reload.
94+
95+
Both `options.parentURL` and `options.resolver` are required. There is no
96+
recursive option: `clearCache` invalidates only the resolved module, not its
97+
dependencies. Callers that need to reload a graph must track and clear each
98+
module themselves.
99+
100+
The specifier is resolved using the chosen `resolver`, then the resolved module
101+
is removed from all Node.js internal caches (CommonJS `require` cache, CommonJS
102+
resolution caches, ESM resolve cache, ESM load cache, and ESM translators
103+
cache). When `resolver` is `'import'`, `importAttributes` are part of the ESM
104+
resolve-cache key, so only the exact `(specifier, parentURL, importAttributes)`
105+
resolution entry is removed. When a `file:` URL is resolved, cached module jobs
106+
for the same file path are cleared even if they differ by search or hash. This
107+
means clearing `'./mod.mjs?v=1'` will also clear `'./mod.mjs?v=2'` and any
108+
other query/hash variants that resolve to the same file.
109+
110+
When `resolver` is `'require'`, cached `package.json` data for the resolved
111+
module's package is also cleared so that updated exports/imports conditions are
112+
picked up on the next resolution.
113+
114+
Clearing a module does not clear cached entries for its dependencies. When using
115+
`resolver: 'import'`, resolution cache entries for other specifiers that resolve
116+
to the same target are not cleared — only the exact
117+
`(specifier, parentURL, importAttributes)` entry is removed. The module cache
118+
itself is cleared by resolved file path, so all specifiers pointing to the same
119+
file will see a fresh execution on next import.
120+
121+
#### Memory retention and static imports
122+
123+
`clearCache` only removes references from the Node.js internal caches (the ESM
124+
load cache, resolve cache, CJS `require.cache`, and related structures). It does
125+
**not** affect references created by user modules, for example through a static
126+
`import`. If one module imports another, `clearCache` will not clean up that
127+
link. It only clears references from Node.js internal caches to user modules.
128+
129+
When a module M is **statically imported** by a live parent module P (via a
130+
top-level `importfrom ''` statement that has already been evaluated), the
131+
engine keeps a permanent internal strong reference from P's compiled module
132+
record to M's module record. Calling `clearCache(M)` cannot sever that link.
133+
Consequences:
134+
135+
* The old instance of M **stays alive in memory** for as long as P is alive,
136+
regardless of how many times M is cleared and re-imported.
137+
* A fresh `import(M)` after clearing will create a **separate** module instance
138+
that new importers see. P, however, continues to use the original instance —
139+
the two coexist simultaneously (sometimes called a "split-brain" state).
140+
* This is a **bounded** retention: one stale module instance per cleared module
141+
per live static parent. It does not grow unboundedly across clear/re-import
142+
cycles.
143+
144+
For **dynamically imported** modules (`await import('./M.mjs')` with no live
145+
static parent holding the result), the old module becomes eligible for
146+
garbage collection once `clearCache` removes it from Node.js caches and all
147+
JavaScript references (for example, stored namespace objects) are dropped.
148+
149+
The safest pattern for hot-reload of ES modules is to use cache-busting search
150+
parameters (so each version is a distinct module URL) and use dynamic imports
151+
for modules that need to be reloaded:
152+
153+
#### ECMA-262 spec considerations
154+
155+
Re-importing the exact same `(specifier, parentURL, importAttributes)` tuple after clearing the module cache
156+
technically violates the idempotency invariant of the ECMA-262
157+
[`HostLoadImportedModule`][] host hook, which expects that the same module request always
158+
returns the same Module Record for a given referrer. The result of violating this requirement
159+
is undefined — e.g. it can lead to crashes. For spec-compliant usage, use
160+
cache-busting search parameters so that each reload uses a distinct module request:
161+
162+
```mjs
163+
import { clearCache } from 'node:module';
164+
import { watch } from 'node:fs';
165+
166+
let version = 0;
167+
const base = new URL('./app.mjs', import.meta.url);
168+
169+
watch(base, async () => {
170+
// Clear the module cache for the previous version.
171+
clearCache(new URL(`${base.href}?v=${version}`), {
172+
parentURL: import.meta.url,
173+
resolver: 'import',
174+
});
175+
version++;
176+
// Re-import with a new search parameter — this is a distinct module request
177+
// and does not violate the ECMA-262 invariant.
178+
const mod = await import(`${base.href}?v=${version}`);
179+
console.log('reloaded:', mod);
180+
});
181+
```
182+
183+
#### Examples
184+
185+
Relative specifiers are resolved against `parentURL`, not against the process
186+
working directory:
187+
188+
```mjs
189+
import { clearCache } from 'node:module';
190+
191+
// Resolves to the `mod.mjs` sibling of *this* module, then clears it.
192+
await import('./mod.mjs');
193+
clearCache('./mod.mjs', {
194+
parentURL: import.meta.url,
195+
resolver: 'import',
196+
});
197+
await import('./mod.mjs'); // re-executes the module
198+
```
199+
200+
```cjs
201+
const { clearCache } = require('node:module');
202+
const { pathToFileURL } = require('node:url');
203+
204+
require('./mod.js');
205+
206+
clearCache('./mod.js', {
207+
parentURL: pathToFileURL(__filename),
208+
resolver: 'require',
209+
});
210+
require('./mod.js'); // eslint-disable-line node-core/no-duplicate-requires
211+
// re-executes the module
212+
```
213+
214+
Bare specifiers are resolved the same way `import`/`require` would resolve them
215+
from `parentURL` (including `node_modules` lookup and `package.json` `"exports"`):
216+
217+
```mjs
218+
import { clearCache } from 'node:module';
219+
220+
await import('some-package');
221+
clearCache('some-package', {
222+
parentURL: import.meta.url,
223+
resolver: 'import',
224+
});
225+
await import('some-package'); // re-executes the package entry point
226+
```
227+
228+
An absolute `file:` URL still requires `parentURL` and `resolver`. The URL is
229+
the cache key; `parentURL` is used if the loader needs to resolve it again
230+
(for example, through customization hooks):
231+
232+
```mjs
233+
import { clearCache } from 'node:module';
234+
235+
const url = new URL('./mod.mjs', import.meta.url);
236+
await import(url);
237+
clearCache(url, {
238+
parentURL: import.meta.url,
239+
resolver: 'import',
240+
});
241+
await import(url); // re-executes the module
242+
```
243+
244+
Reloading a CommonJS module between tests (the ESM equivalent should use
245+
cache-busting search parameters; see [ECMA-262 spec considerations][]):
246+
247+
```cjs
248+
const { clearCache } = require('node:module');
249+
const { pathToFileURL } = require('node:url');
250+
251+
function loadFresh() {
252+
clearCache('./app.js', {
253+
parentURL: pathToFileURL(__filename),
254+
resolver: 'require',
255+
});
256+
return require('./app.js');
257+
}
258+
259+
const first = loadFresh();
260+
const second = loadFresh();
261+
// `first` and `second` are independently evaluated copies.
262+
```
263+
69264
### `module.findPackageJSON(specifier[, base])`
70265
71266
<!-- YAML
@@ -2072,6 +2267,7 @@ returned object contains the following keys:
20722267
[CommonJS]: modules.md
20732268
[Conditional exports]: packages.md#conditional-exports
20742269
[Customization hooks]: #customization-hooks
2270+
[ECMA-262 spec considerations]: #ecma-262-spec-considerations
20752271
[ES Modules]: esm.md
20762272
[Permission Model]: permissions.md#permission-model
20772273
[Source Map]: https://tc39.es/ecma426/
@@ -2082,6 +2278,7 @@ returned object contains the following keys:
20822278
[`--enable-source-maps`]: cli.md#--enable-source-maps
20832279
[`--import`]: cli.md#--importmodule
20842280
[`--require`]: cli.md#-r---require-module
2281+
[`HostLoadImportedModule`]: https://tc39.es/ecma262/#sec-HostLoadImportedModule
20852282
[`NODE_COMPILE_CACHE=dir`]: cli.md#node_compile_cachedir
20862283
[`NODE_COMPILE_CACHE_PORTABLE=1`]: cli.md#node_compile_cache_portable1
20872284
[`NODE_COMPILE_CACHE_READONLY=1`]: cli.md#node_compile_cache_readonly1

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
purgeModuleCachesForPrefix,
@@ -120,6 +122,7 @@ module.exports = {
120122
kModuleCircularVisited,
121123
initializeCJS,
122124
Module,
125+
clearCJSResolutionCaches,
123126
findLongestRegisteredExtension,
124127
resolveForCJSWithHooks,
125128
loadSourceForCJSWithHooks: loadSource,
@@ -229,7 +232,29 @@ let { startTimer, endTimer } = debugWithTimer('module_timer', (start, end) => {
229232
const { tracingChannel } = require('diagnostics_channel');
230233
const onRequire = getLazy(() => tracingChannel('module.require'));
231234

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

234259
let requireDepth = 0;
235260
let isPreloading = false;

0 commit comments

Comments
 (0)