Skip to content

Commit 886982c

Browse files
authored
fix: keep active-file reloads on immediate watcher events (#127)
Use immediate file watcher events with JavaScript coalescing and stale watcher cleanup tests.
1 parent 3db03ef commit 886982c

2 files changed

Lines changed: 249 additions & 25 deletions

File tree

src/hooks/use-file-watcher.ts

Lines changed: 109 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { useEffect } from "react";
2-
import { watch, type UnwatchFn, type WatchEvent } from "@tauri-apps/plugin-fs";
1+
import { useEffect, useRef } from "react";
2+
import { watchImmediate, type UnwatchFn, type WatchEvent } from "@tauri-apps/plugin-fs";
3+
4+
const WATCH_DEBOUNCE_MS = 350;
35

46
export function isFileContentChangeEvent(event: WatchEvent): boolean {
57
if (event.type === "any") return true;
@@ -11,34 +13,117 @@ export function isFileContentChangeEvent(event: WatchEvent): boolean {
1113
return false;
1214
}
1315

16+
type FileWatchFn = (
17+
path: string,
18+
onEvent: (event: WatchEvent) => void,
19+
) => Promise<UnwatchFn>;
20+
21+
type ScheduleFn = (fn: () => void, ms: number) => unknown;
22+
type CancelFn = (id: unknown) => void;
23+
24+
export type FileWatcherController = {
25+
setPath(path: string | null): void;
26+
dispose(): void;
27+
};
28+
29+
export type FileWatcherControllerOptions = {
30+
watch?: FileWatchFn;
31+
debounceMs?: number;
32+
isRelevant?: (event: WatchEvent) => boolean;
33+
schedule?: ScheduleFn;
34+
cancel?: CancelFn;
35+
};
36+
37+
export function createFileWatcherController(
38+
onChange: () => void,
39+
options: FileWatcherControllerOptions = {},
40+
): FileWatcherController {
41+
const watch: FileWatchFn = options.watch ?? ((path, onEvent) => watchImmediate(path, onEvent));
42+
const debounceMs = options.debounceMs ?? WATCH_DEBOUNCE_MS;
43+
const isRelevant = options.isRelevant ?? isFileContentChangeEvent;
44+
const schedule: ScheduleFn = options.schedule ?? ((fn, ms) => setTimeout(fn, ms));
45+
const cancel: CancelFn = options.cancel
46+
?? ((id) => clearTimeout(id as ReturnType<typeof setTimeout>));
47+
48+
let watchedPath: string | null = null;
49+
let pending: Promise<UnwatchFn | null> | null = null;
50+
let timer: unknown = null;
51+
let disposed = false;
52+
53+
const cancelPendingChange = () => {
54+
if (timer === null) return;
55+
cancel(timer);
56+
timer = null;
57+
};
58+
59+
const fire = () => {
60+
if (timer !== null) return;
61+
timer = schedule(() => {
62+
timer = null;
63+
onChange();
64+
}, debounceMs);
65+
};
66+
67+
const clearWatcher = () => {
68+
const current = pending;
69+
watchedPath = null;
70+
pending = null;
71+
cancelPendingChange();
72+
void current?.then((unwatch) => unwatch?.());
73+
};
74+
75+
const addWatcher = (path: string) => {
76+
// `registration` is referenced inside its own initializer; the callback
77+
// can only run after this synchronous assignment completes.
78+
const registration: Promise<UnwatchFn | null> = watch(path, (event) => {
79+
if (disposed || pending !== registration) return;
80+
if (isRelevant(event)) fire();
81+
}).catch((error: unknown) => {
82+
console.warn(`marka.md: failed to watch file ${path}`, error);
83+
if (pending === registration) {
84+
watchedPath = null;
85+
pending = null;
86+
}
87+
return null;
88+
});
89+
watchedPath = path;
90+
pending = registration;
91+
};
92+
93+
return {
94+
setPath(path) {
95+
if (disposed || path === watchedPath) return;
96+
clearWatcher();
97+
if (path) addWatcher(path);
98+
},
99+
dispose() {
100+
disposed = true;
101+
clearWatcher();
102+
},
103+
};
104+
}
105+
14106
/**
15107
* Watches the active file through Tauri's native filesystem watcher.
16108
*/
17109
export function useFileWatcher(path: string | null, onChange: () => void): void {
110+
const onChangeRef = useRef(onChange);
111+
const controllerRef = useRef<FileWatcherController | null>(null);
112+
18113
useEffect(() => {
19-
if (!path) return;
20-
let disposed = false;
21-
let unwatch: UnwatchFn | null = null;
22-
23-
void watch(
24-
path,
25-
(event) => {
26-
if (isFileContentChangeEvent(event)) onChange();
27-
},
28-
{ delayMs: 350 },
29-
)
30-
.then((stop) => {
31-
if (disposed) stop();
32-
else unwatch = stop;
33-
})
34-
.catch((error) => {
35-
console.warn(`marka.md: failed to watch file ${path}`, error);
36-
});
114+
onChangeRef.current = onChange;
115+
}, [onChange]);
37116

117+
useEffect(() => {
118+
const controller = createFileWatcherController(() => onChangeRef.current());
119+
controllerRef.current = controller;
38120
return () => {
39-
disposed = true;
40-
unwatch?.();
41-
unwatch = null;
121+
controllerRef.current = null;
122+
controller.dispose();
42123
};
43-
}, [path, onChange]);
124+
}, []);
125+
126+
useEffect(() => {
127+
controllerRef.current?.setPath(path);
128+
}, [path]);
44129
}

