fix: reuse note content cache for rapid note switches

This commit is contained in:
lucaronin
2026-04-18 17:30:49 +02:00
parent bc11c869aa
commit c0bb722db5
2 changed files with 82 additions and 21 deletions

View File

@@ -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<string>((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', () => {

View File

@@ -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<string, Promise<string>>()
const NOTE_CONTENT_CACHE_LIMIT = 48
interface NoteContentCacheEntry {
path: NotePath
promise: Promise<string>
}
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<string> {
if (prefetchCache.has(path)) prefetchCache.delete(path)
prefetchCache.set(path, promise)
trimPrefetchCache()
return promise
}
function requestNoteContent({ path }: Pick<NoteContentCacheEntry, 'path'>): Promise<string> {
const promise = (isTauri()
? invoke<string>('get_note_content', { path })
: mockInvoke<string>('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<string>('get_note_content', { path })
: mockInvoke<string>('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<string> {
// 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<string>('get_note_content', { path })
: mockInvoke<string>('get_note_content', { path })
return prefetchCache.get(path) ?? requestNoteContent({ path })
}
export type { Tab }