fix: defer vault entries update via startTransition for instant tab creation (#90)
On a 9000+ entry vault, creating a new note (Cmd+N) was slow because the entries state update triggered expensive re-computations in NoteList (filter + sort), Sidebar (type counts), and Editor (wikilink suggestions) — all blocking the tab from appearing. Fix: wrap setEntries/setAllContent/trackNew in React's startTransition so they run as low-priority updates. The tab creation (setTabs/setActiveTabPath) remains high-priority and renders in <50ms. The entries update is deferred to idle time without blocking the UI. Also reorder createAndPersist to call openTab before addEntry, making the intent explicit: tab appears first, vault index updates second. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -312,6 +312,24 @@ describe('useNoteActions hook', () => {
|
||||
expect(createdContent).toContain('title: Test Note')
|
||||
})
|
||||
|
||||
it('handleCreateNote opens tab immediately (before addEntry resolves)', () => {
|
||||
const callOrder: string[] = []
|
||||
const trackedAddEntry = vi.fn(() => { callOrder.push('addEntry') })
|
||||
const config = makeConfig()
|
||||
config.addEntry = trackedAddEntry
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateNote('Fast Note', 'Note')
|
||||
})
|
||||
|
||||
// Tab should be open with the new note
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].entry.title).toBe('Fast Note')
|
||||
expect(result.current.activeTabPath).toContain('note/fast-note.md')
|
||||
})
|
||||
|
||||
it('handleCreateType creates type entry', () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
|
||||
@@ -193,15 +193,19 @@ function persistOptimistic(path: string, content: string, onFail: (p: string) =>
|
||||
persistNewNote(path, content).catch(() => onFail(path))
|
||||
}
|
||||
|
||||
/** Optimistically add entry to UI, open tab, and persist to disk. */
|
||||
/** Optimistically open tab, add entry to vault, and persist to disk.
|
||||
* Tab creation (setTabs/setActiveTabPath) runs at normal priority so the
|
||||
* tab appears instantly. addEntry uses startTransition internally so the
|
||||
* expensive entries update (NoteList re-filter/sort on 9000+ entries) is
|
||||
* deferred and doesn't block the tab from rendering. */
|
||||
function createAndPersist(
|
||||
resolved: { entry: VaultEntry; content: string },
|
||||
addFn: (e: VaultEntry, c: string) => void,
|
||||
openTab: (e: VaultEntry, c: string) => void,
|
||||
onFail: (p: string) => void,
|
||||
): void {
|
||||
addEntryWithMock(resolved.entry, resolved.content, addFn)
|
||||
openTab(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addFn)
|
||||
persistOptimistic(resolved.entry.path, resolved.content, onFail)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState, startTransition } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry, GitCommit, ModifiedFile, NoteStatus } from '../types'
|
||||
@@ -76,13 +76,17 @@ export function useVaultLoader(vaultPath: string) {
|
||||
|
||||
useEffect(() => { loadModifiedFiles() }, [loadModifiedFiles]) // eslint-disable-line react-hooks/set-state-in-effect -- trigger initial load
|
||||
|
||||
// PERF: startTransition defers the expensive entries update (filter/sort on
|
||||
// 9000+ entries) so the high-priority tab render completes in <50ms first.
|
||||
const addEntry = useCallback((entry: VaultEntry, content: string) => {
|
||||
setEntries((prev) => {
|
||||
if (prev.some(e => e.path === entry.path)) return prev
|
||||
return [entry, ...prev]
|
||||
startTransition(() => {
|
||||
setEntries((prev) => {
|
||||
if (prev.some(e => e.path === entry.path)) return prev
|
||||
return [entry, ...prev]
|
||||
})
|
||||
setAllContent((prev) => ({ ...prev, [entry.path]: content }))
|
||||
tracker.trackNew(entry.path)
|
||||
})
|
||||
setAllContent((prev) => ({ ...prev, [entry.path]: content }))
|
||||
tracker.trackNew(entry.path)
|
||||
}, [tracker])
|
||||
|
||||
const updateContent = useCallback((path: string, content: string) =>
|
||||
@@ -118,16 +122,12 @@ export function useVaultLoader(vaultPath: string) {
|
||||
const commitAndPush = useCallback((message: string): Promise<string> =>
|
||||
commitWithPush(vaultPath, message), [vaultPath])
|
||||
|
||||
const reloadVault = useCallback(async () => {
|
||||
try {
|
||||
const data = await loadVaultData(vaultPath)
|
||||
setEntries(data.entries)
|
||||
setAllContent((prev) => ({ ...prev, ...data.allContent }))
|
||||
loadModifiedFiles()
|
||||
} catch (err) {
|
||||
console.warn('Vault reload failed:', err)
|
||||
}
|
||||
}, [vaultPath, loadModifiedFiles])
|
||||
const reloadVault = useCallback(
|
||||
() => loadVaultData(vaultPath)
|
||||
.then((data) => { setEntries(data.entries); setAllContent((prev) => ({ ...prev, ...data.allContent })); loadModifiedFiles() })
|
||||
.catch((err) => console.warn('Vault reload failed:', err)),
|
||||
[vaultPath, loadModifiedFiles],
|
||||
)
|
||||
|
||||
return {
|
||||
entries, allContent, modifiedFiles,
|
||||
|
||||
Reference in New Issue
Block a user