From 6d79dc2a942aa16364f6b20a00e6aa0adcbdebba Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Thu, 2 Apr 2026 17:01:07 -0400 Subject: [PATCH] module: add clearCache for CJS and ESM --- doc/api/module.md | 130 ++++++++ lib/internal/modules/cjs/loader.js | 27 +- lib/internal/modules/clear.js | 289 ++++++++++++++++++ lib/internal/modules/esm/loader.js | 23 ++ lib/internal/modules/esm/module_map.js | 34 +++ lib/internal/modules/esm/translators.js | 39 ++- lib/internal/modules/helpers.js | 28 ++ lib/internal/modules/package_json_reader.js | 24 ++ lib/module.js | 2 + src/node_modules.cc | 22 ++ src/node_modules.h | 2 + .../test-module-clear-cache-cross-system.mjs | 60 ++++ .../test-module-clear-cache-hash-map.mjs | 112 +++++++ ...est-module-clear-cache-import-cjs-race.mjs | 89 ++++++ ...test-module-clear-cache-query-variants.mjs | 21 ++ ...-module-clear-cache-static-import-leak.mjs | 104 +++++++ .../test-module-clear-cache-wasm-leak.mjs | 29 ++ .../test-module-clear-cache-wasm.mjs | 19 ++ test/es-module/test-module-clear-cache.mjs | 38 +++ test/fixtures/module-cache/cjs-child.js | 6 + test/fixtures/module-cache/cjs-counter.js | 5 + test/fixtures/module-cache/cjs-parent.js | 5 + test/fixtures/module-cache/esm-counter.mjs | 4 + test/fixtures/module-cache/esm-nested-a.mjs | 3 + test/fixtures/module-cache/esm-nested-b.mjs | 6 + test/fixtures/module-cache/esm-nested-c.mjs | 4 + .../module-cache/esm-query-counter.mjs | 4 + .../module-cache/esm-static-parent.mjs | 3 + .../source-map/cjs-closure-source-map.js | 9 + ...ule-hooks-clear-cache-import-attributes.js | 80 +++++ .../test-module-hooks-clear-cache-redirect.js | 55 ++++ ...-module-hooks-clear-cache-resolve-cache.js | 38 +++ .../test-module-hooks-clear-cache.js | 49 +++ .../test-module-clear-cache-children.js | 30 ++ .../test-module-clear-cache-cjs-cache.js | 39 +++ .../test-module-clear-cache-cjs-resolution.js | 36 +++ test/parallel/test-module-clear-cache-leak.js | 27 ++ ...est-module-clear-cache-module-wrap-leak.js | 40 +++ .../test-module-clear-cache-options.js | 142 +++++++++ .../test-module-clear-cache-parent-links.js | 42 +++ ...test-module-clear-cache-pkgjson-exports.js | 54 ++++ ...t-module-clear-cache-source-map-closure.js | 33 ++ test/parallel/test-module-clear-cache.js | 29 ++ typings/internalBinding/modules.d.ts | 1 + 44 files changed, 1834 insertions(+), 2 deletions(-) create mode 100644 lib/internal/modules/clear.js create mode 100644 test/es-module/test-module-clear-cache-cross-system.mjs create mode 100644 test/es-module/test-module-clear-cache-hash-map.mjs create mode 100644 test/es-module/test-module-clear-cache-import-cjs-race.mjs create mode 100644 test/es-module/test-module-clear-cache-query-variants.mjs create mode 100644 test/es-module/test-module-clear-cache-static-import-leak.mjs create mode 100644 test/es-module/test-module-clear-cache-wasm-leak.mjs create mode 100644 test/es-module/test-module-clear-cache-wasm.mjs create mode 100644 test/es-module/test-module-clear-cache.mjs create mode 100644 test/fixtures/module-cache/cjs-child.js create mode 100644 test/fixtures/module-cache/cjs-counter.js create mode 100644 test/fixtures/module-cache/cjs-parent.js create mode 100644 test/fixtures/module-cache/esm-counter.mjs create mode 100644 test/fixtures/module-cache/esm-nested-a.mjs create mode 100644 test/fixtures/module-cache/esm-nested-b.mjs create mode 100644 test/fixtures/module-cache/esm-nested-c.mjs create mode 100644 test/fixtures/module-cache/esm-query-counter.mjs create mode 100644 test/fixtures/module-cache/esm-static-parent.mjs create mode 100644 test/fixtures/source-map/cjs-closure-source-map.js create mode 100644 test/module-hooks/test-module-hooks-clear-cache-import-attributes.js create mode 100644 test/module-hooks/test-module-hooks-clear-cache-redirect.js create mode 100644 test/module-hooks/test-module-hooks-clear-cache-resolve-cache.js create mode 100644 test/module-hooks/test-module-hooks-clear-cache.js create mode 100644 test/parallel/test-module-clear-cache-children.js create mode 100644 test/parallel/test-module-clear-cache-cjs-cache.js create mode 100644 test/parallel/test-module-clear-cache-cjs-resolution.js create mode 100644 test/parallel/test-module-clear-cache-leak.js create mode 100644 test/parallel/test-module-clear-cache-module-wrap-leak.js create mode 100644 test/parallel/test-module-clear-cache-options.js create mode 100644 test/parallel/test-module-clear-cache-parent-links.js create mode 100644 test/parallel/test-module-clear-cache-pkgjson-exports.js create mode 100644 test/parallel/test-module-clear-cache-source-map-closure.js create mode 100644 test/parallel/test-module-clear-cache.js diff --git a/doc/api/module.md b/doc/api/module.md index ebede62eeca3a4..acab91d041be76 100644 --- a/doc/api/module.md +++ b/doc/api/module.md @@ -66,6 +66,135 @@ const require = createRequire(import.meta.url); const siblingModule = require('./sibling-module'); ``` +### `module.clearCache(specifier, options)` + + + +> Stability: 1.0 - Early development + +* `specifier` {string|URL} The module specifier, as it would have been passed to + `import()` or `require()`. +* `options` {Object} + * `parentURL` {string|URL} The parent URL used to resolve the specifier. Parent identity + is part of the resolution cache key. For CommonJS, pass `pathToFileURL(__filename)`. + For ES modules, pass `import.meta.url`. + * `resolver` {string} Specifies how resolution should be performed. Must be either + `'import'` or `'require'`. + * `importAttributes` {Object} Optional import attributes. Only meaningful when + `resolver` is `'import'`. + +Clears the module resolution and module caches for a module. This enables +reload patterns similar to deleting from `require.cache` in CommonJS, and is useful for +hot module reload. + +The specifier is resolved using the chosen `resolver`, then the resolved module is removed +from all internal caches (CommonJS `require` cache, CommonJS resolution caches, ESM resolve +cache, ESM load cache, and ESM translators cache). When `resolver` is `'import'`, +`importAttributes` are part of the ESM resolve-cache key, so only the exact +`(specifier, parentURL, importAttributes)` resolution entry is removed. When a `file:` URL is +resolved, cached module jobs for 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 `resolver` is `'require'`, cached `package.json` data for the resolved module's package +is also cleared so that updated exports/imports conditions are picked up on the next +resolution. + +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. + +#### 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 use dynamic imports for modules that need to be reloaded: + +#### ECMA-262 spec considerations + +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. 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 +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', + }); + 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'; + +await import('./mod.mjs'); + +clearCache('./mod.mjs', { + parentURL: import.meta.url, + resolver: 'import', +}); +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: pathToFileURL(__filename), + resolver: 'require', +}); +require('./mod.js'); // eslint-disable-line node-core/no-duplicate-requires +// re-executes the module +``` + ### `module.findPackageJSON(specifier[, base])`