Skip to content
5 changes: 5 additions & 0 deletions .changeset/case-insensitive-time-info.md
Original file line number Diff line number Diff line change
@@ -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).
55 changes: 54 additions & 1 deletion lib/DirectoryWatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ class DirectoryWatcher extends EventEmitter {
this.files = new Map();
/** @type {Map<string, number>} */
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<string, Watcher<DirectoryWatcherEvents> | boolean>} */
this.directories = new Map();
/** @type {Map<string, Watcher<FileWatcherEvents>>} */
Expand Down Expand Up @@ -1155,14 +1159,63 @@ 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,
);
}
}
}
}
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;
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
close() {
if (this.closed) return;
this.closed = true;
Expand Down
61 changes: 53 additions & 8 deletions test/Casing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand All @@ -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();
});
});
});
});
}
31 changes: 31 additions & 0 deletions test/DirectoryWatcherExtra.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
7 changes: 7 additions & 0 deletions types/DirectoryWatcher.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ declare class DirectoryWatcher extends EventEmitter<{
files: Map<string, Entry>;
/** @type {Map<string, number>} */
filesWithoutCase: Map<string, number>;
/** @type {boolean | undefined} */
caseInsensitive: boolean | undefined;
/** @type {Map<string, Watcher<DirectoryWatcherEvents> | boolean>} */
directories: Map<string, Watcher<DirectoryWatcherEvents> | boolean>;
/** @type {Map<string, Watcher<FileWatcherEvents>>} */
Expand Down Expand Up @@ -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 {
Expand Down
Loading