tests/file-watcher.test.ts

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
import { expect, test } from "bun:test";
2-
import { isFileContentChangeEvent } from "../src/hooks/use-file-watcher";
2+
import type { UnwatchFn, WatchEvent } from "@tauri-apps/plugin-fs";
3+
import {
4+
createFileWatcherController,
5+
isFileContentChangeEvent,
6+
} from "../src/hooks/use-file-watcher";
7+
8+
const CONTENT_CHANGE: WatchEvent = {
9+
type: { modify: { kind: "data", mode: "content" } },
10+
paths: [],
11+
attrs: null,
12+
};
13+
const ACCESS_CHANGE: WatchEvent = {
14+
type: { access: { kind: "open", mode: "read" } },
15+
paths: [],
16+
attrs: null,
17+
};
318

419
test("reloads for file create, remove, and content changes", () => {
520
expect(isFileContentChangeEvent({ type: { create: { kind: "file" } }, paths: [], attrs: null })).toBe(true);
@@ -12,3 +27,127 @@ test("ignores access, metadata, and unrelated events", () => {
1227
expect(isFileContentChangeEvent({ type: { modify: { kind: "metadata", mode: "permissions" } }, paths: [], attrs: null })).toBe(false);
1328
expect(isFileContentChangeEvent({ type: "other", paths: [], attrs: null })).toBe(false);
1429
});
30+
31+
function fakeWatchFactory() {
32+
const registrations: Array<{
33+
path: string;
34+
emit: (event: WatchEvent) => void;
35+
resolve: () => void;
36+
reject: (error: unknown) => void;
37+
unwatchCalls: number;
38+
}> = [];
39+
40+
const watch = (path: string, onEvent: (event: WatchEvent) => void): Promise<UnwatchFn> => {
41+
return new Promise<UnwatchFn>((resolve, reject) => {
42+
const reg = {
43+
path,
44+
emit: onEvent,
45+
resolve: () => resolve((() => { reg.unwatchCalls += 1; }) as UnwatchFn),
46+
reject,
47+
unwatchCalls: 0,
48+
};
49+
registrations.push(reg);
50+
});
51+
};
52+
53+
return { watch, registrations };
54+
}
55+
56+
function fakeScheduler() {
57+
const tasks = new Map<number, () => void>();
58+
let nextId = 1;
59+
60+
return {
61+
schedule(fn: () => void) {
62+
const id = nextId;
63+
nextId += 1;
64+
tasks.set(id, fn);
65+
return id;
66+
},
67+
cancel(id: unknown) {
68+
tasks.delete(id as number);
69+
},
70+
runAll() {
71+
const pending = Array.from(tasks.entries());
72+
tasks.clear();
73+
for (const [, fn] of pending) fn();
74+
},
75+
get size() {
76+
return tasks.size;
77+
},
78+
};
79+
}
80+
81+
function flushAsyncCleanup(): Promise<void> {
82+
return new Promise((resolve) => setTimeout(resolve, 0));
83+
}
84+
85+
test("coalesces active file content changes and ignores access events", () => {
86+
const { watch, registrations } = fakeWatchFactory();
87+
const scheduler = fakeScheduler();
88+
let reloads = 0;
89+
90+
const controller = createFileWatcherController(() => { reloads += 1; }, {
91+
watch,
92+
schedule: scheduler.schedule,
93+
cancel: scheduler.cancel,
94+
});
95+
96+
controller.setPath("/notes/today.md");
97+
registrations[0].emit(CONTENT_CHANGE);
98+
registrations[0].emit(CONTENT_CHANGE);
99+
registrations[0].emit(ACCESS_CHANGE);
100+
101+
expect(scheduler.size).toBe(1);
102+
scheduler.runAll();
103+
expect(reloads).toBe(1);
104+
});
105+
106+
test("keeps stale file watcher events from reloading the new active file", async () => {
107+
const { watch, registrations } = fakeWatchFactory();
108+
const scheduler = fakeScheduler();
109+
let reloads = 0;
110+
111+
const controller = createFileWatcherController(() => { reloads += 1; }, {
112+
watch,
113+
schedule: scheduler.schedule,
114+
cancel: scheduler.cancel,
115+
});
116+
117+
controller.setPath("/notes/old.md");
118+
controller.setPath("/notes/new.md");
119+
120+
registrations[0].emit(CONTENT_CHANGE);
121+
registrations[1].emit(CONTENT_CHANGE);
122+
123+
scheduler.runAll();
124+
expect(reloads).toBe(1);
125+
126+
registrations[0].resolve();
127+
await flushAsyncCleanup();
128+
expect(registrations[0].unwatchCalls).toBe(1);
129+
});
130+
131+
test("dispose releases the active file watcher and cancels pending reloads", async () => {
132+
const { watch, registrations } = fakeWatchFactory();
133+
const scheduler = fakeScheduler();
134+
let reloads = 0;
135+
136+
const controller = createFileWatcherController(() => { reloads += 1; }, {
137+
watch,
138+
schedule: scheduler.schedule,
139+
cancel: scheduler.cancel,
140+
});
141+
142+
controller.setPath("/notes/today.md");
143+
registrations[0].emit(CONTENT_CHANGE);
144+
expect(scheduler.size).toBe(1);
145+
146+
controller.dispose();
147+
scheduler.runAll();
148+
expect(reloads).toBe(0);
149+
150+
registrations[0].resolve();
151+
await flushAsyncCleanup();
152+
expect(registrations[0].unwatchCalls).toBe(1);
153+
});

0 commit comments

Comments
 (0)