From b7788b4b006f981e95128e3cf93503ab988e6f30 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Tue, 10 Feb 2026 13:14:09 -0800 Subject: [PATCH 01/18] module: add clearCache for CJS and ESM --- doc/api/module.md | 45 +++++ lib/internal/modules/cjs/loader.js | 196 ++++++++++++++++++++- lib/internal/modules/esm/module_map.js | 10 ++ test/es-module/test-module-clear-cache.mjs | 22 +++ test/fixtures/module-cache/cjs-counter.js | 5 + test/fixtures/module-cache/esm-counter.mjs | 4 + test/parallel/test-module-clear-cache.js | 25 +++ 7 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 test/es-module/test-module-clear-cache.mjs create mode 100644 test/fixtures/module-cache/cjs-counter.js create mode 100644 test/fixtures/module-cache/esm-counter.mjs create mode 100644 test/parallel/test-module-clear-cache.js diff --git a/doc/api/module.md b/doc/api/module.md index 81e49882def0bf..99a5658939d463 100644 --- a/doc/api/module.md +++ b/doc/api/module.md @@ -66,6 +66,51 @@ const require = createRequire(import.meta.url); const siblingModule = require('./sibling-module'); ``` +### `module.clearCache(specifier[, options])` + + + +> Stability: 1.1 - Active development + +* `specifier` {string|URL} The module specifier or URL to clear. +* `options` {Object} + * `mode` {string} Which caches to clear. Supported values are `'all'`, `'cjs'`, and `'esm'`. + **Default:** `'all'`. + * `parentURL` {string|URL} The parent URL or absolute path used to resolve non-URL specifiers. + For CommonJS, pass `__filename`. For ES modules, pass `import.meta.url`. + * `type` {string} Import attributes `type` used for ESM resolution. + * `importAttributes` {Object} Import attributes for ESM resolution. Cannot be used with `type`. +* Returns: {Object} An object with `{ cjs: boolean, esm: boolean }` indicating whether entries + were removed from each cache. + +Clears the CommonJS `require` cache and/or the ESM module cache for a module. This enables +reload patterns similar to deleting from `require.cache` in CommonJS, and is useful for HMR. +When `mode` is `'all'`, resolution failures for one module system do not throw; check the +returned flags to see what was cleared. + +```mjs +import { clearCache } from 'node:module'; + +const url = new URL('./mod.mjs', import.meta.url); +await import(url.href); + +clearCache(url); +await import(url.href); // re-executes the module +``` + +```cjs +const { clearCache } = require('node:module'); +const path = require('node:path'); + +const file = path.join(__dirname, 'mod.js'); +require(file); + +clearCache(file); +require(file); // re-executes the module +``` + ### `module.findPackageJSON(specifier[, base])` -> Stability: 1.1 - Active development +> Stability: 1.0 - Early development * `specifier` {string|URL} The module specifier, as it would have been passed to `import()` or `require()`. @@ -91,21 +91,61 @@ added: REPLACEME `resolver` is `'import'`. Clears module resolution and/or module caches for a module. This enables -reload patterns similar to deleting from `require.cache` in CommonJS, and is useful for HMR. +reload patterns similar to deleting from `require.cache` in CommonJS, and is useful for +hot module reload. When `caches` is `'module'` or `'all'`, the specifier is resolved using the chosen `resolver` and the resolved module is removed from all internal caches (CommonJS `require` cache, ESM load cache, and ESM translators cache). When a `file:` URL is resolved, cached module jobs for -the same file path are cleared even if they differ by search or hash. +the same file path are cleared even if they differ by search or hash. This means clearing +`'./mod.mjs?v=1'` will also clear `'./mod.mjs?v=2'` and any other query/hash variants that +resolve to the same file. When `caches` is `'resolution'` or `'all'` with `resolver` set to `'import'`, the ESM resolution cache entry for the given `(specifier, parentURL, importAttributes)` tuple is -cleared. CJS does not maintain a separate resolution cache. +cleared. When `resolver` is `'require'`, internal CJS resolution caches (including the +relative resolve cache and path cache) are also cleared for the resolved filename. +When `importAttributes` are provided, they are used to construct the cache key; if a module +was loaded with multiple different import attribute combinations, only the matching entry +is cleared from the resolution cache. The module cache itself (`caches: 'module'`) clears +all attribute variants for the URL. Clearing a module does not clear cached entries for its dependencies, and other specifiers that resolve to the same target may remain. Use consistent specifiers, or call `clearCache()` for each specifier you want to re-execute. +#### ECMA-262 spec considerations + +Re-importing the exact same `(specifier, parentURL)` pair after clearing the module cache +technically violates the idempotency invariant of the ECMA-262 +[`HostLoadImportedModule`][] host hook, which expects that the same module request always +returns the same Module Record for a given referrer. For spec-compliant usage, use +cache-busting search parameters so that each reload uses a distinct module request: + +```mjs +import { clearCache } from 'node:module'; +import { watch } from 'node:fs'; + +let version = 0; +const base = new URL('./app.mjs', import.meta.url); + +watch(base, async () => { + // Clear the module cache for the previous version. + clearCache(new URL(`${base.href}?v=${version}`), { + parentURL: import.meta.url, + resolver: 'import', + caches: 'all', + }); + version++; + // Re-import with a new search parameter — this is a distinct module request + // and does not violate the ECMA-262 invariant. + const mod = await import(`${base.href}?v=${version}`); + console.log('reloaded:', mod); +}); +``` + +#### Examples + ```mjs import { clearCache } from 'node:module'; @@ -2082,6 +2122,7 @@ returned object contains the following keys: [`--require`]: cli.md#-r---require-module [`NODE_COMPILE_CACHE=dir`]: cli.md#node_compile_cachedir [`NODE_COMPILE_CACHE_PORTABLE=1`]: cli.md#node_compile_cache_portable1 +[`HostLoadImportedModule`]: https://tc39.es/ecma262/#sec-HostLoadImportedModule [`NODE_DISABLE_COMPILE_CACHE=1`]: cli.md#node_disable_compile_cache1 [`NODE_V8_COVERAGE=dir`]: cli.md#node_v8_coveragedir [`SourceMap`]: #class-modulesourcemap diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index a320736d1b6fd7..145dad75f37bbc 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -119,6 +119,7 @@ module.exports = { kModuleCircularVisited, initializeCJS, Module, + clearCJSResolutionCaches, findLongestRegisteredExtension, resolveForCJSWithHooks, loadSourceForCJSWithHooks: loadSource, @@ -225,6 +226,30 @@ const onRequire = getLazy(() => tracingChannel('module.require')); const relativeResolveCache = { __proto__: null }; +/** + * Clear all entries in the CJS relative resolve cache and _pathCache + * that map to a given filename. This is needed by clearCache() to + * prevent stale resolution results after a module is removed. + * @param {string} filename The resolved filename to purge. + */ +function clearCJSResolutionCaches(filename) { + // Clear from relativeResolveCache (keyed by parent.path + '\x00' + request). + const relKeys = ObjectKeys(relativeResolveCache); + for (let i = 0; i < relKeys.length; i++) { + if (relativeResolveCache[relKeys[i]] === filename) { + delete relativeResolveCache[relKeys[i]]; + } + } + + // Clear from Module._pathCache (keyed by request + '\x00' + paths). + const pathKeys = ObjectKeys(Module._pathCache); + for (let i = 0; i < pathKeys.length; i++) { + if (Module._pathCache[pathKeys[i]] === filename) { + delete Module._pathCache[pathKeys[i]]; + } + } +} + let requireDepth = 0; let isPreloading = false; let statCache = null; diff --git a/lib/internal/modules/clear.js b/lib/internal/modules/clear.js index c22b3c37bd98ca..e7c2a5388960b3 100644 --- a/lib/internal/modules/clear.js +++ b/lib/internal/modules/clear.js @@ -10,9 +10,9 @@ const { StringPrototypeStartsWith, } = primordials; -const { Module, resolveForCJSWithHooks } = require('internal/modules/cjs/loader'); +const { Module, resolveForCJSWithHooks, clearCJSResolutionCaches } = require('internal/modules/cjs/loader'); const { fileURLToPath, isURL, URLParse, pathToFileURL } = require('internal/url'); -const { kEmptyObject, isWindows } = require('internal/util'); +const { emitExperimentalWarning, kEmptyObject, isWindows } = require('internal/util'); const { validateObject, validateOneOf, validateString } = require('internal/validators'); const { codes: { @@ -87,6 +87,10 @@ function createParentModuleForClearCache(parentPath) { /** * Resolve a cache filename for CommonJS. + * Always goes through resolveForCJSWithHooks so that registered hooks + * are respected. For file: URLs, search/hash are stripped before resolving + * since CJS operates on file paths. For non-file URLs, the specifier is + * passed as-is to let hooks handle it. * @param {string|URL} specifier * @param {string|undefined} parentPath * @returns {string|null} @@ -99,44 +103,47 @@ function resolveClearCacheFilename(specifier, parentPath) { const parsedURL = getURLFromClearCacheSpecifier(specifier); let request = specifier; if (parsedURL) { - if (parsedURL.protocol !== 'file:' || parsedURL.search !== '' || parsedURL.hash !== '') { - return null; + if (parsedURL.protocol === 'file:') { + // Strip search/hash - CJS operates on file paths. + if (parsedURL.search !== '' || parsedURL.hash !== '') { + parsedURL.search = ''; + parsedURL.hash = ''; + } + request = fileURLToPath(parsedURL); + } else { + // Non-file URLs (e.g. virtual://) — pass the href as-is + // so that registered hooks can resolve them. + request = parsedURL.href; } - request = fileURLToPath(parsedURL); } const parent = parentPath ? createParentModuleForClearCache(parentPath) : null; - const { filename, format } = resolveForCJSWithHooks(request, parent, false, false); - if (format === 'builtin') { + try { + const { filename, format } = resolveForCJSWithHooks(request, parent, false, false); + if (format === 'builtin') { + return null; + } + return filename; + } catch { + // Resolution can fail for non-file specifiers without hooks — return null + // to silently skip clearing rather than throwing. return null; } - return filename; } /** * Resolve a cache URL for ESM. + * Always goes through the loader's resolveSync so that registered hooks + * (e.g. hooks that redirect specifiers) are respected. * @param {string|URL} specifier - * @param {string|undefined} parentURL + * @param {string} parentURL * @returns {string} */ function resolveClearCacheURL(specifier, parentURL) { - const parsedURL = getURLFromClearCacheSpecifier(specifier); - if (parsedURL != null) { - return parsedURL.href; - } - - if (path.isAbsolute(specifier)) { - return pathToFileURL(specifier).href; - } - - if (parentURL === undefined) { - throw new ERR_INVALID_ARG_VALUE('options.parentURL', parentURL, - 'must be provided for non-URL ESM specifiers'); - } - const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader(); - const request = { specifier, __proto__: null }; + const specifierStr = isURL(specifier) ? specifier.href : specifier; + const request = { specifier: specifierStr, __proto__: null }; return cascadedLoader.resolveSync(parentURL, request).url; } @@ -253,6 +260,8 @@ function isRelative(pathToCheck) { * }} options */ function clearCache(specifier, options) { + emitExperimentalWarning('module.clearCache'); + const isSpecifierURL = isURL(specifier); if (!isSpecifierURL) { validateString(specifier, 'specifier'); @@ -273,13 +282,13 @@ function clearCache(specifier, options) { const clearResolution = caches === 'resolution' || caches === 'all'; const clearModule = caches === 'module' || caches === 'all'; - // Resolve the specifier when module cache clearing is needed. + // Resolve the specifier when module or resolution cache clearing is needed. // Must be done BEFORE clearing resolution caches since resolution // may rely on the resolve cache. let resolvedFilename = null; let resolvedURL = null; - if (clearModule) { + if (clearModule || clearResolution) { if (resolver === 'require') { resolvedFilename = resolveClearCacheFilename(specifier, parentPath); if (resolvedFilename) { @@ -293,13 +302,31 @@ function clearCache(specifier, options) { } } - // Clear resolution cache. Only ESM has a structured resolution cache; - // CJS resolution results are not separately cached. - if (clearResolution && resolver === 'import') { - const specifierStr = isSpecifierURL ? specifier.href : specifier; - const cascadedLoader = - require('internal/modules/esm/loader').getOrInitializeCascadedLoader(); - cascadedLoader.deleteResolveCacheEntry(specifierStr, parentURL, importAttributes); + // Clear resolution caches. + if (clearResolution) { + // ESM has a structured resolution cache keyed by (specifier, parentURL, + // importAttributes). + if (resolver === 'import') { + const specifierStr = isSpecifierURL ? specifier.href : specifier; + const cascadedLoader = + require('internal/modules/esm/loader').getOrInitializeCascadedLoader(); + cascadedLoader.deleteResolveCacheEntry(specifierStr, parentURL, importAttributes); + } + + // CJS has relativeResolveCache and Module._pathCache that map + // specifiers to filenames. Clear entries pointing to the resolved file. + if (resolvedFilename) { + clearCJSResolutionCaches(resolvedFilename); + + // Clear package.json caches for the resolved module's package so that + // updated exports/imports conditions are picked up on re-resolution. + const { getNearestParentPackageJSON, clearPackageJSONCache } = + require('internal/modules/package_json_reader'); + const pkg = getNearestParentPackageJSON(resolvedFilename); + if (pkg?.path) { + clearPackageJSONCache(pkg.path); + } + } } // Clear module caches everywhere in Node.js. @@ -311,6 +338,10 @@ function clearCache(specifier, options) { delete Module._cache[resolvedFilename]; deleteModuleFromParents(cachedModule); } + // Also clear CJS resolution caches that point to this filename, + // even if only 'module' was requested, to avoid stale resolution + // results pointing to a purged module. + clearCJSResolutionCaches(resolvedFilename); } // ESM load cache and translators cjsCache diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js index 953a80f801f489..7f3421bb6319e3 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -6,7 +6,6 @@ const { ArrayPrototypeReduce, FunctionPrototypeCall, JSONStringify, - ObjectKeys, ObjectSetPrototypeOf, Promise, PromisePrototypeThen, @@ -31,7 +30,7 @@ const { ERR_UNKNOWN_MODULE_FORMAT, } = require('internal/errors').codes; const { getOptionValue } = require('internal/options'); -const { isURL, pathToFileURL, fileURLToPath, URLParse } = require('internal/url'); +const { isURL, pathToFileURL } = require('internal/url'); const { kEmptyObject } = require('internal/util'); const { compileSourceTextModule, @@ -182,47 +181,15 @@ class ModuleLoader { } /** - * Delete cached resolutions that resolve to a file path. - * @param {string} filename - * @returns {boolean} true if any entries were deleted. + * Check if a cached resolution exists for a specific request. + * @param {string} specifier + * @param {string|undefined} parentURL + * @param {Record} importAttributes + * @returns {boolean} true if an entry exists. */ - deleteResolveCacheByFilename(filename) { - let deleted = false; - for (const entry of this.#resolveCache) { - const parentURL = entry[0]; - const entries = entry[1]; - const keys = ObjectKeys(entries); - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; - const resolvedURL = entries[key]?.url; - if (!resolvedURL) { - continue; - } - const parsedURL = URLParse(resolvedURL); - if (!parsedURL || parsedURL.protocol !== 'file:') { - continue; - } - if (parsedURL.search !== '' || parsedURL.hash !== '') { - parsedURL.search = ''; - parsedURL.hash = ''; - } - let resolvedFilename; - try { - resolvedFilename = fileURLToPath(parsedURL); - } catch { - continue; - } - if (resolvedFilename === filename) { - delete entries[key]; - deleted = true; - } - } - - if (ObjectKeys(entries).length === 0) { - this.#resolveCache.delete(parentURL); - } - } - return deleted; + hasResolveCacheEntry(specifier, parentURL, importAttributes = { __proto__: null }) { + const serializedKey = this.#resolveCache.serializeKey(specifier, importAttributes); + return this.#resolveCache.has(serializedKey, parentURL); } /** diff --git a/lib/internal/modules/package_json_reader.js b/lib/internal/modules/package_json_reader.js index 6c6bf0383bc338..a0af8b73362e22 100644 --- a/lib/internal/modules/package_json_reader.js +++ b/lib/internal/modules/package_json_reader.js @@ -353,8 +353,32 @@ function findPackageJSON(specifier, base = 'data:') { return pkg?.path; } +/** + * Clear all package.json caches for a given package directory. + * This removes entries from: + * - The C++ native package_configs_ cache (via the binding) + * - The JS deserializedPackageJSONCache + * - The JS moduleToParentPackageJSONCache + * @param {string} packageJSONPath Absolute path to the package.json file. + */ +function clearPackageJSONCache(packageJSONPath) { + // Clear the native C++ cache. + modulesBinding.clearPackageJSONCache(packageJSONPath); + + // Clear the JS-level deserialized cache. + deserializedPackageJSONCache.delete(packageJSONPath); + + // Clear moduleToParentPackageJSONCache entries that point to this package.json. + for (const { 0: key, 1: value } of moduleToParentPackageJSONCache) { + if (value === packageJSONPath) { + moduleToParentPackageJSONCache.delete(key); + } + } +} + module.exports = { read, + clearPackageJSONCache, getNearestParentPackageJSON, getPackageScopeConfig, getPackageType, diff --git a/src/node_modules.cc b/src/node_modules.cc index 7d8e24f915be95..0589e84170fb44 100644 --- a/src/node_modules.cc +++ b/src/node_modules.cc @@ -598,6 +598,26 @@ void SaveCompileCacheEntry(const FunctionCallbackInfo& args) { env->compile_cache_handler()->MaybeSave(cache_entry, utf8.ToStringView()); } +void BindingData::ClearPackageJSONCache( + const FunctionCallbackInfo& args) { + CHECK_GE(args.Length(), 1); + CHECK(args[0]->IsString()); + + Realm* realm = Realm::GetCurrent(args); + auto binding_data = realm->GetBindingData(); + + BufferValue path(realm->isolate(), args[0]); + ToNamespacedPath(realm->env(), &path); + + auto it = binding_data->package_configs_.find(path.ToString()); + if (it != binding_data->package_configs_.end()) { + binding_data->package_configs_.erase(it); + args.GetReturnValue().Set(true); + } else { + args.GetReturnValue().Set(false); + } +} + void BindingData::CreatePerIsolateProperties(IsolateData* isolate_data, Local target) { Isolate* isolate = isolate_data->isolate(); @@ -618,6 +638,7 @@ void BindingData::CreatePerIsolateProperties(IsolateData* isolate_data, SetMethod(isolate, target, "flushCompileCache", FlushCompileCache); SetMethod(isolate, target, "getCompileCacheEntry", GetCompileCacheEntry); SetMethod(isolate, target, "saveCompileCacheEntry", SaveCompileCacheEntry); + SetMethod(isolate, target, "clearPackageJSONCache", ClearPackageJSONCache); } void BindingData::CreatePerContextProperties(Local target, @@ -689,6 +710,7 @@ void BindingData::RegisterExternalReferences( registry->Register(FlushCompileCache); registry->Register(GetCompileCacheEntry); registry->Register(SaveCompileCacheEntry); + registry->Register(ClearPackageJSONCache); } } // namespace modules diff --git a/src/node_modules.h b/src/node_modules.h index d610306a3a3111..54988f5f337147 100644 --- a/src/node_modules.h +++ b/src/node_modules.h @@ -64,6 +64,8 @@ class BindingData : public SnapshotableObject { const v8::FunctionCallbackInfo& args); static void GetPackageJSONScripts( const v8::FunctionCallbackInfo& args); + static void ClearPackageJSONCache( + const v8::FunctionCallbackInfo& args); static void CreatePerIsolateProperties(IsolateData* isolate_data, v8::Local ctor); diff --git a/test/module-hooks/test-module-hooks-clear-cache-redirect.js b/test/module-hooks/test-module-hooks-clear-cache-redirect.js new file mode 100644 index 00000000000000..539e48a2d203b3 --- /dev/null +++ b/test/module-hooks/test-module-hooks-clear-cache-redirect.js @@ -0,0 +1,56 @@ +'use strict'; + +// Tests that clearCache with resolver: 'import' respects registered hooks +// that redirect specifiers. When a hook redirects specifier A to specifier B, +// clearCache(A) should clear the cache for B (the redirected target). + +const common = require('../common'); + +const assert = require('node:assert'); +const { pathToFileURL } = require('node:url'); +const { clearCache, registerHooks } = require('node:module'); + +const hook = registerHooks({ + resolve(specifier, context, nextResolve) { + // Redirect 'redirected-esm' to a virtual URL. + if (specifier === 'redirected-esm') { + return { + url: 'virtual://redirected-target', + format: 'module', + shortCircuit: true, + }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === 'virtual://redirected-target') { + return { + format: 'module', + source: 'globalThis.__module_cache_redirect_counter = ' + + '(globalThis.__module_cache_redirect_counter ?? 0) + 1;\n' + + 'export const count = globalThis.__module_cache_redirect_counter;\n', + shortCircuit: true, + }; + } + return nextLoad(url, context); + }, +}); + +(async () => { + const first = await import('redirected-esm'); + assert.strictEqual(first.count, 1); + + // Clear using the original specifier — hooks should resolve it + // to the redirected target and clear that cache. + clearCache('redirected-esm', { + parentURL: pathToFileURL(__filename), + resolver: 'import', + caches: 'all', + }); + + const second = await import('redirected-esm'); + assert.strictEqual(second.count, 2); + + hook.deregister(); + delete globalThis.__module_cache_redirect_counter; +})().then(common.mustCall()); diff --git a/test/module-hooks/test-module-hooks-clear-cache-resolve-cache.js b/test/module-hooks/test-module-hooks-clear-cache-resolve-cache.js index 85aa6f6cbbdb0b..139105bf9abfaf 100644 --- a/test/module-hooks/test-module-hooks-clear-cache-resolve-cache.js +++ b/test/module-hooks/test-module-hooks-clear-cache-resolve-cache.js @@ -1,86 +1,76 @@ // Flags: --expose-internals +// Tests that caches: 'module' does NOT clear the resolve cache, +// while caches: 'resolution' and caches: 'all' DO clear it. +// Uses the exposed hasResolveCacheEntry method instead of monkey-patching. 'use strict'; const common = require('../common'); const assert = require('node:assert'); +const path = require('node:path'); const { pathToFileURL } = require('node:url'); -const { clearCache, registerHooks } = require('node:module'); +const { clearCache } = require('node:module'); const { getOrInitializeCascadedLoader } = require('internal/modules/esm/loader'); -let loadCalls = 0; -const hook = registerHooks({ - resolve(specifier, context, nextResolve) { - if (specifier === 'virtual') { - return { - url: 'virtual://cache-clear-resolve', - format: 'module', - shortCircuit: true, - }; - } - return nextResolve(specifier, context); - }, - load(url, context, nextLoad) { - if (url === 'virtual://cache-clear-resolve') { - loadCalls++; - return { - format: 'module', - source: 'export const count = ' + - '(globalThis.__module_cache_virtual_counter ?? 0) + 1;\n' + - 'globalThis.__module_cache_virtual_counter = count;\n', - shortCircuit: true, - }; - } - return nextLoad(url, context); - }, -}); +// Use a real file-based specifier so the resolve cache is populated +// by the default resolver (the resolve cache is used for non-hook paths). +const fixture = path.join(__dirname, '..', 'fixtures', 'module-cache', 'esm-counter.mjs'); +const specifier = pathToFileURL(fixture).href; +const parentURL = pathToFileURL(__filename).href; (async () => { - const first = await import('virtual'); + const cascadedLoader = getOrInitializeCascadedLoader(); + + // --- Test 1: caches: 'module' should NOT clear the resolve cache --- + const first = await import(specifier); assert.strictEqual(first.count, 1); - assert.strictEqual(loadCalls, 1); - const loadCallsAfterFirst = loadCalls; - const cascadedLoader = getOrInitializeCascadedLoader(); - let deleteResolveCalls = 0; - const originalDeleteResolveCacheEntry = cascadedLoader.deleteResolveCacheEntry; - cascadedLoader.deleteResolveCacheEntry = function(...args) { - deleteResolveCalls++; - return originalDeleteResolveCacheEntry.apply(this, args); - }; + // After import, the resolve cache should have an entry. + assert.ok(cascadedLoader.hasResolveCacheEntry(specifier, parentURL), + 'resolve cache should have an entry after import'); + + // caches: 'module' should NOT clear the resolve cache entry. + clearCache(specifier, { + parentURL, + resolver: 'import', + caches: 'module', + }); + assert.ok(cascadedLoader.hasResolveCacheEntry(specifier, parentURL), + 'resolve cache should still have entry after caches: "module"'); + + // Re-import to repopulate the load cache (since 'module' cleared it). + const afterModuleClear = await import(specifier); + assert.strictEqual(afterModuleClear.count, 2); + + // --- Test 2: caches: 'resolution' SHOULD clear the resolve cache, + // but NOT re-evaluate the module (load cache still holds it) --- + clearCache(specifier, { + parentURL, + resolver: 'import', + caches: 'resolution', + }); + assert.ok(!cascadedLoader.hasResolveCacheEntry(specifier, parentURL), + 'resolve cache should be cleared after caches: "resolution"'); - try { - // caches: 'module' should NOT touch the resolve cache. - clearCache('virtual', { - parentURL: pathToFileURL(__filename), - resolver: 'import', - caches: 'module', - }); - assert.strictEqual(deleteResolveCalls, 0); + // Re-import: module should NOT be re-evaluated — load cache still holds it. + const afterResClear = await import(specifier); + assert.strictEqual(afterResClear.count, 2); - // caches: 'resolution' SHOULD clear the resolve cache entry. - clearCache('virtual', { - parentURL: pathToFileURL(__filename), - resolver: 'import', - caches: 'resolution', - }); - assert.strictEqual(deleteResolveCalls, 1); + // --- Test 3: caches: 'all' SHOULD clear both resolve and load caches --- + // Repopulate the resolve cache first. + await import(specifier); - // caches: 'all' SHOULD also clear the resolve cache entry. - clearCache('virtual', { - parentURL: pathToFileURL(__filename), - resolver: 'import', - caches: 'all', - }); - assert.strictEqual(deleteResolveCalls, 2); - } finally { - cascadedLoader.deleteResolveCacheEntry = originalDeleteResolveCacheEntry; - } + clearCache(specifier, { + parentURL, + resolver: 'import', + caches: 'all', + }); + assert.ok(!cascadedLoader.hasResolveCacheEntry(specifier, parentURL), + 'resolve cache should be cleared after caches: "all"'); - const second = await import('virtual'); - assert.strictEqual(second.count, 2); - assert.strictEqual(loadCalls, loadCallsAfterFirst + 1); + // After 'all', re-import should re-evaluate. + const afterAllClear = await import(specifier); + assert.strictEqual(afterAllClear.count, 3); - hook.deregister(); - delete globalThis.__module_cache_virtual_counter; + delete globalThis.__module_cache_esm_counter; })().then(common.mustCall()); diff --git a/test/parallel/test-module-clear-cache-cjs-resolution.js b/test/parallel/test-module-clear-cache-cjs-resolution.js new file mode 100644 index 00000000000000..216086c804e5ec --- /dev/null +++ b/test/parallel/test-module-clear-cache-cjs-resolution.js @@ -0,0 +1,45 @@ +// Flags: --expose-internals +// Tests that clearCache with caches: 'resolution' or 'all' also clears +// the CJS relativeResolveCache and Module._pathCache entries. +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const Module = require('node:module'); +const { clearCache } = require('node:module'); + +const fixture = path.join(__dirname, '..', 'fixtures', 'module-cache', 'cjs-counter.js'); + +// Load via require to populate relativeResolveCache and _pathCache. +require(fixture); +assert.notStrictEqual(Module._cache[fixture], undefined); + +// Module._pathCache should have an entry pointing to this fixture. +const pathCacheKeys = Object.keys(Module._pathCache); +const hasPathCacheEntry = pathCacheKeys.some( + (key) => Module._pathCache[key] === fixture, +); +assert.ok(hasPathCacheEntry, 'Module._pathCache should contain the fixture'); + +// Clear only resolution caches. +clearCache(fixture, { + parentURL: pathToFileURL(__filename), + resolver: 'require', + caches: 'resolution', +}); + +// Module._cache should still be present (we only cleared resolution). +assert.notStrictEqual(Module._cache[fixture], undefined); + +// But _pathCache entries for this filename should be cleared. +const pathCacheKeysAfter = Object.keys(Module._pathCache); +const hasPathCacheEntryAfter = pathCacheKeysAfter.some( + (key) => Module._pathCache[key] === fixture, +); +assert.ok(!hasPathCacheEntryAfter, + 'Module._pathCache should not contain the fixture after clearing resolution'); + +delete globalThis.__module_cache_cjs_counter; diff --git a/test/parallel/test-module-clear-cache-pkgjson-exports.js b/test/parallel/test-module-clear-cache-pkgjson-exports.js new file mode 100644 index 00000000000000..df115cb7f5f35a --- /dev/null +++ b/test/parallel/test-module-clear-cache-pkgjson-exports.js @@ -0,0 +1,55 @@ +// Tests that after updating package.json exports to point to a different file, +// clearCache with caches: 'all' causes re-resolution to pick up the new export. +'use strict'; + +require('../common'); +const tmpdir = require('../common/tmpdir'); + +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const { clearCache, createRequire } = require('node:module'); + +tmpdir.refresh(); + +// Create a temporary package with two entry points. +const pkgDir = path.join(tmpdir.path, 'node_modules', 'test-exports-pkg'); +fs.mkdirSync(pkgDir, { recursive: true }); + +fs.writeFileSync(path.join(pkgDir, 'entry-a.js'), + 'module.exports = "a";\n'); +fs.writeFileSync(path.join(pkgDir, 'entry-b.js'), + 'module.exports = "b";\n'); + +// Initial package.json: exports points to entry-a.js. +fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ + name: 'test-exports-pkg', + exports: './entry-a.js', +})); + +// Create a require function rooted in tmpdir so it finds node_modules there. +const parentFile = path.join(tmpdir.path, 'parent.js'); +fs.writeFileSync(parentFile, ''); +const localRequire = createRequire(parentFile); + +// First require — should resolve to entry-a. +const resultA = localRequire('test-exports-pkg'); +assert.strictEqual(resultA, 'a'); + +// Update the package.json to point exports to entry-b.js. +fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ + name: 'test-exports-pkg', + exports: './entry-b.js', +})); + +// Clear all caches for the package. +clearCache('test-exports-pkg', { + parentURL: pathToFileURL(parentFile), + resolver: 'require', + caches: 'all', +}); + +// Second require — should now resolve to entry-b. +const resultB = localRequire('test-exports-pkg'); +assert.strictEqual(resultB, 'b'); diff --git a/typings/internalBinding/modules.d.ts b/typings/internalBinding/modules.d.ts index 0b1d0e2938319f..7e48ebf8687df9 100644 --- a/typings/internalBinding/modules.d.ts +++ b/typings/internalBinding/modules.d.ts @@ -29,4 +29,5 @@ export interface ModulesBinding { enableCompileCache(path?: string): { status: number, message?: string, directory?: string } getCompileCacheDir(): string | undefined flushCompileCache(keepDeserializedCache?: boolean): void + clearPackageJSONCache(path: string): boolean } From 0b472a6fa688cf642278c88ae94366e9caeb96ba Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Mon, 2 Mar 2026 15:31:19 -0500 Subject: [PATCH 13/18] fixup! module: add clearCache for CJS and ESM --- doc/api/module.md | 2 +- lib/internal/modules/clear.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/api/module.md b/doc/api/module.md index 18d48ccc32f4cb..5765b9e3a9adcc 100644 --- a/doc/api/module.md +++ b/doc/api/module.md @@ -2120,9 +2120,9 @@ returned object contains the following keys: [`--enable-source-maps`]: cli.md#--enable-source-maps [`--import`]: cli.md#--importmodule [`--require`]: cli.md#-r---require-module +[`HostLoadImportedModule`]: https://tc39.es/ecma262/#sec-HostLoadImportedModule [`NODE_COMPILE_CACHE=dir`]: cli.md#node_compile_cachedir [`NODE_COMPILE_CACHE_PORTABLE=1`]: cli.md#node_compile_cache_portable1 -[`HostLoadImportedModule`]: https://tc39.es/ecma262/#sec-HostLoadImportedModule [`NODE_DISABLE_COMPILE_CACHE=1`]: cli.md#node_disable_compile_cache1 [`NODE_V8_COVERAGE=dir`]: cli.md#node_v8_coveragedir [`SourceMap`]: #class-modulesourcemap diff --git a/lib/internal/modules/clear.js b/lib/internal/modules/clear.js index e7c2a5388960b3..35235d7f3ea2d5 100644 --- a/lib/internal/modules/clear.js +++ b/lib/internal/modules/clear.js @@ -111,7 +111,7 @@ function resolveClearCacheFilename(specifier, parentPath) { } request = fileURLToPath(parsedURL); } else { - // Non-file URLs (e.g. virtual://) — pass the href as-is + // Non-file URLs (e.g. virtual://) - pass the href as-is // so that registered hooks can resolve them. request = parsedURL.href; } @@ -125,7 +125,7 @@ function resolveClearCacheFilename(specifier, parentPath) { } return filename; } catch { - // Resolution can fail for non-file specifiers without hooks — return null + // Resolution can fail for non-file specifiers without hooks - return null // to silently skip clearing rather than throwing. return null; } From ddde84eb4647aad6955e896d0e9cba791aee2737 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 20 Mar 2026 10:19:06 -0400 Subject: [PATCH 14/18] fixup! module: add clearCache for CJS and ESM --- doc/api/module.md | 31 ++++++++-------- lib/internal/modules/cjs/loader.js | 5 ++- lib/internal/modules/clear.js | 59 ++++++++---------------------- 3 files changed, 34 insertions(+), 61 deletions(-) diff --git a/doc/api/module.md b/doc/api/module.md index 5765b9e3a9adcc..e084b6fe45d49d 100644 --- a/doc/api/module.md +++ b/doc/api/module.md @@ -105,18 +105,20 @@ When `caches` is `'resolution'` or `'all'` with `resolver` set to `'import'`, th resolution cache entry for the given `(specifier, parentURL, importAttributes)` tuple is cleared. When `resolver` is `'require'`, internal CJS resolution caches (including the relative resolve cache and path cache) are also cleared for the resolved filename. -When `importAttributes` are provided, they are used to construct the cache key; if a module +When `importAttributes` are provided for `'import'` resolution, they are used to construct the cache key; if a module was loaded with multiple different import attribute combinations, only the matching entry is cleared from the resolution cache. The module cache itself (`caches: 'module'`) clears all attribute variants for the URL. -Clearing a module does not clear cached entries for its dependencies, and other specifiers -that resolve to the same target may remain. Use consistent specifiers, or call `clearCache()` -for each specifier you want to re-execute. +Clearing a module does not clear cached entries for its dependencies. When using +`resolver: 'import'`, resolution cache entries for other specifiers that resolve to the +same target are not cleared — only the exact `(specifier, parentURL, importAttributes)` +entry is removed. The module cache itself is cleared by resolved file path, so all +specifiers pointing to the same file will see a fresh execution on next import. #### ECMA-262 spec considerations -Re-importing the exact same `(specifier, parentURL)` pair after clearing the module cache +Re-importing the exact same `(specifier, parentURL, importAttribtues)` tuple after clearing the module cache technically violates the idempotency invariant of the ECMA-262 [`HostLoadImportedModule`][] host hook, which expects that the same module request always returns the same Module Record for a given referrer. For spec-compliant usage, use @@ -149,31 +151,28 @@ watch(base, async () => { ```mjs import { clearCache } from 'node:module'; -const url = new URL('./mod.mjs', import.meta.url); -await import(url.href); +await import('./mod.mjs'); -clearCache(url, { +clearCache('./mod.mjs', { parentURL: import.meta.url, resolver: 'import', caches: 'module', }); -await import(url.href); // re-executes the module +await import('./mod.mjs'); // re-executes the module ``` ```cjs const { clearCache } = require('node:module'); -const { pathToFileURL } = require('node:url'); -const path = require('node:path'); -const file = path.join(__dirname, 'mod.js'); -require(file); +require('./mod.js'); -clearCache(file, { - parentURL: pathToFileURL(__filename), +clearCache('./mod.js', { + parentURL: __filename, resolver: 'require', caches: 'module', }); -require(file); // re-executes the module +require('./mod.js'); // eslint-disable-line node-core/no-duplicate-requires +// re-executes the module ``` ### `module.findPackageJSON(specifier[, base])` diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 145dad75f37bbc..296a90d5a8161c 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -111,6 +111,8 @@ const kIsExecuting = Symbol('kIsExecuting'); const kURL = Symbol('kURL'); const kFormat = Symbol('kFormat'); +const relativeResolveCache = { __proto__: null }; + // Set first due to cycle with ESM loader functions. module.exports = { kModuleSource, @@ -120,6 +122,7 @@ module.exports = { initializeCJS, Module, clearCJSResolutionCaches, + relativeResolveCache, findLongestRegisteredExtension, resolveForCJSWithHooks, loadSourceForCJSWithHooks: loadSource, @@ -224,8 +227,6 @@ let { startTimer, endTimer } = debugWithTimer('module_timer', (start, end) => { const { tracingChannel } = require('diagnostics_channel'); const onRequire = getLazy(() => tracingChannel('module.require')); -const relativeResolveCache = { __proto__: null }; - /** * Clear all entries in the CJS relative resolve cache and _pathCache * that map to a given filename. This is needed by clearCache() to diff --git a/lib/internal/modules/clear.js b/lib/internal/modules/clear.js index 35235d7f3ea2d5..36a15c5bf41328 100644 --- a/lib/internal/modules/clear.js +++ b/lib/internal/modules/clear.js @@ -10,7 +10,12 @@ const { StringPrototypeStartsWith, } = primordials; -const { Module, resolveForCJSWithHooks, clearCJSResolutionCaches } = require('internal/modules/cjs/loader'); +const { + Module, + resolveForCJSWithHooks, + clearCJSResolutionCaches, + relativeResolveCache, +} = require('internal/modules/cjs/loader'); const { fileURLToPath, isURL, URLParse, pathToFileURL } = require('internal/url'); const { emitExperimentalWarning, kEmptyObject, isWindows } = require('internal/util'); const { validateObject, validateOneOf, validateString } = require('internal/validators'); @@ -56,23 +61,6 @@ function normalizeClearCacheParent(parentURL) { return { __proto__: null, parentURL: url.href, parentPath }; } -/** - * Parse a specifier as a URL when possible. - * @param {string|URL} specifier - * @returns {URL|null} - */ -function getURLFromClearCacheSpecifier(specifier) { - if (isURL(specifier)) { - return specifier; - } - - if (typeof specifier !== 'string' || path.isAbsolute(specifier)) { - return null; - } - - return URLParse(specifier) ?? null; -} - /** * Create a synthetic parent module for CJS resolution. * @param {string} parentPath @@ -88,10 +76,9 @@ function createParentModuleForClearCache(parentPath) { /** * Resolve a cache filename for CommonJS. * Always goes through resolveForCJSWithHooks so that registered hooks - * are respected. For file: URLs, search/hash are stripped before resolving - * since CJS operates on file paths. For non-file URLs, the specifier is - * passed as-is to let hooks handle it. - * @param {string|URL} specifier + * are respected. CJS operates on file paths and bare specifiers; URL + * objects are not valid require() arguments so they are not supported. + * @param {string} specifier * @param {string|undefined} parentPath * @returns {string|null} */ @@ -100,26 +87,9 @@ function resolveClearCacheFilename(specifier, parentPath) { return null; } - const parsedURL = getURLFromClearCacheSpecifier(specifier); - let request = specifier; - if (parsedURL) { - if (parsedURL.protocol === 'file:') { - // Strip search/hash - CJS operates on file paths. - if (parsedURL.search !== '' || parsedURL.hash !== '') { - parsedURL.search = ''; - parsedURL.hash = ''; - } - request = fileURLToPath(parsedURL); - } else { - // Non-file URLs (e.g. virtual://) - pass the href as-is - // so that registered hooks can resolve them. - request = parsedURL.href; - } - } - const parent = parentPath ? createParentModuleForClearCache(parentPath) : null; try { - const { filename, format } = resolveForCJSWithHooks(request, parent, false, false); + const { filename, format } = resolveForCJSWithHooks(specifier, parent, false, false); if (format === 'builtin') { return null; } @@ -314,10 +284,13 @@ function clearCache(specifier, options) { } // CJS has relativeResolveCache and Module._pathCache that map - // specifiers to filenames. Clear entries pointing to the resolved file. - if (resolvedFilename) { - clearCJSResolutionCaches(resolvedFilename); + // specifiers to filenames. Only clear the exact entry for this request. + if (resolver === 'require') { + const request = isSpecifierURL ? specifier.href : specifier; + delete relativeResolveCache[`${parentPath}\x00${request}`]; + } + if (resolvedFilename) { // Clear package.json caches for the resolved module's package so that // updated exports/imports conditions are picked up on re-resolution. const { getNearestParentPackageJSON, clearPackageJSONCache } = From be1f4a54660d112be958d9877dcee751b0b78357 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 20 Mar 2026 10:22:17 -0400 Subject: [PATCH 15/18] fixup! module: add clearCache for CJS and ESM --- lib/internal/modules/cjs/loader.js | 1 - lib/internal/modules/clear.js | 37 ++++++++++++++++++++++-------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 296a90d5a8161c..7b6d8dfa524a9a 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -122,7 +122,6 @@ module.exports = { initializeCJS, Module, clearCJSResolutionCaches, - relativeResolveCache, findLongestRegisteredExtension, resolveForCJSWithHooks, loadSourceForCJSWithHooks: loadSource, diff --git a/lib/internal/modules/clear.js b/lib/internal/modules/clear.js index 36a15c5bf41328..91590a6b7c5f56 100644 --- a/lib/internal/modules/clear.js +++ b/lib/internal/modules/clear.js @@ -14,7 +14,6 @@ const { Module, resolveForCJSWithHooks, clearCJSResolutionCaches, - relativeResolveCache, } = require('internal/modules/cjs/loader'); const { fileURLToPath, isURL, URLParse, pathToFileURL } = require('internal/url'); const { emitExperimentalWarning, kEmptyObject, isWindows } = require('internal/util'); @@ -76,20 +75,37 @@ function createParentModuleForClearCache(parentPath) { /** * Resolve a cache filename for CommonJS. * Always goes through resolveForCJSWithHooks so that registered hooks - * are respected. CJS operates on file paths and bare specifiers; URL - * objects are not valid require() arguments so they are not supported. - * @param {string} specifier + * are respected. CJS operates on file paths and bare specifiers. file: + * URL objects or strings are converted to paths; non-file URLs are not + * supported and will return null. + * @param {string|URL} specifier * @param {string|undefined} parentPath * @returns {string|null} */ function resolveClearCacheFilename(specifier, parentPath) { - if (!parentPath && typeof specifier === 'string' && isRelative(specifier)) { + let request; + if (isURL(specifier)) { + if (specifier.protocol !== 'file:') { + return null; + } + request = fileURLToPath(specifier); + } else if (typeof specifier === 'string' && StringPrototypeStartsWith(specifier, 'file:')) { + const parsed = URLParse(specifier); + if (!parsed || parsed.protocol !== 'file:') { + return null; + } + request = fileURLToPath(parsed); + } else { + request = specifier; + } + + if (!parentPath && isRelative(request)) { return null; } const parent = parentPath ? createParentModuleForClearCache(parentPath) : null; try { - const { filename, format } = resolveForCJSWithHooks(specifier, parent, false, false); + const { filename, format } = resolveForCJSWithHooks(request, parent, false, false); if (format === 'builtin') { return null; } @@ -284,10 +300,11 @@ function clearCache(specifier, options) { } // CJS has relativeResolveCache and Module._pathCache that map - // specifiers to filenames. Only clear the exact entry for this request. - if (resolver === 'require') { - const request = isSpecifierURL ? specifier.href : specifier; - delete relativeResolveCache[`${parentPath}\x00${request}`]; + // specifiers to filenames. Clear all entries pointing to the resolved + // file. Module._pathCache keys are not easily reconstructable so a + // value-scan is required. + if (resolver === 'require' && resolvedFilename) { + clearCJSResolutionCaches(resolvedFilename); } if (resolvedFilename) { From 30214787c403f12fd2ba1b1911029b0f290ab3cc Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 27 Mar 2026 13:40:08 -0400 Subject: [PATCH 16/18] fixup! module: add clearCache for CJS and ESM --- doc/api/module.md | 29 ++-- lib/internal/modules/cjs/loader.js | 18 +++ lib/internal/modules/clear.js | 96 +++++--------- lib/internal/modules/esm/loader.js | 11 ++ lib/internal/modules/esm/module_map.js | 29 ++++ lib/internal/modules/esm/translators.js | 31 +---- lib/internal/modules/helpers.js | 28 ++++ ...t-module-clear-cache-caches-resolution.mjs | 28 ++++ ...ule-hooks-clear-cache-import-attributes.js | 125 +++++++----------- .../test-module-clear-cache-cjs-cache.js | 46 ++++--- .../test-module-clear-cache-cjs-resolution.js | 44 ++++-- .../test-module-clear-cache-options.js | 31 ++--- 12 files changed, 278 insertions(+), 238 deletions(-) diff --git a/doc/api/module.md b/doc/api/module.md index e084b6fe45d49d..f77c0b998fb017 100644 --- a/doc/api/module.md +++ b/doc/api/module.md @@ -87,8 +87,6 @@ added: REPLACEME * `'module'` — clear the cached module everywhere in Node.js (not counting JS-level references). * `'all'` — clear both resolution and module caches. - * `importAttributes` {Object} Optional import attributes. Only meaningful when - `resolver` is `'import'`. Clears module resolution and/or module caches for a module. This enables 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 `'./mod.mjs?v=1'` will also clear `'./mod.mjs?v=2'` and any other query/hash variants that resolve to the same file. -When `caches` is `'resolution'` or `'all'` with `resolver` set to `'import'`, the ESM -resolution cache entry for the given `(specifier, parentURL, importAttributes)` tuple is -cleared. When `resolver` is `'require'`, internal CJS resolution caches (including the -relative resolve cache and path cache) are also cleared for the resolved filename. -When `importAttributes` are provided for `'import'` resolution, they are used to construct the cache key; if a module -was loaded with multiple different import attribute combinations, only the matching entry -is cleared from the resolution cache. The module cache itself (`caches: 'module'`) clears -all attribute variants for the URL. +When `caches` is `'resolution'` or `'all'` with `resolver` set to `'import'`, all ESM +resolution cache entries for the given `(specifier, parentURL)` pair are cleared regardless +of import attributes. When `resolver` is `'require'`, the specific CJS relative resolve +cache entry for the `(parentDir, specifier)` pair is cleared, together with any cached +`package.json` data for the resolved module's package. Clearing a module does not clear cached entries for its dependencies. When using `resolver: 'import'`, resolution cache entries for other specifiers that resolve to the -same target are not cleared — only the exact `(specifier, parentURL, importAttributes)` -entry is removed. The module cache itself is cleared by resolved file path, so all -specifiers pointing to the same file will see a fresh execution on next import. +same target are not cleared — only entries for the exact `(specifier, parentURL)` pair +are removed. The module cache itself is cleared by resolved file path, so all specifiers +pointing to the same file will see a fresh execution on next import. #### ECMA-262 spec considerations -Re-importing the exact same `(specifier, parentURL, importAttribtues)` tuple after clearing the module cache +Re-importing the exact same `(specifier, parentURL, importAttributes)` tuple after clearing the module cache technically violates the idempotency invariant of the ECMA-262 [`HostLoadImportedModule`][] host hook, which expects that the same module request always -returns the same Module Record for a given referrer. For spec-compliant usage, use +returns the same Module Record for a given referrer. The result of violating this requirement +is undefined — e.g. it can lead to crashes. For spec-compliant usage, use cache-busting search parameters so that each reload uses a distinct module request: ```mjs @@ -163,11 +159,12 @@ await import('./mod.mjs'); // re-executes the module ```cjs const { clearCache } = require('node:module'); +const { pathToFileURL } = require('node:url'); require('./mod.js'); clearCache('./mod.js', { - parentURL: __filename, + parentURL: pathToFileURL(__filename), resolver: 'require', caches: 'module', }); diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 7b6d8dfa524a9a..9991da470cd3ab 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -122,6 +122,7 @@ module.exports = { initializeCJS, Module, clearCJSResolutionCaches, + deleteCJSRelativeResolveCacheEntry, findLongestRegisteredExtension, resolveForCJSWithHooks, loadSourceForCJSWithHooks: loadSource, @@ -250,6 +251,23 @@ function clearCJSResolutionCaches(filename) { } } +/** + * Delete a single entry from the CJS relative resolve cache. + * The cache key is `${parent.path}\x00${request}` where parent.path is the + * directory containing the parent module (i.e. path.dirname(parentFilename)). + * @param {string} parentDir Directory of the parent module (path.dirname of filename). + * @param {string} request The specifier as originally passed to require(). + * @returns {boolean} true if the entry existed and was deleted. + */ +function deleteCJSRelativeResolveCacheEntry(parentDir, request) { + const key = `${parentDir}\x00${request}`; + if (relativeResolveCache[key] !== undefined) { + delete relativeResolveCache[key]; + return true; + } + return false; +} + let requireDepth = 0; let isPreloading = false; let statCache = null; diff --git a/lib/internal/modules/clear.js b/lib/internal/modules/clear.js index 91590a6b7c5f56..7f5f858a96184a 100644 --- a/lib/internal/modules/clear.js +++ b/lib/internal/modules/clear.js @@ -14,9 +14,11 @@ const { Module, resolveForCJSWithHooks, clearCJSResolutionCaches, + deleteCJSRelativeResolveCacheEntry, } = require('internal/modules/cjs/loader'); +const { getFilePathFromFileURL } = require('internal/modules/helpers'); const { fileURLToPath, isURL, URLParse, pathToFileURL } = require('internal/url'); -const { emitExperimentalWarning, kEmptyObject, isWindows } = require('internal/util'); +const { emitExperimentalWarning, isWindows } = require('internal/util'); const { validateObject, validateOneOf, validateString } = require('internal/validators'); const { codes: { @@ -75,29 +77,18 @@ function createParentModuleForClearCache(parentPath) { /** * Resolve a cache filename for CommonJS. * Always goes through resolveForCJSWithHooks so that registered hooks - * are respected. CJS operates on file paths and bare specifiers. file: - * URL objects or strings are converted to paths; non-file URLs are not - * supported and will return null. + * are respected. The specifier is passed as-is: if hooks are registered, + * they handle any URL interpretation; if not, it is treated as a plain + * path/identifier (matching how require() interprets its argument). * @param {string|URL} specifier * @param {string|undefined} parentPath * @returns {string|null} */ function resolveClearCacheFilename(specifier, parentPath) { - let request; - if (isURL(specifier)) { - if (specifier.protocol !== 'file:') { - return null; - } - request = fileURLToPath(specifier); - } else if (typeof specifier === 'string' && StringPrototypeStartsWith(specifier, 'file:')) { - const parsed = URLParse(specifier); - if (!parsed || parsed.protocol !== 'file:') { - return null; - } - request = fileURLToPath(parsed); - } else { - request = specifier; - } + // Pass the specifier through as-is. When hooks are registered they + // receive the raw value; without hooks CJS resolution treats it as + // a plain path or bare name, consistent with how require() behaves. + const request = isURL(specifier) ? specifier.href : specifier; if (!parentPath && isRelative(request)) { return null; @@ -164,29 +155,6 @@ function deleteModuleFromParents(targetModule) { return deleted; } -/** - * Resolve a file path for a file URL, stripping search/hash. - * @param {string} url - * @returns {string|null} - */ -function getFilePathFromClearCacheURL(url) { - const parsedURL = URLParse(url); - if (parsedURL?.protocol !== 'file:') { - return null; - } - - if (parsedURL.search !== '' || parsedURL.hash !== '') { - parsedURL.search = ''; - parsedURL.hash = ''; - } - - try { - return fileURLToPath(parsedURL); - } catch { - return null; - } -} - /** * Remove load cache entries for a URL and its file-path variants. * @param {import('internal/modules/esm/module_map').LoadCache} loadCache @@ -195,7 +163,7 @@ function getFilePathFromClearCacheURL(url) { */ function deleteLoadCacheEntries(loadCache, url) { let deleted = loadCache.deleteAll(url); - const filename = getFilePathFromClearCacheURL(url); + const filename = getFilePathFromFileURL(url); if (!filename) { return deleted; } @@ -210,7 +178,7 @@ function deleteLoadCacheEntries(loadCache, url) { if (cachedURL === url) { continue; } - const cachedFilename = getFilePathFromClearCacheURL(cachedURL); + const cachedFilename = getFilePathFromFileURL(cachedURL); if (cachedFilename === filename) { loadCache.deleteAll(cachedURL); deleted = true; @@ -240,7 +208,6 @@ function isRelative(pathToCheck) { * @param {string|URL} specifier What would've been passed into import() or require(). * @param {{ * parentURL: string|URL, - * importAttributes?: Record, * resolver: 'import'|'require', * caches: 'resolution'|'module'|'all', * }} options @@ -260,11 +227,6 @@ function clearCache(specifier, options) { validateOneOf(resolver, 'options.resolver', ['import', 'require']); validateOneOf(caches, 'options.caches', ['resolution', 'module', 'all']); - const importAttributes = options.importAttributes ?? kEmptyObject; - if (options.importAttributes !== undefined) { - validateObject(options.importAttributes, 'options.importAttributes'); - } - const clearResolution = caches === 'resolution' || caches === 'all'; const clearModule = caches === 'module' || caches === 'all'; @@ -283,7 +245,7 @@ function clearCache(specifier, options) { } else { resolvedURL = resolveClearCacheURL(specifier, parentURL); if (resolvedURL) { - resolvedFilename = getFilePathFromClearCacheURL(resolvedURL); + resolvedFilename = getFilePathFromFileURL(resolvedURL); } } } @@ -291,30 +253,32 @@ function clearCache(specifier, options) { // Clear resolution caches. if (clearResolution) { // ESM has a structured resolution cache keyed by (specifier, parentURL, - // importAttributes). + // importAttributes). Clear all attribute variants for the given + // (specifier, parentURL) pair since attributes don't affect resolution + // per spec and it avoids partial-clear surprises. if (resolver === 'import') { const specifierStr = isSpecifierURL ? specifier.href : specifier; const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader(); - cascadedLoader.deleteResolveCacheEntry(specifierStr, parentURL, importAttributes); + cascadedLoader.deleteAllResolveCacheEntries(specifierStr, parentURL); } - // CJS has relativeResolveCache and Module._pathCache that map - // specifiers to filenames. Clear all entries pointing to the resolved - // file. Module._pathCache keys are not easily reconstructable so a - // value-scan is required. - if (resolver === 'require' && resolvedFilename) { - clearCJSResolutionCaches(resolvedFilename); - } + // CJS resolution caches are only relevant when the resolver is 'require'. + if (resolver === 'require' && parentPath) { + // Delete the specific relativeResolveCache entry for this + // (parent-dir, request) pair. More targeted than a full value-scan. + const requestStr = isSpecifierURL ? specifier.href : specifier; + deleteCJSRelativeResolveCacheEntry(path.dirname(parentPath), requestStr); - if (resolvedFilename) { // Clear package.json caches for the resolved module's package so that // updated exports/imports conditions are picked up on re-resolution. - const { getNearestParentPackageJSON, clearPackageJSONCache } = - require('internal/modules/package_json_reader'); - const pkg = getNearestParentPackageJSON(resolvedFilename); - if (pkg?.path) { - clearPackageJSONCache(pkg.path); + if (resolvedFilename) { + const { getNearestParentPackageJSON, clearPackageJSONCache } = + require('internal/modules/package_json_reader'); + const pkg = getNearestParentPackageJSON(resolvedFilename); + if (pkg?.path) { + clearPackageJSONCache(pkg.path); + } } } } diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js index 7f3421bb6319e3..add6b3292965bc 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -180,6 +180,17 @@ class ModuleLoader { return this.#resolveCache.deleteBySpecifier(specifier, parentURL, importAttributes); } + /** + * Delete all cached resolution entries for a specifier from a parent URL, + * regardless of import attributes. + * @param {string} specifier + * @param {string|undefined} parentURL + * @returns {boolean} true if at least one entry was deleted. + */ + deleteAllResolveCacheEntries(specifier, parentURL) { + return this.#resolveCache.deleteAllBySpecifier(specifier, parentURL); + } + /** * Check if a cached resolution exists for a specific request. * @param {string} specifier diff --git a/lib/internal/modules/esm/module_map.js b/lib/internal/modules/esm/module_map.js index c2a23e8f25566a..b62d20fdf1c2c0 100644 --- a/lib/internal/modules/esm/module_map.js +++ b/lib/internal/modules/esm/module_map.js @@ -8,6 +8,7 @@ const { ObjectKeys, ObjectPrototypeHasOwnProperty, SafeMap, + StringPrototypeStartsWith, } = primordials; const { kImplicitTypeAttribute } = require('internal/modules/esm/assert'); let debug = require('internal/util/debuglog').debuglog('esm', (fn) => { @@ -109,6 +110,34 @@ class ResolveCache extends SafeMap { } return true; } + + /** + * Delete all cached resolution entries for a specifier in a parent URL, + * regardless of import attributes. + * @param {string} specifier + * @param {string|undefined} parentURL + * @returns {boolean} true if at least one entry was deleted. + */ + deleteAllBySpecifier(specifier, parentURL) { + const entries = super.get(parentURL); + if (entries == null) { + return false; + } + // Keys are serialized as `specifier + '::' + attributes`. Match all + // entries whose prefix up to and including '::' matches this specifier. + const prefix = specifier + '::'; + let deleted = false; + for (const key of ObjectKeys(entries)) { + if (key === prefix || StringPrototypeStartsWith(key, prefix)) { + delete entries[key]; + deleted = true; + } + } + if (deleted && ObjectKeys(entries).length === 0) { + super.delete(parentURL); + } + return deleted; + } } /** diff --git a/lib/internal/modules/esm/translators.js b/lib/internal/modules/esm/translators.js index 73c26c4da0e649..9b2e385c43f074 100644 --- a/lib/internal/modules/esm/translators.js +++ b/lib/internal/modules/esm/translators.js @@ -30,6 +30,7 @@ const { stringify, stripBOM, urlToFilename, + getFilePathFromFileURL, } = require('internal/modules/helpers'); const { stripTypeScriptModuleTypes } = require('internal/modules/typescript'); const { @@ -44,7 +45,7 @@ const { loadSourceForCJSWithHooks, populateCJSExportsFromESM, } = require('internal/modules/cjs/loader'); -const { fileURLToPath, pathToFileURL, URL, URLParse } = require('internal/url'); +const { fileURLToPath, pathToFileURL, URL } = require('internal/url'); let debug = require('internal/util/debuglog').debuglog('esm', (fn) => { debug = fn; }); @@ -184,30 +185,6 @@ function loadCJSModule(module, source, url, filename, isMain) { // TODO: can we use a weak map instead? const cjsCache = new SafeMap(); -/** - * Resolve a file path for a file URL, stripping search/hash. - * @param {string} url - * @returns {string|null} - */ -function getFilePathFromCjsCacheURL(url) { - const parsedURL = URLParse(url); - if (!parsedURL) { - return null; - } - if (parsedURL.protocol !== 'file:') { - return null; - } - if (parsedURL.search !== '' || parsedURL.hash !== '') { - parsedURL.search = ''; - parsedURL.hash = ''; - } - try { - return fileURLToPath(parsedURL); - } catch { - return null; - } -} - /** * Remove cjsCache entries for a URL and its file-path variants. * @param {string} url @@ -215,7 +192,7 @@ function getFilePathFromCjsCacheURL(url) { */ function clearCjsCache(url) { let deleted = cjsCache.delete(url); - const filename = getFilePathFromCjsCacheURL(url); + const filename = getFilePathFromFileURL(url); if (!filename) { return deleted; } @@ -230,7 +207,7 @@ function clearCjsCache(url) { if (cachedURL === url) { continue; } - const cachedFilename = getFilePathFromCjsCacheURL(cachedURL); + const cachedFilename = getFilePathFromFileURL(cachedURL); if (cachedFilename === filename) { cjsCache.delete(cachedURL); deleted = true; diff --git a/lib/internal/modules/helpers.js b/lib/internal/modules/helpers.js index 01739fefd6a7f1..237e17517eb077 100644 --- a/lib/internal/modules/helpers.js +++ b/lib/internal/modules/helpers.js @@ -362,6 +362,33 @@ function urlToFilename(url) { return url; } +/** + * Get the file path from a file: URL string, stripping search and hash params. + * Returns null if the input is not a valid file: URL or cannot be converted. + * @param {string} url + * @returns {string|null} + */ +function getFilePathFromFileURL(url) { + let parsedURL; + try { + parsedURL = new URL(url); + } catch { + return null; + } + if (parsedURL.protocol !== 'file:') { + return null; + } + if (parsedURL.search !== '' || parsedURL.hash !== '') { + parsedURL.search = ''; + parsedURL.hash = ''; + } + try { + return fileURLToPath(parsedURL); + } catch { + return null; + } +} + // Whether we have started executing any user-provided CJS code. // This is set right before we call the wrapped CJS code (not after, // in case we are half-way in the execution when internals check this). @@ -527,4 +554,5 @@ module.exports = { _hasStartedUserESMExecution = true; }, urlToFilename, + getFilePathFromFileURL, }; diff --git a/test/es-module/test-module-clear-cache-caches-resolution.mjs b/test/es-module/test-module-clear-cache-caches-resolution.mjs index 0e9e95217fa660..3931579a866aff 100644 --- a/test/es-module/test-module-clear-cache-caches-resolution.mjs +++ b/test/es-module/test-module-clear-cache-caches-resolution.mjs @@ -40,6 +40,34 @@ clearCache(specifier, { const third = await import(specifier); assert.strictEqual(third.count, 2); +// --- Test that a different specifier form for the same file also sees the +// new value after cache clearing. This covers the case raised in spec +// discussions where "another importer that originally cached the old value +// should also get the new value on re-import". --- + +// Load via absolute URL (different specifier form, same resolved file). +const absoluteURL = new URL(specifier, import.meta.url).href; +const fromAbsolute = await import(absoluteURL); +// The absolute-URL import maps to the same re-evaluated module. +assert.strictEqual(fromAbsolute.count, 2); +assert.strictEqual(third, fromAbsolute); + +// Clear again and verify both specifier forms re-evaluate. +clearCache(specifier, { + parentURL: import.meta.url, + resolver: 'import', + caches: 'module', +}); + +const fourth = await import(specifier); +assert.strictEqual(fourth.count, 3); + +// The absolute URL specifier should also pick up the new module since they +// point to the same file and the load cache was cleared by file path. +const fourthAbs = await import(absoluteURL); +assert.strictEqual(fourthAbs.count, 3); +assert.strictEqual(fourth, fourthAbs); + // --- CJS: resolution-only clearing should be a no-op --- const cjsFixturePath = fileURLToPath( diff --git a/test/module-hooks/test-module-hooks-clear-cache-import-attributes.js b/test/module-hooks/test-module-hooks-clear-cache-import-attributes.js index 5ed977f2b22a8d..aa1993557254af 100644 --- a/test/module-hooks/test-module-hooks-clear-cache-import-attributes.js +++ b/test/module-hooks/test-module-hooks-clear-cache-import-attributes.js @@ -1,93 +1,60 @@ // Flags: --expose-internals -// Tests that the importAttributes option is forwarded correctly -// to the ESM resolve cache deletion. +// Tests that clearCache with caches: 'resolution' clears ALL resolve-cache +// entries for a given (specifier, parentURL) pair regardless of import +// attributes. Since the internal resolve cache is only populated by the +// default resolver (not custom hooks), this test uses a real file specifier. 'use strict'; const common = require('../common'); const assert = require('node:assert'); +const path = require('node:path'); const { pathToFileURL } = require('node:url'); -const { clearCache, registerHooks } = require('node:module'); +const { clearCache } = require('node:module'); const { getOrInitializeCascadedLoader } = require('internal/modules/esm/loader'); -const hook = registerHooks({ - resolve(specifier, context, nextResolve) { - if (specifier === 'virtual-json') { - return { - url: 'virtual://json-data', - format: 'json', - shortCircuit: true, - }; - } - return nextResolve(specifier, context); - }, - load(url, context, nextLoad) { - if (url === 'virtual://json-data') { - return { - format: 'json', - source: '{"key": "value"}', - shortCircuit: true, - }; - } - return nextLoad(url, context); - }, -}); +const fixture = path.join(__dirname, '..', 'fixtures', 'module-cache', 'esm-counter.mjs'); +const specifier = pathToFileURL(fixture).href; +const parentURL = pathToFileURL(__filename).href; (async () => { - const first = await import('virtual-json', { with: { type: 'json' } }); - assert.deepStrictEqual(first.default, { key: 'value' }); - const cascadedLoader = getOrInitializeCascadedLoader(); - const capturedCalls = []; - const original = cascadedLoader.deleteResolveCacheEntry; - cascadedLoader.deleteResolveCacheEntry = function(specifier, parentURL, importAttributes) { - capturedCalls.push({ specifier, parentURL, importAttributes }); - return original.call(this, specifier, parentURL, importAttributes); - }; - - try { - // Without importAttributes — default empty object is forwarded. - clearCache('virtual-json', { - parentURL: pathToFileURL(__filename), - resolver: 'import', - caches: 'resolution', - }); - assert.strictEqual(capturedCalls.length, 1); - assert.strictEqual(capturedCalls[0].specifier, 'virtual-json'); - assert.deepStrictEqual(Object.keys(capturedCalls[0].importAttributes), []); - - // With importAttributes: { type: 'json' } — forwarded through. - clearCache('virtual-json', { - parentURL: pathToFileURL(__filename), - resolver: 'import', - caches: 'resolution', - importAttributes: { type: 'json' }, - }); - assert.strictEqual(capturedCalls.length, 2); - assert.deepStrictEqual(capturedCalls[1].importAttributes, { type: 'json' }); - - // caches: 'all' also forwards importAttributes for resolution clearing. - clearCache('virtual-json', { - parentURL: pathToFileURL(__filename), - resolver: 'import', - caches: 'all', - importAttributes: { type: 'json' }, - }); - assert.strictEqual(capturedCalls.length, 3); - assert.deepStrictEqual(capturedCalls[2].importAttributes, { type: 'json' }); - - // resolver: 'require' should NOT call deleteResolveCacheEntry - // (even with caches: 'resolution'). - clearCache('virtual-json', { - parentURL: pathToFileURL(__filename), - resolver: 'require', - caches: 'resolution', - importAttributes: { type: 'json' }, - }); - assert.strictEqual(capturedCalls.length, 3); // unchanged - } finally { - cascadedLoader.deleteResolveCacheEntry = original; - } - hook.deregister(); + // Import via absolute URL to populate the default resolve cache. + await import(specifier); + assert.ok( + cascadedLoader.hasResolveCacheEntry(specifier, parentURL), + 'resolve cache should have entry after import', + ); + + // Clearing with caches: 'resolution' should remove ALL attribute variants + // for the (specifier, parentURL) pair — not just the default empty-attribute entry. + clearCache(specifier, { + parentURL, + resolver: 'import', + caches: 'resolution', + }); + + assert.ok( + !cascadedLoader.hasResolveCacheEntry(specifier, parentURL), + 'resolve cache should be cleared after clearCache with caches: "resolution"', + ); + + // Verify that resolver: 'require' does NOT touch the ESM resolve cache. + // Re-populate first. + await import(specifier); + assert.ok(cascadedLoader.hasResolveCacheEntry(specifier, parentURL)); + + clearCache(specifier, { + parentURL, + resolver: 'require', + caches: 'resolution', + }); + // The ESM resolve cache should still have the entry. + assert.ok( + cascadedLoader.hasResolveCacheEntry(specifier, parentURL), + 'resolve cache should NOT be cleared when resolver is "require"', + ); + + delete globalThis.__module_cache_esm_counter; })().then(common.mustCall()); diff --git a/test/parallel/test-module-clear-cache-cjs-cache.js b/test/parallel/test-module-clear-cache-cjs-cache.js index 16539a7351a067..37d407a29f4614 100644 --- a/test/parallel/test-module-clear-cache-cjs-cache.js +++ b/test/parallel/test-module-clear-cache-cjs-cache.js @@ -1,28 +1,40 @@ // Flags: --expose-internals +// Verifies that CJS Module instances created when a CommonJS file is loaded +// via import() are garbage-collectible after clearCache. Uses +// checkIfCollectableByCounting for a robust GC-level check rather than +// asserting on internal cache state. 'use strict'; const common = require('../common'); -const assert = require('node:assert'); -const path = require('node:path'); const { pathToFileURL } = require('node:url'); const { clearCache } = require('node:module'); -const { clearCjsCache } = require('internal/modules/esm/translators'); +const { Module } = require('internal/modules/cjs/loader'); +const { checkIfCollectableByCounting } = require('../common/gc'); -const fixturePath = path.join(__dirname, '..', 'fixtures', 'module-cache', 'cjs-counter.js'); -const url = pathToFileURL(fixturePath); +const fixtureURL = new URL( + '../fixtures/module-cache/cjs-counter.js', + pathToFileURL(__filename), +); +const parentURL = pathToFileURL(__filename).href; -(async () => { - const first = await import(`${url.href}?v=1`); - assert.strictEqual(first.default.count, 1); +const outer = 8; +const inner = 4; - clearCache(url, { - parentURL: pathToFileURL(__filename), - resolver: 'import', - caches: 'module', - }); +const runIteration = common.mustCallAtLeast(async (i) => { + for (let j = 0; j < inner; j++) { + const url = `${fixtureURL.href}?v=${i}-${j}`; + await import(url); + clearCache(url, { + parentURL, + resolver: 'import', + caches: 'module', + }); + } + return inner; +}); - // Verify the cjsCache was also cleared for query variants. - assert.strictEqual(clearCjsCache(`${url.href}?v=1`), false); - delete globalThis.__module_cache_cjs_counter; -})().then(common.mustCall()); +checkIfCollectableByCounting(runIteration, Module, outer) + .then(common.mustCall(() => { + delete globalThis.__module_cache_cjs_counter; + })); diff --git a/test/parallel/test-module-clear-cache-cjs-resolution.js b/test/parallel/test-module-clear-cache-cjs-resolution.js index 216086c804e5ec..1611a5053ef969 100644 --- a/test/parallel/test-module-clear-cache-cjs-resolution.js +++ b/test/parallel/test-module-clear-cache-cjs-resolution.js @@ -1,6 +1,7 @@ // Flags: --expose-internals -// Tests that clearCache with caches: 'resolution' or 'all' also clears -// the CJS relativeResolveCache and Module._pathCache entries. +// Tests that clearCache with caches: 'resolution' or 'all' clears +// the CJS relativeResolveCache entries, and that caches: 'module' or 'all' +// also clears Module._pathCache. 'use strict'; require('../common'); @@ -18,11 +19,10 @@ require(fixture); assert.notStrictEqual(Module._cache[fixture], undefined); // Module._pathCache should have an entry pointing to this fixture. -const pathCacheKeys = Object.keys(Module._pathCache); -const hasPathCacheEntry = pathCacheKeys.some( - (key) => Module._pathCache[key] === fixture, -); -assert.ok(hasPathCacheEntry, 'Module._pathCache should contain the fixture'); +const hasPathCacheEntry = () => + Object.keys(Module._pathCache).some((key) => Module._pathCache[key] === fixture); + +assert.ok(hasPathCacheEntry(), 'Module._pathCache should contain the fixture'); // Clear only resolution caches. clearCache(fixture, { @@ -34,12 +34,28 @@ clearCache(fixture, { // Module._cache should still be present (we only cleared resolution). assert.notStrictEqual(Module._cache[fixture], undefined); -// But _pathCache entries for this filename should be cleared. -const pathCacheKeysAfter = Object.keys(Module._pathCache); -const hasPathCacheEntryAfter = pathCacheKeysAfter.some( - (key) => Module._pathCache[key] === fixture, -); -assert.ok(!hasPathCacheEntryAfter, - 'Module._pathCache should not contain the fixture after clearing resolution'); +// With caches: 'resolution', only the specific relativeResolveCache entry is +// targeted-cleared. Module._pathCache requires a value scan which is only done +// for caches: 'module' or 'all'. +assert.ok(hasPathCacheEntry(), + 'Module._pathCache should still contain the fixture after caches: "resolution"'); + +// --- caches: 'module' should clear Module._pathCache --- + +// Re-populate the caches. +require(fixture); // Will be a no-op if already in cache + +clearCache(fixture, { + parentURL: pathToFileURL(__filename), + resolver: 'require', + caches: 'module', +}); + +// After caches: 'module', _pathCache entries pointing to this file are purged. +assert.ok(!hasPathCacheEntry(), + 'Module._pathCache should not contain the fixture after caches: "module"'); + +// Module._cache entry should also be cleared. +assert.strictEqual(Module._cache[fixture], undefined); delete globalThis.__module_cache_cjs_counter; diff --git a/test/parallel/test-module-clear-cache-options.js b/test/parallel/test-module-clear-cache-options.js index 899c3a5861e9b9..6d37c0b82e25dd 100644 --- a/test/parallel/test-module-clear-cache-options.js +++ b/test/parallel/test-module-clear-cache-options.js @@ -103,16 +103,6 @@ assert.throws(() => clearCache(123, { code: 'ERR_INVALID_ARG_TYPE', }); -// importAttributes must be an object if provided. -assert.throws(() => clearCache(fixture, { - parentURL: pathToFileURL(__filename), - resolver: 'require', - caches: 'module', - importAttributes: 'bad', -}), { - code: 'ERR_INVALID_ARG_TYPE', -}); - // --- No-op scenarios (should not throw) --- // Clearing a module that was never loaded — no-op. @@ -137,7 +127,8 @@ clearCache(fixture, { caches: 'module', }); -// URL object specifier with resolver: 'require'. +// URL object specifier with resolver: 'require' is a no-op. +// CJS does not interpret file: URLs as paths; hooks would be needed to handle them. const third = require(fixture); assert.strictEqual(third.count, 4); clearCache(pathToFileURL(fixture), { @@ -145,21 +136,19 @@ clearCache(pathToFileURL(fixture), { resolver: 'require', caches: 'module', }); -assert.strictEqual(require.cache[fixture], undefined); +// Cache should still be populated — URL specifiers are not resolved for CJS. +assert.notStrictEqual(require.cache[fixture], undefined); -// String URL specifier with resolver: 'require'. -const fourth = require(fixture); -assert.strictEqual(fourth.count, 5); +// String file: URL specifier with resolver: 'require' is also a no-op. clearCache(pathToFileURL(fixture).href, { parentURL: pathToFileURL(__filename), resolver: 'require', caches: 'module', }); -assert.strictEqual(require.cache[fixture], undefined); +// Cache should still be populated. +assert.notStrictEqual(require.cache[fixture], undefined); -// parentURL as string URL (not URL object). -const fifth = require(fixture); -assert.strictEqual(fifth.count, 6); +// parentURL as string URL (not URL object) — still works when specifier is a path. clearCache(fixture, { parentURL: pathToFileURL(__filename).href, resolver: 'require', @@ -167,4 +156,8 @@ clearCache(fixture, { }); assert.strictEqual(require.cache[fixture], undefined); +// Re-require to bump count for subsequent assertions. +const fourth = require(fixture); +assert.strictEqual(fourth.count, 5); + delete globalThis.__module_cache_cjs_counter; From ca820565bcb216749a38a229f554658431a3d568 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 27 Mar 2026 15:12:43 -0400 Subject: [PATCH 17/18] fixup! module: add clearCache for CJS and ESM --- doc/api/module.md | 30 +++++ .../test-module-clear-cache-hash-map.mjs | 115 ++++++++++++++++++ ...est-module-clear-cache-import-cjs-race.mjs | 79 +++++++++++- ...-module-clear-cache-static-import-leak.mjs | 106 ++++++++++++++++ .../module-cache/esm-static-parent.mjs | 3 + 5 files changed, 328 insertions(+), 5 deletions(-) create mode 100644 test/es-module/test-module-clear-cache-hash-map.mjs create mode 100644 test/es-module/test-module-clear-cache-static-import-leak.mjs create mode 100644 test/fixtures/module-cache/esm-static-parent.mjs diff --git a/doc/api/module.md b/doc/api/module.md index f77c0b998fb017..90a208e92b22bf 100644 --- a/doc/api/module.md +++ b/doc/api/module.md @@ -111,6 +111,36 @@ same target are not cleared — only entries for the exact `(specifier, parentUR are removed. The module cache itself is cleared by resolved file path, so all specifiers pointing to the same file will see a fresh execution on next import. +#### Memory retention and static imports + +`clearCache` only removes references from the Node.js **JavaScript-level** caches +(the ESM load cache, resolve cache, CJS `require.cache`, and related structures). +It does **not** affect V8-internal module graph references. + +When a module M is **statically imported** by a live parent module P +(i.e., via a top-level `import … from '…'` statement that has already been +evaluated), V8's module instantiation creates a permanent internal strong +reference from P's compiled module record to M's module record. Calling +`clearCache(M)` cannot sever that link. Consequences: + +* The old instance of M **stays alive in memory** for as long as P is alive, + regardless of how many times M is cleared and re-imported. +* A fresh `import(M)` after clearing will create a **separate** module instance + that new importers see. P, however, continues to use the original instance — + the two coexist simultaneously (sometimes called a "split-brain" state). +* This is a **bounded** retention: one stale module instance per cleared module + per live static parent. It does not grow unboundedly across clear/re-import + cycles. + +For **dynamically imported** modules (`await import('./M.mjs')` with no live +static parent holding the result), the old `ModuleWrap` becomes eligible for +garbage collection once `clearCache` removes it from Node.js caches and all +JS-land references (e.g., stored namespace objects) are dropped. + +The safest pattern for hot-reload of ES modules is to use cache-busting search +parameters (so each version is a distinct module URL) and to avoid statically +importing modules that need to be reloaded: + #### ECMA-262 spec considerations Re-importing the exact same `(specifier, parentURL, importAttributes)` tuple after clearing the module cache diff --git a/test/es-module/test-module-clear-cache-hash-map.mjs b/test/es-module/test-module-clear-cache-hash-map.mjs new file mode 100644 index 00000000000000..383cd9b0bf5355 --- /dev/null +++ b/test/es-module/test-module-clear-cache-hash-map.mjs @@ -0,0 +1,115 @@ +// Flags: --expose-internals +// Evaluates the hash_to_module_map memory behaviour across clearCache cycles. +// +// hash_to_module_map is a C++ unordered_multimap on the +// Environment. Every new ModuleWrap adds an entry; the destructor removes it. +// Clearing the Node-side loadCache does not directly touch hash_to_module_map — +// entries are removed only when ModuleWrap objects are garbage-collected. +// +// We verify two invariants: +// +// 1. DYNAMIC imports: after clearCache + GC, the old ModuleWrap is collected +// and therefore its hash_to_module_map entry is removed. The map does NOT +// grow without bound for purely-dynamic import/clear cycles. +// (Verified via checkIfCollectableByCounting.) +// +// 2. STATIC imports: when a parent P statically imports M, clearing M from +// the load cache does not free M's ModuleWrap (the static link keeps it). +// Each re-import adds one new entry while the old entry stays for the +// lifetime of P. This is a bounded, expected retention (not an unbounded +// leak): it is capped at one stale entry per module per live static parent. + +import '../common/index.mjs'; + +import assert from 'node:assert'; +import { clearCache, createRequire } from 'node:module'; +import { queryObjects } from 'v8'; + +const require = createRequire(import.meta.url); +const { checkIfCollectableByCounting } = require('../common/gc'); +const { internalBinding } = require('internal/test/binding'); +const { ModuleWrap } = internalBinding('module_wrap'); + +const counterBase = new URL( + '../fixtures/module-cache/esm-counter.mjs', + import.meta.url, +).href; + +const parentURL = new URL( + '../fixtures/module-cache/esm-static-parent.mjs', + import.meta.url, +).href; + +// ── Invariant 1: dynamic-only cycles do NOT leak ModuleWraps ──────────────── +// Use cache-busting query params so each import gets a distinct URL. + +const outer = 8; +const inner = 4; + +await checkIfCollectableByCounting(async (i) => { + for (let j = 0; j < inner; j++) { + const url = `${counterBase}?hm=${i}-${j}`; + await import(url); + clearCache(url, { + parentURL: import.meta.url, + resolver: 'import', + caches: 'all', + }); + } + return inner; +}, ModuleWrap, outer, 50); + +// ── Invariant 2: static-parent cycles cause bounded retention ─────────────── +// After loading the static parent (which pins one counter instance), each +// clear+re-import of the base counter URL creates exactly one new ModuleWrap +// while the old one stays alive (pinned by the parent). +// The net growth per cycle is +1. After N cycles the live count is +// baseline + 1(parent) + 1(pinned original counter) + 1(current counter) +// — a constant overhead, not growing with N. + +// Load the static parent; this also loads the counter (count starts at 1 for +// the global, but we seed it fresh by clearing any earlier runs' state). +delete globalThis.__module_cache_esm_counter; + +const parent = await import(parentURL); +assert.strictEqual(parent.count, 1); + +const wrapCount0 = queryObjects(ModuleWrap, { format: 'count' }); + +// Cycle 1: clear counter + re-import → new instance created, old pinned. +clearCache(counterBase, { + parentURL: import.meta.url, + resolver: 'import', + caches: 'all', +}); +const v2 = await import(counterBase); +assert.strictEqual(v2.count, 2); + +const wrapCount1 = queryObjects(ModuleWrap, { format: 'count' }); +// +1 new ModuleWrap (v2); old one kept alive by parent's static link. +assert.strictEqual(wrapCount1, wrapCount0 + 1, + 'Each clear+reimport cycle adds exactly one new ModuleWrap ' + + 'when a static parent holds the old instance'); + +// Cycle 2: clear counter again + re-import. +clearCache(counterBase, { + parentURL: import.meta.url, + resolver: 'import', + caches: 'all', +}); +const v3 = await import(counterBase); +assert.strictEqual(v3.count, 3); + +const wrapCount2 = queryObjects(ModuleWrap, { format: 'count' }); +// Another +1 (v3); v2 is no longer in loadCache and has no other strong +// holder, so it MAY have been collected already. v1 (pinned by parent) is +// still alive. Net growth is bounded by the number of active versions in +// any live strong reference — typically just the current one plus the +// parent-pinned original. +assert.ok( + wrapCount2 <= wrapCount1 + 1, + `After a second cycle, live ModuleWrap count should grow by at most 1 ` + + `(got ${wrapCount2}, was ${wrapCount1})`, +); + +delete globalThis.__module_cache_esm_counter; diff --git a/test/es-module/test-module-clear-cache-import-cjs-race.mjs b/test/es-module/test-module-clear-cache-import-cjs-race.mjs index 7edc8ca132f389..cf5fef7d7fee13 100644 --- a/test/es-module/test-module-clear-cache-import-cjs-race.mjs +++ b/test/es-module/test-module-clear-cache-import-cjs-race.mjs @@ -1,25 +1,94 @@ +// Tests race conditions between clearCache and concurrent dynamic imports +// of a CJS module loaded via import(). +// +// Scenarios covered: +// A) clearCache fires BEFORE the in-flight import promise settles: +// p = import(url); clearCache(url); result = await p +// The original import must still succeed with the first module instance. +// +// B) Two concurrent imports (sharing the same in-flight job) with clearCache +// between them, then a third import after clearing: +// p1 = import(url) → job1 created, cached +// p2 = import(url) → reuses job1 (same in-flight promise) +// clearCache(url) → removes job1 from cache +// p3 = import(url) → job3 created (fresh execution) +// [await all three] +// p1 and p2 must resolve to the SAME module instance (they shared job1). +// p3 must resolve to a DIFFERENT, freshly-executed module instance. +// +// C) clearCache fires AFTER the import has fully settled and another clear + +// import is done serially — basic sanity check that repeated cycles work. + import '../common/index.mjs'; import assert from 'node:assert'; import { clearCache } from 'node:module'; const url = new URL('../fixtures/module-cache/cjs-counter.js', import.meta.url); -const importPromise = import(url.href); +// ── Scenario A: clearCache before in-flight import settles ────────────────── + +const p_a = import(url.href); // in-flight; module not yet resolved clearCache(url, { parentURL: import.meta.url, resolver: 'import', caches: 'module', }); -const first = await importPromise; -assert.strictEqual(first.default.count, 1); +const result_a = await p_a; +// Scenario A: in-flight import must still resolve to the first instance. +assert.strictEqual(result_a.default.count, 1); + +// ── Scenario B: two concurrent imports share a job; clearCache between ────── + +// Re-seed for a clean counter baseline. +clearCache(url, { + parentURL: import.meta.url, + resolver: 'import', + caches: 'module', +}); + +delete globalThis.__module_cache_cjs_counter; + +// Both p_b1 and p_b2 start before clearCache → they share the same in-flight job. +const p_b1 = import(url.href); +const p_b2 = import(url.href); + +clearCache(url, { + parentURL: import.meta.url, + resolver: 'import', + caches: 'module', +}); + +// p_b3 starts after clearCache → gets a fresh independent job. +const p_b3 = import(url.href); + +const [r_b1, r_b2, r_b3] = await Promise.all([p_b1, p_b2, p_b3]); + +// p_b1 and p_b2 shared the same in-flight job → identical module namespace. +assert.strictEqual(r_b1, r_b2); +// Scenario B: shared job resolves to the first (re-seeded) instance. +assert.strictEqual(r_b1.default.count, 1); + +// p_b3 was created after clearCache → fresh execution, different instance. +assert.notStrictEqual(r_b3, r_b1); +assert.strictEqual(r_b3.default.count, 2); + +// ── Scenario C: serial repeated cycles ───────────────────────────────────── + +clearCache(url, { + parentURL: import.meta.url, + resolver: 'import', + caches: 'module', +}); +const r_c1 = await import(url.href); +assert.strictEqual(r_c1.default.count, 3); clearCache(url, { parentURL: import.meta.url, resolver: 'import', caches: 'module', }); -const second = await import(url.href); -assert.strictEqual(second.default.count, 2); +const r_c2 = await import(url.href); +assert.strictEqual(r_c2.default.count, 4); delete globalThis.__module_cache_cjs_counter; diff --git a/test/es-module/test-module-clear-cache-static-import-leak.mjs b/test/es-module/test-module-clear-cache-static-import-leak.mjs new file mode 100644 index 00000000000000..9e827985e1784d --- /dev/null +++ b/test/es-module/test-module-clear-cache-static-import-leak.mjs @@ -0,0 +1,106 @@ +// Flags: --expose-internals +// Verifies the V8-level memory-retention behaviour of clearCache when a module +// is statically imported by a still-live parent. +// +// BACKGROUND: +// When a parent module P statically imports M (via `import … from './M'`), +// V8's module instantiation creates an internal strong reference from P's +// compiled module record to M's module record. This link is permanent for +// the lifetime of P. Clearing M from Node.js's JS-level caches (loadCache / +// resolveCache) does NOT sever the V8-internal link; the old ModuleWrap for +// M stays alive as long as P is alive. +// +// Consequence: after `clearCache(M)` + `await import(M)`, TWO module +// instances coexist: +// - M_old : retained by P's V8-internal link, never re-executed by P +// - M_new : created by the fresh import(), seen by all NEW importers +// +// This is a *bounded* leak (one stale instance per cleared module per live +// static parent), not an unbounded one. It is unavoidable given the +// ECMA-262 HostLoadImportedModule idempotency requirement. +// +// For purely dynamic imports (no static parent holding them) the old +// ModuleWrap IS collectible after clearCache — see the second half of this +// test which uses checkIfCollectableByCounting to confirm that. + +import '../common/index.mjs'; + +import assert from 'node:assert'; +import { clearCache, createRequire } from 'node:module'; +import { queryObjects } from 'v8'; + +// Use createRequire to access CJS-only internals from this ESM file. +const require = createRequire(import.meta.url); +const { checkIfCollectableByCounting } = require('../common/gc'); +const { internalBinding } = require('internal/test/binding'); +const { ModuleWrap } = internalBinding('module_wrap'); + +const counterURL = new URL( + '../fixtures/module-cache/esm-counter.mjs', + import.meta.url, +).href; + +const parentURL = new URL( + '../fixtures/module-cache/esm-static-parent.mjs', + import.meta.url, +).href; + +// ── Part 1 : static-parent split-brain ────────────────────────────────────── +// Load the static parent, which in turn statically imports esm-counter.mjs. + +const parent = await import(parentURL); +assert.strictEqual(parent.count, 1); // counter runs once + +// Snapshot the number of live ModuleWraps before clearing. +const wrapsBefore = queryObjects(ModuleWrap, { format: 'count' }); + +// Clear the counter's Node-side caches (does NOT sever V8 static links). +clearCache(counterURL, { + parentURL: import.meta.url, + resolver: 'import', + caches: 'all', +}); + +// Re-import counter: a fresh instance is created and executed. +const fresh = await import(counterURL); +assert.strictEqual(fresh.count, 2); // New execution. +// Parent still sees the OLD instance via the V8-internal static link — +// the "split-brain" behaviour. +assert.strictEqual(parent.count, 1); + +// After the fresh import there should be MORE live ModuleWraps than before, +// because the old instance (held by the parent) was NOT collected. +const wrapsAfter = queryObjects(ModuleWrap, { format: 'count' }); +assert.ok( + wrapsAfter > wrapsBefore, + `Expected more live ModuleWraps after re-import (old instance retained ` + + `by static parent). before=${wrapsBefore}, after=${wrapsAfter}`, +); + +// ── Part 2 : dynamic-only modules ARE collectible ─────────────────────────── +// Prove that for purely-dynamic imports (no static parent), cleared modules +// can be garbage-collected. This confirms that the static-parent case is the +// source of the memory retention, not clearCache itself. + +const baseURL = new URL( + '../fixtures/module-cache/esm-counter.mjs', + import.meta.url, +).href; + +const outer = 8; +const inner = 4; + +await checkIfCollectableByCounting(async (i) => { + for (let j = 0; j < inner; j++) { + const url = `${baseURL}?leak-test=${i}-${j}`; + await import(url); + clearCache(url, { + parentURL: import.meta.url, + resolver: 'import', + caches: 'all', + }); + } + return inner; +}, ModuleWrap, outer, 50); + +delete globalThis.__module_cache_esm_counter; diff --git a/test/fixtures/module-cache/esm-static-parent.mjs b/test/fixtures/module-cache/esm-static-parent.mjs new file mode 100644 index 00000000000000..be6d5561490b57 --- /dev/null +++ b/test/fixtures/module-cache/esm-static-parent.mjs @@ -0,0 +1,3 @@ +// A parent module that statically imports esm-counter.mjs. +// Used by tests that verify memory retention of statically-linked modules. +export { count } from './esm-counter.mjs'; From d7340c081b13bd85773c4a2605d03cceaeafa8d6 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 27 Mar 2026 15:25:39 -0400 Subject: [PATCH 18/18] fixup! module: add clearCache for CJS and ESM --- doc/api/module.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/api/module.md b/doc/api/module.md index 90a208e92b22bf..874300c84ed3c0 100644 --- a/doc/api/module.md +++ b/doc/api/module.md @@ -138,8 +138,7 @@ garbage collection once `clearCache` removes it from Node.js caches and all JS-land references (e.g., stored namespace objects) are dropped. The safest pattern for hot-reload of ES modules is to use cache-busting search -parameters (so each version is a distinct module URL) and to avoid statically -importing modules that need to be reloaded: +parameters (so each version is a distinct module URL) and use dynamic imports for modules that need to be reloaded: #### ECMA-262 spec considerations