Skip to content

Commit a0f6257

Browse files
feat: add exact option to CachedInputFileSystem#purge (#591)
1 parent 658315c commit a0f6257

4 files changed

Lines changed: 207 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"enhanced-resolve": minor
3+
---
4+
5+
`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`.

lib/CachedInputFileSystem.js

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -367,23 +367,67 @@ class CacheBackend {
367367

368368
/**
369369
* @param {(string | Buffer | URL | number | (string | URL | Buffer | number)[] | Set<string | URL | Buffer | number>)=} what what to purge
370+
* @param {{ exact?: boolean }=} options options; `exact: true` removes only entries whose key matches `what` exactly instead of any entry whose key starts with `what`
370371
*/
371-
purge(what) {
372-
if (!what) {
372+
purge(what, options) {
373+
if (what === undefined || what === null) {
373374
if (this._mode !== STORAGE_MODE_IDLE) {
374375
this._data.clear();
375376
for (const level of this._levels) {
376377
level.clear();
377378
}
378379
this._enterIdleMode();
379380
}
380-
} else if (
381+
return;
382+
}
383+
const exact =
384+
options !== undefined && options !== null && options.exact === true;
385+
if (exact) {
386+
if (
387+
typeof what === "string" ||
388+
Buffer.isBuffer(what) ||
389+
what instanceof URL ||
390+
typeof what === "number"
391+
) {
392+
const strWhat = typeof what !== "string" ? what.toString() : what;
393+
const data = this._data.get(strWhat);
394+
if (data !== undefined) {
395+
this._data.delete(strWhat);
396+
data.level.delete(strWhat);
397+
}
398+
} else {
399+
for (const item of what) {
400+
const strItem = typeof item !== "string" ? item.toString() : item;
401+
const data = this._data.get(strItem);
402+
if (data !== undefined) {
403+
this._data.delete(strItem);
404+
data.level.delete(strItem);
405+
}
406+
}
407+
}
408+
if (this._data.size === 0) {
409+
this._enterIdleMode();
410+
}
411+
return;
412+
}
413+
if (
381414
typeof what === "string" ||
382415
Buffer.isBuffer(what) ||
383416
what instanceof URL ||
384417
typeof what === "number"
385418
) {
386419
const strWhat = typeof what !== "string" ? what.toString() : what;
420+
if (strWhat === "") {
421+
// empty string is a prefix of every key — short-circuit the O(n) scan
422+
if (this._mode !== STORAGE_MODE_IDLE) {
423+
this._data.clear();
424+
for (const level of this._levels) {
425+
level.clear();
426+
}
427+
this._enterIdleMode();
428+
}
429+
return;
430+
}
387431
for (const [key, data] of this._data) {
388432
if (key.startsWith(strWhat)) {
389433
this._data.delete(key);
@@ -414,7 +458,7 @@ class CacheBackend {
414458
* @param {(string | Buffer | URL | number | (string | URL | Buffer | number)[] | Set<string | URL | Buffer | number>)=} what what to purge
415459
*/
416460
purgeParent(what) {
417-
if (!what) {
461+
if (what === undefined || what === null) {
418462
this.purge();
419463
} else if (
420464
typeof what === "string" ||
@@ -673,14 +717,19 @@ module.exports = class CachedInputFileSystem {
673717

674718
/**
675719
* @param {(string | Buffer | URL | number | (string | URL | Buffer | number)[] | Set<string | URL | Buffer | number>)=} what what to purge
720+
* @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`
676721
*/
677-
purge(what) {
678-
this._statBackend.purge(what);
679-
this._lstatBackend.purge(what);
680-
this._readdirBackend.purgeParent(what);
681-
this._readFileBackend.purge(what);
682-
this._readlinkBackend.purge(what);
683-
this._readJsonBackend.purge(what);
684-
this._realpathBackend.purge(what);
722+
purge(what, options) {
723+
this._statBackend.purge(what, options);
724+
this._lstatBackend.purge(what, options);
725+
if (options !== undefined && options !== null && options.exact === true) {
726+
this._readdirBackend.purge(what, options);
727+
} else {
728+
this._readdirBackend.purgeParent(what);
729+
}
730+
this._readFileBackend.purge(what, options);
731+
this._readlinkBackend.purge(what, options);
732+
this._readJsonBackend.purge(what, options);
733+
this._realpathBackend.purge(what, options);
685734
}
686735
};

test/CachedInputFileSystem.test.js

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,146 @@ describe("cachedInputFileSystem CacheBackend", () => {
460460
});
461461
});
462462

463+
it("should not clear the entire cache when purging falsy keys 0 or ''", (done) => {
464+
// number 0 (a valid fd) and "" are valid cache keys per the signature;
465+
// passing them must not be confused with the no-arg "clear all" form.
466+
fs.stat("/test/path", (err, r1) => {
467+
r1.cached = true;
468+
fs.purge(0);
469+
fs.stat("/test/path", (err, r2) => {
470+
expect(r2.cached).toBe(true);
471+
fs.purge("");
472+
fs.stat("/test/path", (err, r3) => {
473+
// Empty string is a prefix of every key, so prefix-purge still clears
474+
// everything — but only because of the prefix semantics, not because
475+
// "" was misclassified as "no argument".
476+
expect(r3.cached).toBeUndefined();
477+
done();
478+
});
479+
});
480+
});
481+
});
482+
483+
it("should not crash when options is null", (done) => {
484+
fs.stat("/test/path", (err, r1) => {
485+
r1.cached = true;
486+
expect(() => fs.purge("/test/path", null)).not.toThrow();
487+
fs.stat("/test/path", (err, r2) => {
488+
// null is treated as "no options" — falls back to prefix purge
489+
expect(r2.cached).toBeUndefined();
490+
done();
491+
});
492+
});
493+
});
494+
495+
it("should purge exact entries only when exact: true", (done) => {
496+
fs.stat("/test/path", (err, r1) => {
497+
expect(r1.path).toBe("/test/path");
498+
r1.cached = true;
499+
fs.stat("/test/path/sub", (err, r2) => {
500+
expect(r2.path).toBe("/test/path/sub");
501+
r2.cached = true;
502+
// Prefix purge with exact: true should NOT remove the child entry
503+
fs.purge("/test/path", { exact: true });
504+
fs.stat("/test/path", (err, r3) => {
505+
// /test/path was the exact match, should be re-fetched (cached flag gone)
506+
expect(r3.cached).toBeUndefined();
507+
fs.stat("/test/path/sub", (err, r4) => {
508+
// /test/path/sub should still be cached
509+
expect(r4.cached).toBe(true);
510+
done();
511+
});
512+
});
513+
});
514+
});
515+
});
516+
517+
it("should purge an array exactly when exact: true", (done) => {
518+
fs.stat("/test/path", (err, r1) => {
519+
r1.cached = true;
520+
fs.stat("/test/path/sub", (err, r2) => {
521+
r2.cached = true;
522+
fs.stat("/other", (err, r3) => {
523+
r3.cached = true;
524+
fs.purge(["/test/path", "/other"], { exact: true });
525+
fs.stat("/test/path", (err, r4) => {
526+
expect(r4.cached).toBeUndefined();
527+
fs.stat("/test/path/sub", (err, r5) => {
528+
expect(r5.cached).toBe(true);
529+
fs.stat("/other", (err, r6) => {
530+
expect(r6.cached).toBeUndefined();
531+
done();
532+
});
533+
});
534+
});
535+
});
536+
});
537+
});
538+
});
539+
540+
it("should still prefix-purge when exact is not set or false", (done) => {
541+
fs.stat("/test/path", (err, r1) => {
542+
r1.cached = true;
543+
fs.stat("/test/path/sub", (err, r2) => {
544+
r2.cached = true;
545+
fs.purge("/test/path");
546+
fs.stat("/test/path", (err, r3) => {
547+
expect(r3.cached).toBeUndefined();
548+
fs.stat("/test/path/sub", (err, r4) => {
549+
expect(r4.cached).toBeUndefined();
550+
done();
551+
});
552+
});
553+
});
554+
});
555+
});
556+
557+
it("should purge readdir of the exact directory when exact: true", (done) => {
558+
fs.readdir("/test/path", (err, r1) => {
559+
expect(r1[0]).toBe("0");
560+
fs.readdir("/test/path/sub", (err, r2) => {
561+
expect(r2[0]).toBe("1");
562+
// Without exact, purging the dir path strips to dirname and prefix-purges
563+
// readdir for that parent — meaning readdir("/test/path") would NOT be evicted
564+
// by purge("/test/path/sub") in legacy mode (parent is "/test/path/", child of
565+
// which is "/test/path/sub", but readdir key is "/test/path"). With exact: true
566+
// the caller is naming the directory itself, so it should evict directly.
567+
fs.purge("/test/path", { exact: true });
568+
fs.readdir("/test/path", (err, r3) => {
569+
expect(r3[0]).toBe("2");
570+
// sibling readdir cache should still be intact
571+
fs.readdir("/test/path/sub", (err, r4) => {
572+
expect(r4[0]).toBe("1");
573+
done();
574+
});
575+
});
576+
});
577+
});
578+
});
579+
580+
it("should accept Buffer with exact: true", (done) => {
581+
fs.stat("/test/path", (err, r1) => {
582+
r1.cached = true;
583+
fs.stat("/test/path/sub", (err, r2) => {
584+
r2.cached = true;
585+
fs.purge(Buffer.from("/test/path"), { exact: true });
586+
fs.stat("/test/path", (err, r3) => {
587+
expect(r3.cached).toBeUndefined();
588+
fs.stat("/test/path/sub", (err, r4) => {
589+
expect(r4.cached).toBe(true);
590+
fs.purge([Buffer.from("/test/path/sub")], {
591+
exact: true,
592+
});
593+
fs.stat("/test/path/sub", (err, r5) => {
594+
expect(r5.cached).toBeUndefined();
595+
done();
596+
});
597+
});
598+
});
599+
});
600+
});
601+
});
602+
463603
it("should not stack overflow when resolving in an async loop", (done) => {
464604
let i = 10000;
465605
const next = () => {

types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ declare class CachedInputFileSystem {
145145
| URL_url
146146
| (string | number | Buffer | URL_url)[]
147147
| Set<string | number | Buffer | URL_url>,
148+
options?: { exact?: boolean },
148149
): void;
149150
}
150151
declare class CloneBasenamePlugin {

0 commit comments

Comments
 (0)