Skip to content

Commit fa80c71

Browse files
perf: avoid debouncer file-ID cache walk on watcher creation; incremental watcher registry (#122)
With delayMs set, tauri-plugin-fs routes watch() through notify-debouncer-full, whose file-ID cache walks the entire tree (stat/handle-open per entry) at watcher creation on macOS and Windows, inside a synchronous Tauri command on the main thread — freezing the UI at startup and on folder add for large roots. - use watchImmediate (no debouncer, no cache walk; identical event shapes) - coalesce JS-side with a fixed 350ms window (bounded refresh under sustained churn — a naive trailing-edge debounce would starve) - incremental watcher registry: adding a folder creates only that watcher; failed registrations are evicted so reconciliation retries them - extract a dependency-injectable controller + 7 unit tests over the lifecycle Co-authored-by: Sasha Merzliakov <26497835+sashamerzliakov@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7e9b843 commit fa80c71

2 files changed

Lines changed: 348 additions & 31 deletions

File tree

src/hooks/use-folder-watcher.ts

Lines changed: 127 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useRef } from "react";
2-
import { watch, type UnwatchFn, type WatchEvent } from "@tauri-apps/plugin-fs";
2+
import { watchImmediate, type UnwatchFn, type WatchEvent } from "@tauri-apps/plugin-fs";
33
import { isFilesystemRoot } from "@/lib/storage";
44

55
const WATCH_DEBOUNCE_MS = 350;
@@ -20,46 +20,142 @@ export function isDirectoryChangeEvent(event: WatchEvent): boolean {
2020
return false;
2121
}
2222

