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..39eab864 100644 --- a/lib/CachedInputFileSystem.js +++ b/lib/CachedInputFileSystem.js @@ -367,9 +367,10 @@ 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) { - if (!what) { + purge(what, options) { + if (what === undefined || what === null) { if (this._mode !== STORAGE_MODE_IDLE) { this._data.clear(); for (const level of this._levels) { @@ -377,13 +378,56 @@ class CacheBackend { } this._enterIdleMode(); } - } else if ( + return; + } + const exact = + options !== undefined && options !== null && 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 || 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); @@ -414,7 +458,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" || @@ -673,14 +717,19 @@ 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); + if (options !== undefined && options !== null && options.exact === true) { + 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); + this._realpathBackend.purge(what, options); } }; diff --git a/test/CachedInputFileSystem.test.js b/test/CachedInputFileSystem.test.js index f39a4748..cc520fff 100644 --- a/test/CachedInputFileSystem.test.js +++ b/test/CachedInputFileSystem.test.js @@ -460,6 +460,146 @@ 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; + 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"); + 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 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; + 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 {