From b7788b4b006f981e95128e3cf93503ab988e6f30 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Tue, 10 Feb 2026 13:14:09 -0800 Subject: [PATCH 01/13] 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/13] 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; }