From f24bb435d3d3206bdcbbc60ef7ff612e62e06a67 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Fri, 19 Jun 2026 08:01:03 -0700 Subject: [PATCH] Fix live file tree startup latency (#935) --- .github/workflows/test.yml | 3 + packages/ui/hooks/useFileBrowser.test.tsx | 99 +++++++++++++++++++++++ packages/ui/hooks/useFileBrowser.ts | 27 +++++-- 3 files changed, 121 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 51149723..85a38505 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,6 +31,9 @@ jobs: - name: Run tests run: bun test + - name: Run file browser DOM tests + run: DOM_TESTS=1 bun test packages/ui/hooks/useFileBrowser.test.tsx + pi-extension-ai-runtime-windows: # Exercises the Pi extension's Node/jiti server mirror on Windows with an # npm-style `pi` shim pair. This catches regressions where `where pi` diff --git a/packages/ui/hooks/useFileBrowser.test.tsx b/packages/ui/hooks/useFileBrowser.test.tsx index f26a0953..22d0c380 100644 --- a/packages/ui/hooks/useFileBrowser.test.tsx +++ b/packages/ui/hooks/useFileBrowser.test.tsx @@ -45,6 +45,22 @@ function installFetchResponses(responses: Response[]): string[] { return calls; } +function installDeferredFetch(): { + calls: string[]; + resolve: (response: Response) => void; +} { + const calls: string[] = []; + let resolve: (response: Response) => void = () => {}; + const pending = new Promise((next) => { + resolve = next; + }); + globalThis.fetch = (async (input: RequestInfo | URL) => { + calls.push(String(input)); + return pending; + }) as unknown as typeof fetch; + return { calls, resolve }; +} + function installMockEventSource(): void { MockEventSource.instances = []; globalThis.EventSource = MockEventSource as unknown as typeof EventSource; @@ -146,6 +162,57 @@ describe("useFileBrowser", () => { await session.unmount(); }); + test.skipIf(!hasDom)("waits for all initial folder snapshots before opening the live watcher", async () => { + installMockEventSource(); + const firstDir = "/tmp/plannotator-docs-a"; + const secondDir = "/tmp/plannotator-docs-b"; + const first = deferred(); + const second = deferred(); + const pending = [first.promise, second.promise]; + const calls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + calls.push(String(input)); + return pending.shift() ?? response({ error: "unexpected fetch" }, 500); + }) as unknown as typeof fetch; + + const session = await mountHook(); + await act(async () => { + session.result.current!.fetchAll([firstDir, secondDir]); + }); + await tick(0); + + expect(calls).toHaveLength(2); + expect(MockEventSource.instances).toHaveLength(0); + + await act(async () => { + first.resolve(response({ tree: [{ type: "file", name: "a.md", path: "a.md" }] })); + await Promise.resolve(); + }); + await tick(0); + + expect(session.result.current!.dirs.find((dir) => dir.path === firstDir)).toMatchObject({ + isLoading: false, + hasLoadedTree: true, + }); + expect(session.result.current!.dirs.find((dir) => dir.path === secondDir)).toMatchObject({ + isLoading: true, + hasLoadedTree: false, + }); + expect(MockEventSource.instances).toHaveLength(0); + + await act(async () => { + second.resolve(response({ tree: [{ type: "file", name: "b.md", path: "b.md" }] })); + await Promise.resolve(); + }); + await tick(0); + + expect(MockEventSource.instances).toHaveLength(1); + expect(MockEventSource.instances[0]?.url).toContain(encodeURIComponent(firstDir)); + expect(MockEventSource.instances[0]?.url).toContain(encodeURIComponent(secondDir)); + + await session.unmount(); + }); + test.skipIf(!hasDom)("quiet invalid-directory refresh clears stale files", async () => { const dirPath = "/tmp/plannotator-docs"; const tree: VaultNode[] = [{ type: "file", name: "a.md", path: "a.md" }]; @@ -233,4 +300,36 @@ describe("useFileBrowser", () => { await session.unmount(); }); + + test.skipIf(!hasDom)("waits for the first tree snapshot before opening the live stream", async () => { + installMockEventSource(); + const dirPath = "/tmp/plannotator-docs"; + const tree: VaultNode[] = [{ type: "file", name: "a.md", path: "a.md" }]; + const deferred = installDeferredFetch(); + + const session = await mountHook(); + await act(async () => { + session.result.current!.fetchTree(dirPath); + await Promise.resolve(); + }); + + expect(deferred.calls).toHaveLength(1); + expect(session.result.current!.dirs[0]).toMatchObject({ + path: dirPath, + isLoading: true, + }); + expect(MockEventSource.instances).toHaveLength(0); + + deferred.resolve(response({ tree })); + await tick(0); + + expect(session.result.current!.dirs[0]).toMatchObject({ + path: dirPath, + isLoading: false, + hasLoadedTree: true, + }); + expect(MockEventSource.instances).toHaveLength(1); + + await session.unmount(); + }); }); diff --git a/packages/ui/hooks/useFileBrowser.ts b/packages/ui/hooks/useFileBrowser.ts index c019759d..563b38ad 100644 --- a/packages/ui/hooks/useFileBrowser.ts +++ b/packages/ui/hooks/useFileBrowser.ts @@ -18,6 +18,8 @@ export interface DirState { isLoading: boolean; error: string | null; workspaceStatus?: WorkspaceStatusPayload; + /** True after the first successful snapshot; live watching waits for this so watcher setup cannot block initial load. */ + hasLoadedTree?: boolean; /** When true, fetches via /api/reference/obsidian/files and opens docs via /api/reference/obsidian/doc */ isVault?: boolean; } @@ -107,7 +109,7 @@ export function useFileBrowser(): UseFileBrowserReturn { : d ); } - return [...prev, { path: dirPath, name, tree: [], isLoading: true, error: null }]; + return [...prev, { path: dirPath, name, tree: [], isLoading: true, error: null, hasLoadedTree: false }]; }); try { @@ -128,6 +130,7 @@ export function useFileBrowser(): UseFileBrowserReturn { tree: options.quiet ? [] : d.tree, workspaceStatus: options.quiet ? undefined : d.workspaceStatus, isLoading: false, + hasLoadedTree: false, error, } : { ...d, isLoading: false, error: d.error } @@ -146,6 +149,7 @@ export function useFileBrowser(): UseFileBrowserReturn { tree: data.tree, workspaceStatus, isLoading: false, + hasLoadedTree: true, error: null, } : d @@ -193,6 +197,7 @@ export function useFileBrowser(): UseFileBrowserReturn { // that the visible tree sits on "Loading..." for seconds. isLoading: true, error: null, + hasLoadedTree: false, })); return [...regularDirs, ...vaultDirs]; }); @@ -265,13 +270,19 @@ export function useFileBrowser(): UseFileBrowserReturn { }, []); const watchDirsKey = useMemo( - () => dirs - // Subscribe only after the initial snapshot is visible. Live updates are - // for future freshness; they must not compete with first paint. - .filter((dir) => !dir.isVault && !dir.error && !dir.isLoading) - .map((dir) => dir.path) - .sort() - .join("\n"), + () => { + const regularDirs = dirs.filter((dir) => !dir.isVault); + const initialLoadPending = regularDirs.some((dir) => dir.isLoading && !dir.hasLoadedTree); + if (initialLoadPending) return ""; + + return regularDirs + // Subscribe only after the initial snapshot is visible. Live updates are + // for future freshness; they must not compete with first paint. + .filter((dir) => !dir.error && dir.hasLoadedTree) + .map((dir) => dir.path) + .sort() + .join("\n"); + }, [dirs] );