From c0bb722db570c12a848b9646e7c8ce6a8573ab8a Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 18 Apr 2026 17:30:49 +0200 Subject: [PATCH] fix: reuse note content cache for rapid note switches --- src/hooks/useTabManagement.test.ts | 42 ++++++++++++++++++++ src/hooks/useTabManagement.ts | 61 ++++++++++++++++++++---------- 2 files changed, 82 insertions(+), 21 deletions(-) diff --git a/src/hooks/useTabManagement.test.ts b/src/hooks/useTabManagement.test.ts index 4d6a0e6b..c0314dae 100644 --- a/src/hooks/useTabManagement.test.ts +++ b/src/hooks/useTabManagement.test.ts @@ -265,6 +265,48 @@ describe('useTabManagement (single-note model)', () => { expect(result.current.tabs[0].content).toBe('# Persisted content') expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1) }) + + it('reuses cached content when reopening a recently loaded note', async () => { + const { mockInvoke } = await import('../mock-tauri') + vi.mocked(mockInvoke) + .mockResolvedValueOnce('# A content') + .mockResolvedValueOnce('# B content') + + const { result } = renderHook(() => useTabManagement()) + await selectNote(result, { path: '/vault/a.md', title: 'A' }) + await selectNote(result, { path: '/vault/b.md', title: 'B' }) + await selectNote(result, { path: '/vault/a.md', title: 'A again' }) + + expect(result.current.tabs[0].entry.path).toBe('/vault/a.md') + expect(result.current.tabs[0].content).toBe('# A content') + expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2) + }) + + it('deduplicates a late prefetch after note opening already started', async () => { + const { mockInvoke } = await import('../mock-tauri') + + let resolveContent!: (value: string) => void + vi.mocked(mockInvoke).mockImplementationOnce( + () => new Promise((resolve) => { resolveContent = resolve }), + ) + + const { result } = renderHook(() => useTabManagement()) + + await act(async () => { + void result.current.handleSelectNote(makeEntry({ path: '/vault/note/rapid.md', title: 'Rapid' })) + prefetchNoteContent('/vault/note/rapid.md') + await Promise.resolve() + }) + + expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1) + + await act(async () => { + resolveContent('# Rapid content') + await Promise.resolve() + }) + + expect(result.current.tabs[0].content).toBe('# Rapid content') + }) }) describe('rapid switching safety', () => { diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index e27af647..604e63f5 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -8,30 +8,57 @@ interface Tab { content: string } +type NotePath = VaultEntry['path'] + // --- Content prefetch cache --- -// Stores in-flight or resolved note content promises, keyed by path. +// Stores in-flight or recently loaded note content promises, keyed by path. // Cleared on vault reload to prevent stale content after external edits. -// Latency profile: eliminates 50-200ms IPC round-trip for hover/keyboard-prefetched notes. +// Latency profile: deduplicates rapid note switches and keeps revisits instant. const prefetchCache = new Map>() +const NOTE_CONTENT_CACHE_LIMIT = 48 + +interface NoteContentCacheEntry { + path: NotePath + promise: Promise +} + +function trimPrefetchCache(): void { + while (prefetchCache.size > NOTE_CONTENT_CACHE_LIMIT) { + const oldestPath = prefetchCache.keys().next().value + if (!oldestPath) return + prefetchCache.delete(oldestPath) + } +} + +function rememberNoteContent({ path, promise }: NoteContentCacheEntry): Promise { + if (prefetchCache.has(path)) prefetchCache.delete(path) + prefetchCache.set(path, promise) + trimPrefetchCache() + return promise +} + +function requestNoteContent({ path }: Pick): Promise { + const promise = (isTauri() + ? invoke('get_note_content', { path }) + : mockInvoke('get_note_content', { path }) + ).catch((err) => { + prefetchCache.delete(path) + throw err + }) + + return rememberNoteContent({ path, promise }) +} /** Prefetch a note's content into the in-memory cache. * Safe to call multiple times — deduplicates concurrent requests for the same path. * Cache is short-lived: cleared on vault reload via clearPrefetchCache(). */ export function prefetchNoteContent(path: string): void { if (prefetchCache.has(path)) return - const promise = (isTauri() - ? invoke('get_note_content', { path }) - : mockInvoke('get_note_content', { path }) - ).catch((err) => { - // Remove failed prefetch so a retry can occur - prefetchCache.delete(path) - throw err - }) - prefetchCache.set(path, promise) + requestNoteContent({ path }) } export function cacheNoteContent(path: string, content: string): void { - prefetchCache.set(path, Promise.resolve(content)) + rememberNoteContent({ path, promise: Promise.resolve(content) }) } /** Clear the prefetch cache. Call on vault reload to prevent stale content. */ @@ -40,15 +67,7 @@ export function clearPrefetchCache(): void { } async function loadNoteContent(path: string): Promise { - // Check prefetch cache first — eliminates IPC round-trip for prefetched notes - const cached = prefetchCache.get(path) - if (cached) { - prefetchCache.delete(path) - return cached - } - return isTauri() - ? invoke('get_note_content', { path }) - : mockInvoke('get_note_content', { path }) + return prefetchCache.get(path) ?? requestNoteContent({ path }) } export type { Tab }