diff --git a/.changeset/case-insensitive-time-info.md b/.changeset/case-insensitive-time-info.md new file mode 100644 index 0000000..409783f --- /dev/null +++ b/.changeset/case-insensitive-time-info.md @@ -0,0 +1,5 @@ +--- +"watchpack": patch +--- + +On case-insensitive file systems, give a file watched with other casing (e.g. imported as `./B` while the file is `b.js`) the time info of the file instead of `null`, so webpack rebuilds it on every change, not only the first (fixes #228). diff --git a/lib/DirectoryWatcher.js b/lib/DirectoryWatcher.js index 72f6aab..bbdb6af 100644 --- a/lib/DirectoryWatcher.js +++ b/lib/DirectoryWatcher.js @@ -233,6 +233,10 @@ class DirectoryWatcher extends EventEmitter { this.files = new Map(); /** @type {Map} */ this.filesWithoutCase = new Map(); + // whether names in this directory ignore case, learned the first time a + // watched path differs from a file on disk only by case + /** @type {boolean | undefined} */ + this.caseInsensitive = undefined; /** @type {Map | boolean>} */ this.directories = new Map(); /** @type {Map>} */ @@ -1155,7 +1159,13 @@ class DirectoryWatcher extends EventEmitter { for (const watcher of watchers) { const { path } = watcher; if (!fileTimestamps.has(path)) { - fileTimestamps.set(path, null); + // on a case-insensitive file system a path with other casing + // is the same file and must change with it + const file = this.getFileWithOtherCase(path); + fileTimestamps.set( + path, + (file !== undefined && this.files.get(file)) || null, + ); } } } @@ -1163,6 +1173,49 @@ class DirectoryWatcher extends EventEmitter { return safeTime; } + /** + * @param {string} target a watched path that is not in `files` + * @returns {string | undefined} the file it names on a case-insensitive file system + */ + getFileWithOtherCase(target) { + if (this.caseInsensitive === false) return; + const key = withoutCase(target); + if (!this.filesWithoutCase.has(key)) return; + let file; + for (const item of this.files.keys()) { + if (withoutCase(item) === key) { + file = item; + break; + } + } + if (file === undefined) return; + if (this.caseInsensitive === undefined) { + let fileStats; + try { + fileStats = fs.lstatSync(file); + } catch (_err) { + // changed meanwhile, the next scan updates `files` + return; + } + try { + const targetStats = fs.lstatSync(target); + this.caseInsensitive = + targetStats.ino === fileStats.ino && + targetStats.dev === fileStats.dev; + } catch (err) { + // only a missing name proves case matters; EBUSY and the like + // leave it to the next collection + if (/** @type {NodeJS.ErrnoException} */ (err).code !== "ENOENT") { + return; + } + // a file removed meanwhile says nothing about the file system + if (!fs.existsSync(file)) return; + this.caseInsensitive = false; + } + } + return this.caseInsensitive ? file : undefined; + } + close() { if (this.closed) return; this.closed = true; diff --git a/test/Casing.test.js b/test/Casing.test.js index 5367fb5..e3c0e17 100644 --- a/test/Casing.test.js +++ b/test/Casing.test.js @@ -101,11 +101,10 @@ if (fsIsCaseInsensitive) { expect(changes).toContain(path.join(fixtures, testFileRename)); expect(removals).not.toContain(path.join(fixtures, testFileRename)); - for (const file of files.keys()) { - if (file.endsWith("hello.txt") && files.get(file)) { - return done(new Error("Renamed file was still in timeInfoEntries")); - } - } + // the old casing still names the renamed file + expect(files.get(path.join(fixtures, testFile))).toBe( + files.get(path.join(fixtures, testFileRename)), + ); return done(); }); @@ -120,11 +119,57 @@ if (fsIsCaseInsensitive) { }); }); }); + + it("should give a file watched with the wrong casing the time info of the file (#228)", (done) => { + testHelper.file("a"); + testHelper.tick(1000, () => { + const w = new Watchpack({ + aggregateTimeout: 100, + }); + const wrongCase = path.join(fixtures, "A"); + w.watch({ files: [wrongCase] }); + testHelper.tick(500, () => { + const before = w.getTimeInfoEntries().get(wrongCase); + expect(before).toBeTruthy(); + w.on("aggregated", () => { + const files = w.getTimeInfoEntries(); + w.close(); + const after = files.get(wrongCase); + expect(after).toBe(files.get(path.join(fixtures, "a"))); + expect(after).not.toEqual(before); + done(); + }); + testHelper.file("a"); + }); + }); + }); }); } else { - describe("casing (no tests)", () => { - it("pass", () => { - expect(true).toBe(true); + describe("casing (case-sensitive file system)", () => { + beforeEach((done) => { + testHelper.before(done); + }); + + afterEach((done) => { + testHelper.after(done); + }); + + it("should report a path with other casing as missing on a case-sensitive file system", (done) => { + testHelper.file("a"); + testHelper.tick(() => { + const w = new Watchpack({ + aggregateTimeout: 100, + }); + const wrongCase = path.join(fixtures, "A"); + w.watch({ files: [wrongCase] }); + testHelper.tick(500, () => { + const files = w.getTimeInfoEntries(); + w.close(); + expect(files.get(wrongCase)).toBeNull(); + expect(files.get(path.join(fixtures, "a"))).toBeTruthy(); + done(); + }); + }); }); }); } diff --git a/test/DirectoryWatcherExtra.test.js b/test/DirectoryWatcherExtra.test.js index ec42622..160b57e 100644 --- a/test/DirectoryWatcherExtra.test.js +++ b/test/DirectoryWatcherExtra.test.js @@ -479,6 +479,37 @@ describe("DirectoryWatcher extra coverage", () => { dw.close(); }); + it("getFileWithOtherCase does not decide case sensitivity on a transient lstat error", () => { + testHelper.file("a"); + const dw = new DirectoryWatcher( + getWatcherManager(EMPTY_OPTIONS), + fixtures, + EMPTY_OPTIONS, + ); + const file = path.join(fixtures, "a"); + const otherCase = path.join(fixtures, "A"); + dw.setFileTime(file, Date.now(), true, false, "scan (file)"); + + const fs = require("graceful-fs"); + + const { lstatSync } = fs; + const spy = jest + .spyOn(fs, "lstatSync") + .mockImplementation((target, ...args) => { + if (target === otherCase) { + throw Object.assign(new Error("EBUSY"), { code: "EBUSY" }); + } + return lstatSync(target, ...args); + }); + try { + expect(dw.getFileWithOtherCase(otherCase)).toBeUndefined(); + expect(dw.caseInsensitive).toBeUndefined(); + } finally { + spy.mockRestore(); + dw.close(); + } + }); + it("setFileTime detects a case-insensitive duplicate and triggers a rescan", () => { const dw = new DirectoryWatcher( getWatcherManager(EMPTY_OPTIONS), diff --git a/types/DirectoryWatcher.d.ts b/types/DirectoryWatcher.d.ts index b135c61..13f92c9 100644 --- a/types/DirectoryWatcher.d.ts +++ b/types/DirectoryWatcher.d.ts @@ -46,6 +46,8 @@ declare class DirectoryWatcher extends EventEmitter<{ files: Map; /** @type {Map} */ filesWithoutCase: Map; + /** @type {boolean | undefined} */ + caseInsensitive: boolean | undefined; /** @type {Map | boolean>} */ directories: Map | boolean>; /** @type {Map>} */ @@ -186,6 +188,11 @@ declare class DirectoryWatcher extends EventEmitter<{ fileTimestamps: TimeInfoEntries, directoryTimestamps: TimeInfoEntries, ): number; + /** + * @param {string} target a watched path that is not in `files` + * @returns {string | undefined} the file it names on a case-insensitive file system + */ + getFileWithOtherCase(target: string): string | undefined; close(): void; } declare namespace DirectoryWatcher {