From 76166af69e96996a1c3d1c1c8fdafb215a1b5e22 Mon Sep 17 00:00:00 2001 From: sheo13666q Date: Fri, 22 May 2026 08:07:22 +0000 Subject: [PATCH 1/5] feat: add exact option to CachedInputFileSystem#purge Pass { exact: true } as the second argument to purge to remove only cache entries whose key matches the input exactly, instead of the default prefix-match behavior. Useful when callers know a specific file changed and want to avoid evicting unrelated descendants under the same path prefix. --- .changeset/feat-purge-exact-matching.md | 5 ++ lib/CachedInputFileSystem.js | 60 +++++++++++++---- test/CachedInputFileSystem.test.js | 85 +++++++++++++++++++++++++ types.d.ts | 1 + 4 files changed, 138 insertions(+), 13 deletions(-) create mode 100644 .changeset/feat-purge-exact-matching.md diff --git a/.changeset/feat-purge-exact-matching.md b/.changeset/feat-purge-exact-matching.md new file mode 100644 index 00000000..c4f3cb83 --- /dev/null +++ b/.changeset/feat-purge-exact-matching.md @@ -0,0 +1,5 @@ +--- +"enhanced-resolve": minor +--- + +`CachedInputFileSystem#purge` accepts a second `{ exact?: boolean }` argument; `exact: true` removes only entries whose key matches `what` exactly instead of any entry whose key starts with `what`. diff --git a/lib/CachedInputFileSystem.js b/lib/CachedInputFileSystem.js index 17e66a9d..c8d30160 100644 --- a/lib/CachedInputFileSystem.js +++ b/lib/CachedInputFileSystem.js @@ -367,8 +367,9 @@ class CacheBackend { /** * @param {(string | Buffer | URL | number | (string | URL | Buffer | number)[] | Set)=} what what to purge + * @param {{ exact?: boolean }=} options options; `exact: true` removes only entries whose key matches `what` exactly instead of any entry whose key starts with `what` */ - purge(what) { + purge(what, options) { if (!what) { if (this._mode !== STORAGE_MODE_IDLE) { this._data.clear(); @@ -377,7 +378,38 @@ class CacheBackend { } this._enterIdleMode(); } - } else if ( + return; + } + const exact = options !== undefined && options.exact === true; + if (exact) { + if ( + typeof what === "string" || + Buffer.isBuffer(what) || + what instanceof URL || + typeof what === "number" + ) { + const strWhat = typeof what !== "string" ? what.toString() : what; + const data = this._data.get(strWhat); + if (data !== undefined) { + this._data.delete(strWhat); + data.level.delete(strWhat); + } + } else { + for (const item of what) { + const strItem = typeof item !== "string" ? item.toString() : item; + const data = this._data.get(strItem); + if (data !== undefined) { + this._data.delete(strItem); + data.level.delete(strItem); + } + } + } + if (this._data.size === 0) { + this._enterIdleMode(); + } + return; + } + if ( typeof what === "string" || Buffer.isBuffer(what) || what instanceof URL || @@ -412,8 +444,9 @@ class CacheBackend { /** * @param {(string | Buffer | URL | number | (string | URL | Buffer | number)[] | Set)=} what what to purge + * @param {{ exact?: boolean }=} options options forwarded to {@link purge} */ - purgeParent(what) { + purgeParent(what, options) { if (!what) { this.purge(); } else if ( @@ -423,14 +456,14 @@ class CacheBackend { typeof what === "number" ) { const strWhat = typeof what !== "string" ? what.toString() : what; - this.purge(dirname(strWhat)); + this.purge(dirname(strWhat), options); } else { const set = new Set(); for (const item of what) { const strItem = typeof item !== "string" ? item.toString() : item; set.add(dirname(strItem)); } - this.purge(set); + this.purge(set, options); } } @@ -673,14 +706,15 @@ module.exports = class CachedInputFileSystem { /** * @param {(string | Buffer | URL | number | (string | URL | Buffer | number)[] | Set)=} what what to purge + * @param {{ exact?: boolean }=} options options; `exact: true` removes only cache entries whose key matches `what` exactly instead of any entry whose key starts with `what` */ - purge(what) { - this._statBackend.purge(what); - this._lstatBackend.purge(what); - this._readdirBackend.purgeParent(what); - this._readFileBackend.purge(what); - this._readlinkBackend.purge(what); - this._readJsonBackend.purge(what); - this._realpathBackend.purge(what); + purge(what, options) { + this._statBackend.purge(what, options); + this._lstatBackend.purge(what, options); + this._readdirBackend.purgeParent(what, options); + this._readFileBackend.purge(what, options); + this._readlinkBackend.purge(what, options); + this._readJsonBackend.purge(what, options); + this._realpathBackend.purge(what, options); } }; diff --git a/test/CachedInputFileSystem.test.js b/test/CachedInputFileSystem.test.js index f39a4748..27dcc385 100644 --- a/test/CachedInputFileSystem.test.js +++ b/test/CachedInputFileSystem.test.js @@ -460,6 +460,91 @@ describe("cachedInputFileSystem CacheBackend", () => { }); }); + it("should purge exact entries only when exact: true", (done) => { + fs.stat("/test/path", (err, r1) => { + expect(r1.path).toBe("/test/path"); + r1.cached = true; + fs.stat("/test/path/sub", (err, r2) => { + expect(r2.path).toBe("/test/path/sub"); + r2.cached = true; + // Prefix purge with exact: true should NOT remove the child entry + fs.purge("/test/path", { exact: true }); + fs.stat("/test/path", (err, r3) => { + // /test/path was the exact match, should be re-fetched (cached flag gone) + expect(r3.cached).toBeUndefined(); + fs.stat("/test/path/sub", (err, r4) => { + // /test/path/sub should still be cached + expect(r4.cached).toBe(true); + done(); + }); + }); + }); + }); + }); + + it("should purge an array exactly when exact: true", (done) => { + fs.stat("/test/path", (err, r1) => { + r1.cached = true; + fs.stat("/test/path/sub", (err, r2) => { + r2.cached = true; + fs.stat("/other", (err, r3) => { + r3.cached = true; + fs.purge(["/test/path", "/other"], { exact: true }); + fs.stat("/test/path", (err, r4) => { + expect(r4.cached).toBeUndefined(); + fs.stat("/test/path/sub", (err, r5) => { + expect(r5.cached).toBe(true); + fs.stat("/other", (err, r6) => { + expect(r6.cached).toBeUndefined(); + done(); + }); + }); + }); + }); + }); + }); + }); + + it("should still prefix-purge when exact is not set or false", (done) => { + fs.stat("/test/path", (err, r1) => { + r1.cached = true; + fs.stat("/test/path/sub", (err, r2) => { + r2.cached = true; + fs.purge("/test/path"); + fs.stat("/test/path", (err, r3) => { + expect(r3.cached).toBeUndefined(); + fs.stat("/test/path/sub", (err, r4) => { + expect(r4.cached).toBeUndefined(); + done(); + }); + }); + }); + }); + }); + + it("should accept Buffer with exact: true", (done) => { + fs.stat("/test/path", (err, r1) => { + r1.cached = true; + fs.stat("/test/path/sub", (err, r2) => { + r2.cached = true; + fs.purge(Buffer.from("/test/path"), { exact: true }); + fs.stat("/test/path", (err, r3) => { + expect(r3.cached).toBeUndefined(); + fs.stat("/test/path/sub", (err, r4) => { + expect(r4.cached).toBe(true); + fs.purge([Buffer.from("/test/path/sub")], { + exact: true, + }); + fs.stat("/test/path/sub", (err, r5) => { + expect(r5.cached).toBeUndefined(); + done(); + }); + }); + }); + }); + }); + }); + it("should not stack overflow when resolving in an async loop", (done) => { let i = 10000; const next = () => { diff --git a/types.d.ts b/types.d.ts index 997900d1..79d19624 100644 --- a/types.d.ts +++ b/types.d.ts @@ -145,6 +145,7 @@ declare class CachedInputFileSystem { | URL_url | (string | number | Buffer | URL_url)[] | Set, + options?: { exact?: boolean }, ): void; } declare class CloneBasenamePlugin { From 932f90ba75398ced3d548aab294e38fa9a4057de Mon Sep 17 00:00:00 2001 From: sheo13666q Date: Fri, 22 May 2026 08:08:55 +0000 Subject: [PATCH 2/5] fix: route exact-mode purge directly to readdir backend When exact: true is set, the caller is naming the exact directory whose readdir cache should be evicted, so going through purgeParent (which strips to dirname before purging) targets the wrong key. Call the readdir backend's purge directly in exact mode instead. --- lib/CachedInputFileSystem.js | 13 ++++++++----- test/CachedInputFileSystem.test.js | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/lib/CachedInputFileSystem.js b/lib/CachedInputFileSystem.js index c8d30160..e8b20400 100644 --- a/lib/CachedInputFileSystem.js +++ b/lib/CachedInputFileSystem.js @@ -444,9 +444,8 @@ class CacheBackend { /** * @param {(string | Buffer | URL | number | (string | URL | Buffer | number)[] | Set)=} what what to purge - * @param {{ exact?: boolean }=} options options forwarded to {@link purge} */ - purgeParent(what, options) { + purgeParent(what) { if (!what) { this.purge(); } else if ( @@ -456,14 +455,14 @@ class CacheBackend { typeof what === "number" ) { const strWhat = typeof what !== "string" ? what.toString() : what; - this.purge(dirname(strWhat), options); + this.purge(dirname(strWhat)); } else { const set = new Set(); for (const item of what) { const strItem = typeof item !== "string" ? item.toString() : item; set.add(dirname(strItem)); } - this.purge(set, options); + this.purge(set); } } @@ -711,7 +710,11 @@ module.exports = class CachedInputFileSystem { purge(what, options) { this._statBackend.purge(what, options); this._lstatBackend.purge(what, options); - this._readdirBackend.purgeParent(what, options); + if (options && options.exact) { + this._readdirBackend.purge(what, options); + } else { + this._readdirBackend.purgeParent(what); + } this._readFileBackend.purge(what, options); this._readlinkBackend.purge(what, options); this._readJsonBackend.purge(what, options); diff --git a/test/CachedInputFileSystem.test.js b/test/CachedInputFileSystem.test.js index 27dcc385..90ea4e5f 100644 --- a/test/CachedInputFileSystem.test.js +++ b/test/CachedInputFileSystem.test.js @@ -522,6 +522,29 @@ describe("cachedInputFileSystem CacheBackend", () => { }); }); + it("should purge readdir of the exact directory when exact: true", (done) => { + fs.readdir("/test/path", (err, r1) => { + expect(r1[0]).toBe("0"); + fs.readdir("/test/path/sub", (err, r2) => { + expect(r2[0]).toBe("1"); + // Without exact, purging the dir path strips to dirname and prefix-purges + // readdir for that parent — meaning readdir("/test/path") would NOT be evicted + // by purge("/test/path/sub") in legacy mode (parent is "/test/path/", child of + // which is "/test/path/sub", but readdir key is "/test/path"). With exact: true + // the caller is naming the directory itself, so it should evict directly. + fs.purge("/test/path", { exact: true }); + fs.readdir("/test/path", (err, r3) => { + expect(r3[0]).toBe("2"); + // sibling readdir cache should still be intact + fs.readdir("/test/path/sub", (err, r4) => { + expect(r4[0]).toBe("1"); + done(); + }); + }); + }); + }); + }); + it("should accept Buffer with exact: true", (done) => { fs.stat("/test/path", (err, r1) => { r1.cached = true; From 2d7568f33841124fe9b0ae4748f94d5bf0e13876 Mon Sep 17 00:00:00 2001 From: sheo13666q Date: Fri, 22 May 2026 08:22:11 +0000 Subject: [PATCH 3/5] fix: handle null options consistently in purge - CacheBackend.purge: null options now treated as no-options instead of crashing on options.exact dereference. - CachedInputFileSystem.purge: switch the readdir branch to the same strict === true check used by CacheBackend so exact-mode is enabled consistently across all backends. - Add a regression test for purge(what, null). --- lib/CachedInputFileSystem.js | 5 +++-- test/CachedInputFileSystem.test.js | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/CachedInputFileSystem.js b/lib/CachedInputFileSystem.js index e8b20400..d75ceb1c 100644 --- a/lib/CachedInputFileSystem.js +++ b/lib/CachedInputFileSystem.js @@ -380,7 +380,8 @@ class CacheBackend { } return; } - const exact = options !== undefined && options.exact === true; + const exact = + options !== undefined && options !== null && options.exact === true; if (exact) { if ( typeof what === "string" || @@ -710,7 +711,7 @@ module.exports = class CachedInputFileSystem { purge(what, options) { this._statBackend.purge(what, options); this._lstatBackend.purge(what, options); - if (options && options.exact) { + if (options !== undefined && options !== null && options.exact === true) { this._readdirBackend.purge(what, options); } else { this._readdirBackend.purgeParent(what); diff --git a/test/CachedInputFileSystem.test.js b/test/CachedInputFileSystem.test.js index 90ea4e5f..d13f25a0 100644 --- a/test/CachedInputFileSystem.test.js +++ b/test/CachedInputFileSystem.test.js @@ -460,6 +460,18 @@ describe("cachedInputFileSystem CacheBackend", () => { }); }); + it("should not crash when options is null", (done) => { + fs.stat("/test/path", (err, r1) => { + r1.cached = true; + expect(() => fs.purge("/test/path", null)).not.toThrow(); + fs.stat("/test/path", (err, r2) => { + // null is treated as "no options" — falls back to prefix purge + expect(r2.cached).toBeUndefined(); + done(); + }); + }); + }); + it("should purge exact entries only when exact: true", (done) => { fs.stat("/test/path", (err, r1) => { expect(r1.path).toBe("/test/path"); From 14132ddbad3412b27091e6f03588ad5e88c15112 Mon Sep 17 00:00:00 2001 From: sheo13666q Date: Fri, 22 May 2026 08:30:40 +0000 Subject: [PATCH 4/5] fix: don't treat falsy what (0, '') as 'purge everything' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy guard if (!what) confused valid cache keys — 0 (a file descriptor, included in the signature) and '' — with the no-argument 'purge all' form. Tighten to an explicit null/undefined check in both CacheBackend.purge and CacheBackend.purgeParent. --- lib/CachedInputFileSystem.js | 4 ++-- test/CachedInputFileSystem.test.js | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/CachedInputFileSystem.js b/lib/CachedInputFileSystem.js index d75ceb1c..84c2cf06 100644 --- a/lib/CachedInputFileSystem.js +++ b/lib/CachedInputFileSystem.js @@ -370,7 +370,7 @@ class CacheBackend { * @param {{ exact?: boolean }=} options options; `exact: true` removes only entries whose key matches `what` exactly instead of any entry whose key starts with `what` */ purge(what, options) { - if (!what) { + if (what === undefined || what === null) { if (this._mode !== STORAGE_MODE_IDLE) { this._data.clear(); for (const level of this._levels) { @@ -447,7 +447,7 @@ class CacheBackend { * @param {(string | Buffer | URL | number | (string | URL | Buffer | number)[] | Set)=} what what to purge */ purgeParent(what) { - if (!what) { + if (what === undefined || what === null) { this.purge(); } else if ( typeof what === "string" || diff --git a/test/CachedInputFileSystem.test.js b/test/CachedInputFileSystem.test.js index d13f25a0..cc520fff 100644 --- a/test/CachedInputFileSystem.test.js +++ b/test/CachedInputFileSystem.test.js @@ -460,6 +460,26 @@ describe("cachedInputFileSystem CacheBackend", () => { }); }); + it("should not clear the entire cache when purging falsy keys 0 or ''", (done) => { + // number 0 (a valid fd) and "" are valid cache keys per the signature; + // passing them must not be confused with the no-arg "clear all" form. + fs.stat("/test/path", (err, r1) => { + r1.cached = true; + fs.purge(0); + fs.stat("/test/path", (err, r2) => { + expect(r2.cached).toBe(true); + fs.purge(""); + fs.stat("/test/path", (err, r3) => { + // Empty string is a prefix of every key, so prefix-purge still clears + // everything — but only because of the prefix semantics, not because + // "" was misclassified as "no argument". + expect(r3.cached).toBeUndefined(); + done(); + }); + }); + }); + }); + it("should not crash when options is null", (done) => { fs.stat("/test/path", (err, r1) => { r1.cached = true; From c8ca73575622c0da84320019cdf6596e4ad5b76c Mon Sep 17 00:00:00 2001 From: sheo13666q Date: Fri, 22 May 2026 08:44:04 +0000 Subject: [PATCH 5/5] perf: short-circuit purge('') back to O(1) clear-all Previously purge('') hit the !what fast-path and was an O(1) Map#clear. After tightening the !what guard to undefined/null only, '' fell into the prefix branch and became an O(n) scan even though it still matches every key. Restore the fast path for the empty-string prefix. --- lib/CachedInputFileSystem.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/CachedInputFileSystem.js b/lib/CachedInputFileSystem.js index 84c2cf06..39eab864 100644 --- a/lib/CachedInputFileSystem.js +++ b/lib/CachedInputFileSystem.js @@ -417,6 +417,17 @@ class CacheBackend { typeof what === "number" ) { const strWhat = typeof what !== "string" ? what.toString() : what; + if (strWhat === "") { + // empty string is a prefix of every key — short-circuit the O(n) scan + if (this._mode !== STORAGE_MODE_IDLE) { + this._data.clear(); + for (const level of this._levels) { + level.clear(); + } + this._enterIdleMode(); + } + return; + } for (const [key, data] of this._data) { if (key.startsWith(strWhat)) { this._data.delete(key);