fix: reuse note content cache for rapid note switches
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user