23+
type FolderWatchFn = (
24+
path: string,
25+
onEvent: (event: WatchEvent) => void,
26+
) => Promise<UnwatchFn>;
27+
28+
type ScheduleFn = (fn: () => void, ms: number) => unknown;
29+
type CancelFn = (id: unknown) => void;
30+
31+
export type FolderWatcherController = {
32+
/** Reconcile the watched set: watchers are created only for new paths and
33+
* removed only for dropped ones — unchanged paths keep their watcher. */
34+
setPaths(paths: readonly string[]): void;
35+
dispose(): void;
36+
};
37+
38+
export type FolderWatcherControllerOptions = {
39+
/** Watcher factory — injectable for tests. Defaults to recursive `watchImmediate`. */
40+
watch?: FolderWatchFn;
41+
debounceMs?: number;
42+
isRelevant?: (event: WatchEvent) => boolean;
43+
schedule?: ScheduleFn;
44+
cancel?: CancelFn;
45+
};
46+
47+
/**
48+
* Watcher registry with change coalescing, extracted from the hook so the
49+
* lifecycle logic is unit-testable (see tests/folder-watcher.test.ts).
50+
*
51+
* Perf-critical details:
52+
* - `watchImmediate` + JS-side coalescing, NOT the plugin's debounced
53+
* `watch`. With `delayMs` set, tauri-plugin-fs (2.5, notify-debouncer-full
54+
* 0.6) routes through a debouncer whose file-ID cache walks the entire tree
55+
* at watcher creation on macOS and Windows (`RecommendedCache = FileIdMap`;
56+
* Linux uses `NoCache`), inside a synchronous Tauri command — on large
57+
* roots that froze the app at startup / folder add.
58+
* - Fixed-window coalescing: the first qualifying event schedules a refresh
59+
* `debounceMs` later and further events ride the same window, so sustained
60+
* filesystem churn cannot starve refreshes.
61+
* - Incremental registry: adding a folder creates only the new watcher.
62+
* - A failed watcher registration is evicted so a later reconciliation can
63+
* retry it (guarded against remove/re-add races via promise identity).
64+
* - Tradeoff: raw (uncoalesced) events now cross IPC; the JS window bounds
65+
* the refresh work, not the message volume.
66+
*/
67+
export function createFolderWatcherController(
68+
onChange: () => void,
69+
options: FolderWatcherControllerOptions = {},
70+
): FolderWatcherController {
71+
const watch: FolderWatchFn = options.watch
72+
?? ((path, onEvent) => watchImmediate(path, onEvent, { recursive: true }));
73+
const debounceMs = options.debounceMs ?? WATCH_DEBOUNCE_MS;
74+
const isRelevant = options.isRelevant ?? isDirectoryChangeEvent;
75+
const schedule: ScheduleFn = options.schedule ?? ((fn, ms) => setTimeout(fn, ms));
76+
const cancel: CancelFn = options.cancel
77+
?? ((id) => clearTimeout(id as ReturnType<typeof setTimeout>));
78+
79+
// path → pending watcher registration; promise identity doubles as the
80+
// "is this watcher still current?" token for stale callbacks.
81+
const registry = new Map<string, Promise<UnwatchFn | null>>();
82+
let timer: unknown = null;
83+
let disposed = false;
84+
85+
const fire = () => {
86+
if (timer !== null) return; // window already open — coalesce
87+
timer = schedule(() => {
88+
timer = null;
89+
onChange();
90+
}, debounceMs);
91+
};
92+
93+
const add = (path: string) => {
94+
// `pending` is referenced inside its own initialiser — safe because the
95+
// whole const binding completes synchronously before any event callback
96+
// or rejection can run in a later task.
97+
const pending: Promise<UnwatchFn | null> = watch(path, (event) => {
98+
if (disposed || registry.get(path) !== pending) return; // stale watcher
99+
if (isRelevant(event)) fire();
100+
}).catch((error: unknown) => {
101+
console.warn(`marka.md: failed to watch folder ${path}`, error);
102+
// evict so the next reconciliation can retry — unless the path was
103+
// removed or re-added (newer registration) in the meantime
104+
if (registry.get(path) === pending) registry.delete(path);
105+
return null;
106+
});
107+
registry.set(path, pending);
108+
};
109+
110+
return {
111+
setPaths(paths) {
112+
if (disposed) return;
113+
const wanted = new Set(paths);
114+
for (const [path, pending] of registry) {
115+
if (wanted.has(path)) continue;
116+
registry.delete(path);
117+
void pending.then((unwatch) => unwatch?.());
118+
}
119+
for (const path of wanted) {
120+
if (!registry.has(path)) add(path);
121+
}
122+
},
123+
dispose() {
124+
disposed = true;
125+
for (const pending of registry.values()) {
126+
void pending.then((unwatch) => unwatch?.());
127+
}
128+
registry.clear();
129+
if (timer !== null) {
130+
cancel(timer);
131+
timer = null;
132+
}
133+
},
134+
};
135+
}
136+
23137
/** Watches each opened root recursively and refreshes the visible tree on structure changes. */
24138
export function useFolderWatcher(paths: readonly string[], onChange: () => void): void {
25139
const onChangeRef = useRef(onChange);
26-
const pathsKey = paths.join("\0");
140+
const controllerRef = useRef<FolderWatcherController | null>(null);
141+
const pathsKey = watchableFolderPaths(paths).join("\0");
27142

28143
useEffect(() => {
29144
onChangeRef.current = onChange;
30145
}, [onChange]);
31146

147+
// controller per mount (not per render) — StrictMode's setup/cleanup/setup
148+
// gets a fresh controller each time, so dispose() can be terminal.
32149
useEffect(() => {
33-
let disposed = false;
34-
const unwatchers = new Set<UnwatchFn>();
35-
const uniquePaths = watchableFolderPaths(paths);
36-
37-
const start = async () => {
38-
for (const path of uniquePaths) {
39-
try {
40-
const unwatch = await watch(
41-
path,
42-
(event) => {
43-
if (isDirectoryChangeEvent(event)) onChangeRef.current();
44-
},
45-
{ recursive: true, delayMs: WATCH_DEBOUNCE_MS },
46-
);
47-
if (disposed) {
48-
unwatch();
49-
} else {
50-
unwatchers.add(unwatch);
51-
}
52-
} catch (error) {
53-
console.warn(`marka.md: failed to watch folder ${path}`, error);
54-
}
55-
}
56-
};
57-
58-
void start();
150+
const controller = createFolderWatcherController(() => onChangeRef.current());
151+
controllerRef.current = controller;
59152
return () => {
60-
disposed = true;
61-
for (const unwatch of unwatchers) unwatch();
62-
unwatchers.clear();
153+
controllerRef.current = null;
154+
controller.dispose();
63155
};
156+
}, []);
157+
158+
useEffect(() => {
159+
controllerRef.current?.setPaths(pathsKey ? pathsKey.split("\0") : []);
64160
}, [pathsKey]);
65161
}

0 commit comments

Comments
 (0)