Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/feat-purge-exact-matching.md
Original file line number Diff line number Diff line change
@@ -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`.
73 changes: 61 additions & 12 deletions lib/CachedInputFileSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -367,23 +367,67 @@ class CacheBackend {

/**
* @param {(string | Buffer | URL | number | (string | URL | Buffer | number)[] | Set<string | URL | Buffer | number>)=} 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) {
level.clear();
}
this._enterIdleMode();
}
} else if (
return;
}
Comment on lines 372 to +382
const exact =
options !== undefined && options !== null && options.exact === true;
if (exact) {
Comment on lines +372 to +385
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);
Expand Down Expand Up @@ -414,7 +458,7 @@ class CacheBackend {
* @param {(string | Buffer | URL | number | (string | URL | Buffer | number)[] | Set<string | URL | Buffer | number>)=} what what to purge
*/
purgeParent(what) {
if (!what) {
if (what === undefined || what === null) {
this.purge();
} else if (
typeof what === "string" ||
Expand Down Expand Up @@ -673,14 +717,19 @@ module.exports = class CachedInputFileSystem {

/**
* @param {(string | Buffer | URL | number | (string | URL | Buffer | number)[] | Set<string | URL | Buffer | number>)=} 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);
}
};
140 changes: 140 additions & 0 deletions test/CachedInputFileSystem.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
1 change: 1 addition & 0 deletions types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ declare class CachedInputFileSystem {
| URL_url
| (string | number | Buffer | URL_url)[]
| Set<string | number | Buffer | URL_url>,
options?: { exact?: boolean },
): void;
}
declare class CloneBasenamePlugin {
Expand Down
Loading