perf(cli): tell the preview about a file edit 30 ms after it lands, not 300 (#4348)

* perf(cli): tell the preview about a file edit 30 ms after it lands, not 300

* perf(cli): keep bulk writes coalesced while lone saves go out fast

* style(cli): name the watcher delay

* perf(cli): throttle watcher flushes instead of switching to a long wait
This commit is contained in:
Miguel Ángel
2026-09-23 18:49:37 -04:00
committed by GitHub
parent 00ea884d8f
commit 5c3f02c855
2 changed files with 37 additions and 3 deletions
+29 -1
View File
@@ -67,7 +67,9 @@ describe("createProjectWatcher", () => {
mockWatcher.emit("change", "change", "scene-a.html");
mockWatcher.emit("change", "change", "scene-b.html");
mockWatcher.emit("change", "change", "scene-a.html");
vi.advanceTimersByTime(300);
vi.advanceTimersByTime(29);
expect(listener).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(listener.mock.calls).toEqual([["scene-a.html"], ["scene-b.html"]]);
projectWatcher.close();
@@ -97,6 +99,32 @@ describe("createProjectWatcher", () => {
expect(() => projectWatcher?.close()).not.toThrow();
});
it("flushes at most once per 300 ms while writes keep coming", () => {
vi.useFakeTimers();
const projectWatcher = createProjectWatcher("/fake/project/dir");
const listener = vi.fn();
projectWatcher.addListener(listener);
mockWatcher.emit("change", "change", "a.html");
vi.advanceTimersByTime(30);
expect(listener).toHaveBeenCalledTimes(1);
mockWatcher.emit("change", "change", "b.html");
vi.advanceTimersByTime(100);
mockWatcher.emit("change", "change", "c.html");
vi.advanceTimersByTime(199);
expect(listener).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(listener.mock.calls.slice(1)).toEqual([["b.html"], ["c.html"]]);
// A save that lands late in the window waits only for quiet, not a whole window.
vi.advanceTimersByTime(290);
mockWatcher.emit("change", "change", "d.html");
vi.advanceTimersByTime(30);
expect(listener).toHaveBeenLastCalledWith("d.html");
projectWatcher.close();
});
// Regression: fs.watch can fail asynchronously (e.g. EMFILE from exhausted
// OS watch handles) via an 'error' event, not a thrown exception. An
// EventEmitter 'error' with no listener crashes the whole process — this
+8 -2
View File
@@ -26,7 +26,10 @@ const WATCHER_EXCLUDED_DIRS = new Set([
"outputs",
"renders",
]);
const DEBOUNCE_MS = 300;
// A save reaches the preview QUIET_MS after the writes go quiet, but at most once per BURST_MS,
// so a checkout or a multi-file tool doesn't start a rebuild for every file.
const QUIET_MS = 30;
const BURST_MS = 300;
export function shouldWatchProjectFile(filename: string): boolean {
if (!filename) return false;
@@ -112,6 +115,7 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher {
const listeners = new Set<FileChangeListener>();
const pendingPaths = new Set<string>();
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let lastFlushAt = Number.NEGATIVE_INFINITY;
let closeTree: (() => void) | null = null;
try {
@@ -130,16 +134,18 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher {
pendingPaths.add(relativePath);
if (debounceTimer) clearTimeout(debounceTimer);
const delay = Math.max(QUIET_MS, lastFlushAt + BURST_MS - Date.now());
debounceTimer = setTimeout(() => {
const changedPaths = [...pendingPaths];
pendingPaths.clear();
debounceTimer = null;
lastFlushAt = Date.now();
for (const changedPath of changedPaths) {
for (const fn of listeners) {
fn(changedPath);
}
}
}, DEBOUNCE_MS);
}, delay);
});
} catch {
// fs.watch may fail on some platforms — degrade gracefully (no auto-refresh)