Skip to content

Commit 7fc9694

Browse files
cursoragentanonrig
andcommitted
module: address clearCache review comments
Document required parentURL/resolver, non-absolute specifier examples, and that clearCache does not follow user-module links or recurse. Reject URL objects when resolver is require, drop linked ModuleWrap references after successful instantiation, and stop testing internals in the clearCache suite. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Yagiz Nizipli <anonrig@users.noreply.github.com>
1 parent 9eddc0d commit 7fc9694

10 files changed

Lines changed: 228 additions & 182 deletions

‎doc/api/module.md‎

Lines changed: 103 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -75,50 +75,62 @@ added: REPLACEME
7575
> Stability: 1.0 - Early development
7676
7777
* `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'`.
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'`.
8588
* `importAttributes` {Object} Optional import attributes. Only meaningful when
8689
`resolver` is `'import'`.
8790
8891
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
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
99108
other query/hash variants that resolve to the same file.
100109
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.
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.
104113
105114
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.
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.
110120
111121
#### Memory retention and static imports
112122
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.
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.
116128
117-
When a module M is **statically imported** by a live parent module P
118-
(i.e., via a top-level `import … from '…'` 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:
129+
When a module M is **statically imported** by a live parent module P (via a
130+
top-level `import … from '…'` 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:
122134
123135
* The old instance of M **stays alive in memory** for as long as P is alive,
124136
regardless of how many times M is cleared and re-imported.
@@ -130,12 +142,13 @@ reference from P's compiled module record to M's module record. Calling
130142
cycles.
131143
132144
For **dynamically imported** modules (`await import('./M.mjs')` with no live
133-
static parent holding the result), the old `ModuleWrap` becomes eligible for
145+
static parent holding the result), the old module becomes eligible for
134146
garbage collection once `clearCache` removes it from Node.js caches and all
135-
JS-land references (e.g., stored namespace objects) are dropped.
147+
JavaScript references (for example, stored namespace objects) are dropped.
136148
137149
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:
150+
parameters (so each version is a distinct module URL) and use dynamic imports
151+
for modules that need to be reloaded:
139152
140153
#### ECMA-262 spec considerations
141154
@@ -169,11 +182,14 @@ watch(base, async () => {
169182
170183
#### Examples
171184
185+
Relative specifiers are resolved against `parentURL`, not against the process
186+
working directory:
187+
172188
```mjs
173189
import { clearCache } from 'node:module';
174190

191+
// Resolves to the `mod.mjs` sibling of *this* module, then clears it.
175192
await import('./mod.mjs');
176-
177193
clearCache('./mod.mjs', {
178194
parentURL: import.meta.url,
179195
resolver: 'import',
@@ -195,6 +211,56 @@ require('./mod.js'); // eslint-disable-line node-core/no-duplicate-requires
195211
// re-executes the module
196212
```
197213
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'); // eslint-disable-line node-core/no-duplicate-requires
257+
}
258+
259+
const first = loadFresh();
260+
const second = loadFresh();
261+
// `first` and `second` are independently evaluated copies.
262+
```
263+
198264
### `module.findPackageJSON(specifier[, base])`
199265
200266
<!-- YAML
@@ -2173,6 +2239,7 @@ returned object contains the following keys:
21732239
* `columnNumber` {number} The 1-indexed columnNumber of the
21742240
corresponding call site in the original source
21752241
2242+
[ECMA-262 spec considerations]: #ecma-262-spec-considerations
21762243
[CommonJS]: modules.md
21772244
[Conditional exports]: packages.md#conditional-exports
21782245
[Customization hooks]: #customization-hooks

‎lib/internal/modules/clear.js‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const { emitExperimentalWarning, kEmptyObject, isWindows } = require('internal/u
2121
const { validateObject, validateOneOf, validateString } = require('internal/validators');
2222
const {
2323
codes: {
24+
ERR_INVALID_ARG_TYPE,
2425
ERR_INVALID_ARG_VALUE,
2526
},
2627
} = require('internal/errors');
@@ -76,26 +77,21 @@ function createParentModuleForClearCache(parentPath) {
7677
/**
7778
* Resolve a cache filename for CommonJS.
7879
* Always goes through resolveForCJSWithHooks so that registered hooks
79-
* are respected. The specifier is passed as-is: if hooks are registered,
80-
* they handle any URL interpretation; if not, it is treated as a plain
81-
* path/identifier (matching how require() interprets its argument).
82-
* @param {string|URL} specifier
80+
* are respected. The specifier is a string path/identifier, matching
81+
* how require() interprets its argument. URL objects are rejected by
82+
* clearCache() before this is called.
83+
* @param {string} specifier
8384
* @param {string|undefined} parentPath
8485
* @returns {string|null}
8586
*/
8687
function resolveClearCacheFilename(specifier, parentPath) {
87-
// Pass the specifier through as-is. When hooks are registered they
88-
// receive the raw value; without hooks CJS resolution treats it as
89-
// a plain path or bare name, consistent with how require() behaves.
90-
const request = isURL(specifier) ? specifier.href : specifier;
91-
92-
if (!parentPath && isRelative(request)) {
88+
if (!parentPath && isRelative(specifier)) {
9389
return null;
9490
}
9591

9692
const parent = parentPath ? createParentModuleForClearCache(parentPath) : null;
9793
try {
98-
const { filename, format } = resolveForCJSWithHooks(request, parent, false, false);
94+
const { filename, format } = resolveForCJSWithHooks(specifier, parent, false, false);
9995
if (format === 'builtin') {
10096
return null;
10197
}
@@ -205,6 +201,7 @@ function isRelative(pathToCheck) {
205201
/**
206202
* Clear module resolution and module caches.
207203
* @param {string|URL} specifier What would've been passed into import() or require().
204+
* When resolver is 'require', this must be a string.
208205
* @param {{
209206
* parentURL: string|URL,
210207
* importAttributes?: Record<string, string>,
@@ -225,6 +222,12 @@ function clearCache(specifier, options) {
225222
const { resolver } = options;
226223
validateOneOf(resolver, 'options.resolver', ['import', 'require']);
227224

225+
// require() does not accept URL objects. A string that happens to look like
226+
// a file: URL is still treated as a path/identifier, matching require().
227+
if (resolver === 'require' && isSpecifierURL) {
228+
throw new ERR_INVALID_ARG_TYPE('specifier', 'string', specifier);
229+
}
230+
228231
const importAttributes = options.importAttributes ?? kEmptyObject;
229232
if (options.importAttributes !== undefined) {
230233
validateObject(options.importAttributes, 'options.importAttributes');

‎lib/internal/modules/esm/loader.js‎

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -161,18 +161,6 @@ class ModuleLoader {
161161
return this.#resolveCache.deleteBySpecifier(specifier, parentURL, importAttributes);
162162
}
163163

164-
/**
165-
* Check if a cached resolution exists for a specific request.
166-
* @param {string} specifier
167-
* @param {string|undefined} parentURL
168-
* @param {Record<string, string>} importAttributes
169-
* @returns {boolean} true if an entry exists.
170-
*/
171-
hasResolveCacheEntry(specifier, parentURL, importAttributes = { __proto__: null }) {
172-
const serializedKey = this.#resolveCache.serializeKey(specifier, importAttributes);
173-
return this.#resolveCache.has(serializedKey, parentURL);
174-
}
175-
176164
/**
177165
* @see {AsyncLoaderHooks.isForAsyncLoaderHookWorker}
178166
* Shortcut to this.#asyncLoaderHooks.isForAsyncLoaderHookWorker.

‎src/module_wrap.cc‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -745,8 +745,8 @@ void ModuleWrap::Instantiate(const FunctionCallbackInfo<Value>& args) {
745745

746746
{
747747
TryCatchScope try_catch(env);
748-
USE(module->InstantiateModule(
749-
context, ResolveModuleCallback, ResolveSourceCallback));
748+
Maybe<bool> instantiated = module->InstantiateModule(
749+
context, ResolveModuleCallback, ResolveSourceCallback);
750750

751751
if (try_catch.HasCaught() && !try_catch.HasTerminated()) {
752752
CHECK(!try_catch.Message().IsEmpty());
@@ -758,7 +758,16 @@ void ModuleWrap::Instantiate(const FunctionCallbackInfo<Value>& args) {
758758
try_catch.ReThrow();
759759
return;
760760
}
761+
762+
if (instantiated.IsNothing() || !instantiated.FromJust()) {
763+
return;
764+
}
761765
}
766+
767+
// After instantiation, V8 holds the module graph. Drop the extra JS
768+
// references to imported ModuleWraps so they can be collected once they
769+
// are removed from Node.js caches.
770+
args.This()->SetInternalField(kLinkedRequestsSlot, Undefined(isolate));
762771
}
763772

764773
void ModuleWrap::Evaluate(const FunctionCallbackInfo<Value>& args) {

‎src/module_wrap.h‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,10 +216,10 @@ class ModuleWrap : public BaseObject {
216216
// nullopt value.
217217
std::optional<bool> has_async_graph_ = std::nullopt;
218218
int module_hash_;
219-
// Corresponds to the ModuleWrap* of the wrappers in kLinkedRequestsSlot.
220-
// These are populated during Link(), and are only valid after that as
221-
// convenient shortcuts, but do not hold the ModuleWraps alive. The actual
222-
// strong references come from the array in kLinkedRequestsSlot.
219+
// Corresponds to the ModuleWrap* of the wrappers in kLinkedRequestsSlot
220+
// during Link()/Instantiate(). These are convenient shortcuts and do not
221+
// hold the ModuleWraps alive. The JS array in kLinkedRequestsSlot is
222+
// cleared after successful instantiation.
223223
std::vector<ModuleWrap*> linked_module_wraps_;
224224
};
225225

0 commit comments

Comments
 (0)