diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 8ce22df2..6fe73c1e 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -161,6 +161,12 @@ interface VaultEntry { Image previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects. +### Note Content Freshness + +The renderer may cache recently opened or preloaded markdown content, but cached content is only a performance hint. Before showing cached markdown or editor-ready blocks, `useTabManagement` validates the cached string with the `validate_note_content` Tauri command. That command re-enters the same vault path boundary checks as `get_note_content` and compares the cached text against the current on-disk file bytes. A mismatch, missing file, or unreadable file falls back to the normal fresh-read path and existing missing/unreadable recovery. + +Prepared BlockNote blocks in `useEditorTabSwap` are keyed by path plus source content. They can be built ahead of time from prefetched markdown, but they are reused only when the validated raw content for that path is identical to the source content that produced the blocks. + ### Entity Types (isA / type) Entity type is stored in the `type:` frontmatter field (e.g. `type: Quarter`). The legacy field name `Is A:` is still accepted as an alias for backwards compatibility but new notes use `type:`. The `VaultEntry.isA` property in TypeScript/Rust holds the resolved value. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 02792103..f10436f0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -89,6 +89,12 @@ flowchart LR The main window starts a native watcher for the active vault through `start_vault_watcher` / `stop_vault_watcher` (`src-tauri/src/vault_watcher.rs`, backed by Rust `notify`). The watcher emits `vault-changed` events for content paths and ignores churn from `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`. `useVaultWatcher` batches those events, suppresses recent app-owned saves, and sends the remaining external paths through `refreshPulledVaultState()` so folders, saved views, note-list state, and the clean active editor all refresh under the ADR-0071 unsaved-edit rules. `useVaultLoader.isReloading` drives the status-bar reload spinner for both manual and watcher-triggered reloads. +#### Note Opening Fast Path + +Note opening uses a bounded in-memory fast path split across raw content and editor-ready blocks. `useTabManagement` owns the raw markdown prefetch cache and `useEditorTabSwap` owns the prepared BlockNote block cache. Cached or preloaded markdown is never rendered directly: before reusing it, the renderer calls the `validate_note_content` Tauri command, which compares the cached text with the current file bytes inside the validated vault boundary. If validation fails, Tolaria discards the cached entry and reads fresh disk content before swapping the editor. + +The note list opportunistically preloads visible and adjacent markdown/text entries after a short idle delay. Once raw content resolves, the editor prepares BlockNote blocks in the background when it is mounted and not in raw mode. This targets the expensive markdown-to-editor conversion stage while keeping filesystem content authoritative and keeping preload memory bounded by the same cache limits as ordinary note switches. + ## Tech Stack | Layer | Technology | Version | diff --git a/src-tauri/src/commands/vault/file_cmds.rs b/src-tauri/src/commands/vault/file_cmds.rs index 08156dc1..abf304c5 100644 --- a/src-tauri/src/commands/vault/file_cmds.rs +++ b/src-tauri/src/commands/vault/file_cmds.rs @@ -101,6 +101,20 @@ pub fn get_note_content(path: PathBuf, vault_path: Option) -> Result, +) -> Result { + with_note_path( + path.as_path(), + vault_path.as_deref(), + ValidatedPathMode::Existing, + |validated_path| vault::note_content_matches(validated_path, &content), + ) +} + #[tauri::command] pub fn save_note_content( path: PathBuf, @@ -341,4 +355,18 @@ mod tests { .unwrap_err(); assert!(folder_error.contains("Path must stay inside the active vault")); } + + #[test] + fn validate_note_content_compares_against_disk() { + let dir = TempDir::new().unwrap(); + let root = vault_root(&dir); + let note = note_path(&dir, "note.md"); + fs::write(¬e, "# Fresh\n").unwrap(); + + assert!( + validate_note_content(note.clone(), "# Fresh\n".to_string(), Some(root.clone()),) + .unwrap() + ); + assert!(!validate_note_content(note, "# Stale\n".to_string(), Some(root)).unwrap()); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4e208602..4403a1fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -372,6 +372,7 @@ macro_rules! app_invoke_handler { commands::list_vault, commands::list_vault_folders, commands::get_note_content, + commands::validate_note_content, commands::create_note_content, commands::save_note_content, commands::update_frontmatter, diff --git a/src-tauri/src/vault/file.rs b/src-tauri/src/vault/file.rs index a4f5a018..6862c864 100644 --- a/src-tauri/src/vault/file.rs +++ b/src-tauri/src/vault/file.rs @@ -29,6 +29,16 @@ fn is_invalid_platform_path_error(error: &Error) -> bool { error.kind() == ErrorKind::InvalidInput || error.raw_os_error() == Some(123) } +fn read_existing_note_bytes(path: &Path) -> Result, String> { + if !path.exists() { + return Err(format!("File does not exist: {}", path.display())); + } + if !path.is_file() { + return Err(format!("Path is not a file: {}", path.display())); + } + fs::read(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e)) +} + #[derive(Clone, Copy)] enum NoteIoOperation { Save, @@ -70,16 +80,16 @@ fn note_io_error(operation: NoteIoOperation, path: NotePathDisplay<'_>, error: & /// Read the content of a single note file. pub fn get_note_content(path: &Path) -> Result { - if !path.exists() { - return Err(format!("File does not exist: {}", path.display())); - } - if !path.is_file() { - return Err(format!("Path is not a file: {}", path.display())); - } - let bytes = fs::read(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; + let bytes = read_existing_note_bytes(path)?; String::from_utf8(bytes).map_err(|_| invalid_utf8_text_error(path)) } +/// Check whether a note still has the exact content the renderer cached. +pub fn note_content_matches(path: &Path, expected_content: &str) -> Result { + let bytes = read_existing_note_bytes(path)?; + Ok(bytes == expected_content.as_bytes()) +} + fn validate_save_path(file_path: &Path, display_path: &str) -> Result<(), String> { let parent_missing = file_path.parent().is_some_and(|p| !p.exists()); if parent_missing { @@ -155,4 +165,14 @@ mod tests { assert!(message.contains("Rename the note or move it to a valid folder")); assert!(!message.contains("os error 123")); } + + #[test] + fn note_content_matches_detects_external_edits() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("note.md"); + fs::write(&path, "# Fresh\n").unwrap(); + + assert!(note_content_matches(&path, "# Fresh\n").unwrap()); + assert!(!note_content_matches(&path, "# Stale\n").unwrap()); + } } diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index f55ed31d..e45d5ad7 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -22,7 +22,7 @@ pub use config_seed::{ seed_config_files, AiGuidanceFileState, VaultAiGuidanceStatus, }; pub use entry::{FolderNode, VaultEntry}; -pub use file::{create_note_content, get_note_content, save_note_content}; +pub use file::{create_note_content, get_note_content, note_content_matches, save_note_content}; pub use folders::{delete_folder, rename_folder, FolderRenameResult}; pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists}; pub use ignored::{filter_gitignored_entries, filter_gitignored_folders, filter_gitignored_paths}; diff --git a/src/components/note-list/useNoteListModel.tsx b/src/components/note-list/useNoteListModel.tsx index ffd8cec0..05e76e32 100644 --- a/src/components/note-list/useNoteListModel.tsx +++ b/src/components/note-list/useNoteListModel.tsx @@ -31,6 +31,8 @@ import { useChangesContextMenu } from './NoteListChangesMenu' import { addNoteListSearchToggleListener, dispatchNoteListSearchAvailability } from '../../utils/noteListSearchEvents' type EntitySelection = Extract +const LIKELY_NEXT_PRELOAD_LIMIT = 8 +const ADJACENT_PRELOAD_RADIUS = 2 function useViewFlags(selection: SidebarSelection) { const isSectionGroup = selection.kind === 'sectionGroup' @@ -42,6 +44,36 @@ function useViewFlags(selection: SidebarSelection) { return { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills } } +function likelyNextPreloadEntries(entries: VaultEntry[], selectedNotePath: string | null): VaultEntry[] { + if (entries.length === 0) return [] + const selectedIndex = selectedNotePath + ? entries.findIndex((entry) => entry.path === selectedNotePath) + : -1 + const start = selectedIndex >= 0 + ? Math.max(0, selectedIndex - ADJACENT_PRELOAD_RADIUS) + : 0 + const end = selectedIndex >= 0 + ? Math.min(entries.length, selectedIndex + ADJACENT_PRELOAD_RADIUS + 1) + : Math.min(entries.length, LIKELY_NEXT_PRELOAD_LIMIT) + return entries + .slice(start, end) + .filter((entry) => !isDeletedNoteEntry(entry) && entry.fileKind !== 'binary') + .slice(0, LIKELY_NEXT_PRELOAD_LIMIT) +} + +function useLikelyNextPreload(entries: VaultEntry[], selectedNotePath: string | null) { + useEffect(() => { + const candidates = likelyNextPreloadEntries(entries, selectedNotePath) + if (candidates.length === 0) return + + const timer = window.setTimeout(() => { + for (const entry of candidates) prefetchNoteContent(entry.path) + }, 100) + + return () => window.clearTimeout(timer) + }, [entries, selectedNotePath]) +} + function useBulkActions( multiSelect: MultiSelectState, onBulkArchive: NoteListProps['onBulkArchive'], @@ -94,6 +126,7 @@ interface UseNoteListContentParams { modifiedSuffixes: string[] modifiedPathSet: Set isInboxView: boolean + selectedNotePath: string | null allNotesNoteListProperties?: string[] | null onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void inboxNoteListProperties?: string[] | null @@ -114,6 +147,7 @@ function useNoteListContent({ modifiedSuffixes, modifiedPathSet, isInboxView, + selectedNotePath, allNotesNoteListProperties, onUpdateAllNotesNoteListProperties, inboxNoteListProperties, @@ -195,6 +229,7 @@ function useNoteListContent({ displayPropsOverride, }), [displayPropsOverride, entries, query, sortedGroups, typeEntryMap]) useVisibleNotesSync({ visibleNotesRef, isEntityView, entityEntry, searched, searchedGroups }) + useLikelyNextPreload(searched, selectedNotePath) return { customProperties, @@ -550,6 +585,7 @@ export function useNoteListModel({ modifiedSuffixes, modifiedPathSet, isInboxView, + selectedNotePath, allNotesNoteListProperties, onUpdateAllNotesNoteListProperties, inboxNoteListProperties, diff --git a/src/hooks/editorTabSwapLifecycle.ts b/src/hooks/editorTabSwapLifecycle.ts new file mode 100644 index 00000000..fdd78978 --- /dev/null +++ b/src/hooks/editorTabSwapLifecycle.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef, type MutableRefObject } from 'react' +import type { useCreateBlockNote } from '@blocknote/react' + +export function useLatestRef(value: T): MutableRefObject { + const ref = useRef(value) + useEffect(() => { + ref.current = value + }, [value]) + return ref +} + +export function useEditorMountState( + editor: ReturnType, + editorMountedRef: MutableRefObject, + pendingSwapRef: MutableRefObject<(() => void) | null>, +) { + useEffect(() => { + if (editor.prosemirrorView) { + editorMountedRef.current = true + } + const cleanup = editor.onMount(() => { + editorMountedRef.current = true + if (pendingSwapRef.current) { + const swap = pendingSwapRef.current + pendingSwapRef.current = null + queueMicrotask(swap) + } + }) + return cleanup + }, [editor, editorMountedRef, pendingSwapRef]) +} diff --git a/src/hooks/useEditorTabSwap.test.ts b/src/hooks/useEditorTabSwap.test.ts index 762ba5e8..26479c1a 100644 --- a/src/hooks/useEditorTabSwap.test.ts +++ b/src/hooks/useEditorTabSwap.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter, useEditorTabSwap } from './useEditorTabSwap' import { normalizeParsedImageBlocks } from './editorTabContent' +import { cacheNoteContent, clearPrefetchCache } from './useTabManagement' describe('extractEditorBody', () => { it('strips frontmatter and preserves H1 heading for new note content', () => { @@ -424,6 +425,33 @@ describe('useEditorTabSwap raw mode sync', () => { expect(mockEditor.replaceBlocks).toHaveBeenCalled() }) + it('prepares prefetched note blocks before the note is opened', async () => { + clearPrefetchCache() + const tabA = makeTab('a.md', 'Note A') + const tabB = { + ...makeTab('b.md', 'Likely Next'), + content: '---\ntitle: Likely Next\n---\n\n# Likely Next\n\nPrepared body.', + } + + const { mockEditor, rerenderWith } = await createSwapHarness({ + initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, + }) + mockEditor.tryParseMarkdownToBlocks.mockClear() + + cacheNoteContent('b.md', tabB.content) + await act(() => new Promise((resolve) => setTimeout(resolve, 100))) + + expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith( + expect.stringContaining('Prepared body.'), + ) + + mockEditor.tryParseMarkdownToBlocks.mockClear() + await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' }) + + expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled() + clearPrefetchCache() + }) + it('clears stale editor DOM selection before switching notes', async () => { const tabA = makeTab('a.md', 'Note A') const tabB = makeTab('b.md', 'Note B') diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index 277b649b..cc14e9b7 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -7,6 +7,9 @@ import { injectMathInBlocks, preProcessMathMarkdown } from '../utils/mathMarkdow import { injectMermaidInBlocks, preProcessMermaidMarkdown, serializeMermaidAwareBlocks } from '../utils/mermaidMarkdown' import { failNoteOpenTrace, finishNoteOpenTrace } from '../utils/noteOpenPerformance' import { resolveImageUrls, portableImageUrls } from '../utils/vaultImages' +import { getResolvedCachedNoteContent, NOTE_CONTENT_CACHE_RESOLVED_EVENT } from './useTabManagement' +import { useEditorMountState, useLatestRef } from './editorTabSwapLifecycle' +import { usePreparedNotePreload } from './usePreparedNotePreload' import { repairMalformedEditorBlocks } from './editorBlockRepair' import { blankParagraphBlocks, @@ -184,6 +187,22 @@ async function resolveBlocksForTarget( return nextState } +function shouldPrepareCachedContent(cache: Map, path: string, content: string): boolean { + return cache.get(path)?.sourceContent !== content +} + +async function prepareCachedNoteContent(options: { + editor: ReturnType + cache: Map + path: string + vaultPath?: string +}): Promise { + const { editor, cache, path, vaultPath } = options + const cached = getResolvedCachedNoteContent(path) + if (!cached || !shouldPrepareCachedContent(cache, path, cached.content)) return + await resolveBlocksForTarget({ editor, cache, targetPath: path, content: cached.content, vaultPath }) +} + function applyBlocksToEditor( editor: ReturnType, blocks: EditorBlocks, @@ -325,35 +344,6 @@ function isUntitledRenameTransition( }) } -function useLatestRef(value: T): MutableRefObject { - const ref = useRef(value) - useEffect(() => { - ref.current = value - }, [value]) - return ref -} - -function useEditorMountState( - editor: ReturnType, - editorMountedRef: MutableRefObject, - pendingSwapRef: MutableRefObject<(() => void) | null>, -) { - useEffect(() => { - if (editor.prosemirrorView) { - editorMountedRef.current = true - } - const cleanup = editor.onMount(() => { - editorMountedRef.current = true - if (pendingSwapRef.current) { - const swap = pendingSwapRef.current - pendingSwapRef.current = null - queueMicrotask(swap) - } - }) - return cleanup - }, [editor, editorMountedRef, pendingSwapRef]) -} - function useEditorChangeHandler(options: { editor: ReturnType tabsRef: MutableRefObject @@ -1146,6 +1136,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, pendingLocalContentRef, vaultPathRef, }) + const preparePreloadedPath = useCallback((path: string) => prepareCachedNoteContent({ editor, cache: tabCacheRef.current, path, vaultPath: vaultPathRef.current }), [editor, tabCacheRef, vaultPathRef]) useEditorMountState(editor, editorMountedRef, pendingSwapRef) useTabSwapEffect({ @@ -1163,6 +1154,12 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, pendingLocalContentRef, vaultPathRef, }) + usePreparedNotePreload({ + eventName: NOTE_CONTENT_CACHE_RESOLVED_EVENT, + editorMountedRef, + rawMode, + preparePath: preparePreloadedPath, + }) return { handleEditorChange, editorMountedRef } } diff --git a/src/hooks/usePreparedNotePreload.ts b/src/hooks/usePreparedNotePreload.ts new file mode 100644 index 00000000..1ccfe7ea --- /dev/null +++ b/src/hooks/usePreparedNotePreload.ts @@ -0,0 +1,85 @@ +import { useEffect, type MutableRefObject } from 'react' + +const PREPARED_NOTE_PRELOAD_DELAY_MS = 75 + +interface PreparedNotePreloadOptions { + eventName: string + editorMountedRef: MutableRefObject + rawMode?: boolean + preparePath: (path: string) => Promise | void +} + +interface PreloadQueueState { + timer: number | null + cancelled: boolean + paths: string[] +} + +interface PreloadRunnerOptions extends Omit { + state: PreloadQueueState +} + +function canSchedulePreload(state: PreloadQueueState): boolean { + return state.timer === null && state.paths.length > 0 +} + +function canRunPreload({ state, rawMode, editorMountedRef }: PreloadRunnerOptions): boolean { + return !state.cancelled && !rawMode && editorMountedRef.current +} + +function schedulePreparedPreload(options: PreloadRunnerOptions): void { + const { state } = options + if (!canSchedulePreload(state)) return + + state.timer = window.setTimeout(() => { + state.timer = null + if (!canRunPreload(options)) return + + const nextPath = state.paths.shift() + if (!nextPath) return + Promise.resolve(options.preparePath(nextPath)).finally(() => { + schedulePreparedPreload(options) + }) + }, PREPARED_NOTE_PRELOAD_DELAY_MS) +} + +function enqueuePreloadPath(state: PreloadQueueState, path: string): void { + if (state.paths.includes(path)) return + state.paths.push(path) +} + +function resolvedContentPath(event: Event): string | null { + return (event as CustomEvent<{ path?: string }>).detail?.path ?? null +} + +function cancelPreloadQueue(state: PreloadQueueState): void { + state.cancelled = true + if (state.timer !== null) window.clearTimeout(state.timer) +} + +export function usePreparedNotePreload({ + eventName, + editorMountedRef, + rawMode, + preparePath, +}: PreparedNotePreloadOptions) { + useEffect(() => { + if (typeof window === 'undefined') return + + const state: PreloadQueueState = { timer: null, cancelled: false, paths: [] } + const runner = { state, editorMountedRef, rawMode, preparePath } + + const handleResolvedContent = (event: Event) => { + const path = resolvedContentPath(event) + if (!path) return + enqueuePreloadPath(state, path) + schedulePreparedPreload(runner) + } + + window.addEventListener(eventName, handleResolvedContent) + return () => { + cancelPreloadQueue(state) + window.removeEventListener(eventName, handleResolvedContent) + } + }, [editorMountedRef, eventName, preparePath, rawMode]) +} diff --git a/src/hooks/useTabManagement.test.ts b/src/hooks/useTabManagement.test.ts index a10fd87c..8803b343 100644 --- a/src/hooks/useTabManagement.test.ts +++ b/src/hooks/useTabManagement.test.ts @@ -57,12 +57,23 @@ async function replaceActiveNote(result: HookState, overrides: Partial expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)) return mockInvoke } +function mockNoteContent(contentByPath: Record) { + vi.mocked(mockInvoke).mockImplementation((cmd: string, args?: Record) => { + const path = typeof args?.path === 'string' ? args.path : '' + const content = contentByPath[path] ?? '# Mock content' + if (cmd === 'validate_note_content') { + return Promise.resolve(content === args?.content) + } + return Promise.resolve(content) + }) +} + function expectSingleActiveTab(result: HookState, path: string) { expect(result.current.tabs).toHaveLength(1) expect(result.current.tabs[0].entry.path).toBe(path) @@ -386,22 +397,51 @@ describe('useTabManagement (single-note model)', () => { }) describe('content prefetch cache', () => { - it('prefetch paints cached content immediately and still validates it from disk', async () => { + it('prefetch validates cached content against disk before reuse', async () => { const mockInvoke = await prefetchResolvedContent('/vault/note/pre.md', '# Prefetched content') - vi.mocked(mockInvoke).mockResolvedValue('# Prefetched content') const { result } = renderHook(() => useTabManagement()) await selectNote(result, { path: '/vault/note/pre.md', title: 'Pre' }) expect(result.current.tabs[0].content).toBe('# Prefetched content') expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2) + expect(vi.mocked(mockInvoke)).toHaveBeenLastCalledWith('validate_note_content', { + path: '/vault/note/pre.md', + content: '# Prefetched content', + }) + }) + + it('does not paint cached content until freshness validation passes', async () => { + const freshness = createDeferred() + cacheNoteContent('/vault/note/stale.md', '# Stale cached content') + vi.mocked(mockInvoke).mockImplementation((cmd: string) => { + if (cmd === 'validate_note_content') return freshness.promise + return Promise.resolve('# Fresh disk content') + }) + + const { result } = renderHook(() => useTabManagement()) + act(() => { + void result.current.handleSelectNote(makeEntry({ path: '/vault/note/stale.md', title: 'Stale' })) + }) + + expect(result.current.activeTabPath).toBe('/vault/note/stale.md') + expect(result.current.tabs).toEqual([]) + + await act(async () => { + freshness.resolve(false) + await Promise.resolve() + }) + + await vi.waitFor(() => { + expect(result.current.tabs[0].content).toBe('# Fresh disk content') + }) }) it('clearPrefetchCache prevents stale content from being served', async () => { const mockInvoke = await prefetchResolvedContent('/vault/note/stale.md', '# Stale') clearPrefetchCache() - vi.mocked(mockInvoke).mockResolvedValue('# Fresh') + mockNoteContent({ '/vault/note/stale.md': '# Fresh' }) const { result } = renderHook(() => useTabManagement()) await selectNote(result, { path: '/vault/note/stale.md', title: 'Stale' }) @@ -439,7 +479,7 @@ describe('useTabManagement (single-note model)', () => { it('serves refreshed cached content after a save replaces stale prefetched data', async () => { const mockInvoke = await prefetchResolvedContent('/vault/note/saved.md', '# Stale prefetched content') - vi.mocked(mockInvoke).mockResolvedValue('# Persisted content') + mockNoteContent({ '/vault/note/saved.md': '# Persisted content' }) cacheNoteContent('/vault/note/saved.md', '# Persisted content') @@ -450,9 +490,12 @@ describe('useTabManagement (single-note model)', () => { expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2) }) - it('activates a warmed note immediately while reusing the cached content', async () => { - const deferred = createDeferred() - vi.mocked(mockInvoke).mockImplementationOnce(() => deferred.promise) + it('activates a warmed note after validating cached content', async () => { + const deferred = createDeferred() + vi.mocked(mockInvoke).mockImplementation((cmd: string) => { + if (cmd === 'validate_note_content') return deferred.promise + return Promise.resolve('# Warm content') + }) cacheNoteContent('/vault/note/warm.md', '# Warm content') const { result } = renderHook(() => useTabManagement()) @@ -462,14 +505,16 @@ describe('useTabManagement (single-note model)', () => { }) expect(result.current.activeTabPath).toBe('/vault/note/warm.md') - expect(result.current.tabs).toHaveLength(1) - expect(result.current.tabs[0].content).toBe('# Warm content') + expect(result.current.tabs).toEqual([]) expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1) await act(async () => { - deferred.resolve('# Warm content') + deferred.resolve(true) await Promise.resolve() }) + + expect(result.current.tabs).toHaveLength(1) + expect(result.current.tabs[0].content).toBe('# Warm content') }) it('does not retain oversized notes in the prefetch cache', async () => { @@ -520,8 +565,11 @@ describe('useTabManagement (single-note model)', () => { it('keeps the newest cached notes warm when trimming to the byte budget', async () => { const { cachedContent, newestPath } = seedCacheBeyondByteLimit() - const deferred = createDeferred() - vi.mocked(mockInvoke).mockImplementationOnce(() => deferred.promise) + const deferred = createDeferred() + vi.mocked(mockInvoke).mockImplementation((cmd: string) => { + if (cmd === 'validate_note_content') return deferred.promise + return Promise.resolve(cachedContent) + }) const { result } = renderHook(() => useTabManagement()) @@ -530,20 +578,22 @@ describe('useTabManagement (single-note model)', () => { }) expect(result.current.activeTabPath).toBe(newestPath) - expect(result.current.tabs).toHaveLength(1) - expect(result.current.tabs[0].content).toBe(cachedContent) + expect(result.current.tabs).toEqual([]) await act(async () => { - deferred.resolve(cachedContent) + deferred.resolve(true) await Promise.resolve() }) + + expect(result.current.tabs).toHaveLength(1) + expect(result.current.tabs[0].content).toBe(cachedContent) }) it('reuses cached content when reopening a recently loaded note', async () => { - vi.mocked(mockInvoke) - .mockResolvedValueOnce('# A content') - .mockResolvedValueOnce('# B content') - .mockResolvedValueOnce('# A content') + mockNoteContent({ + '/vault/a.md': '# A content', + '/vault/b.md': '# B content', + }) const { result } = renderHook(() => useTabManagement()) await selectNote(result, { path: '/vault/a.md', title: 'A' }) diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index 1a0d8fdf..da26795f 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -31,6 +31,7 @@ interface NoteContentCacheEntry { const prefetchCache = new Map() const contentSizeEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null +export const NOTE_CONTENT_CACHE_RESOLVED_EVENT = 'laputa:note-content-cache-resolved' export const NOTE_CONTENT_CACHE_LIMIT = 48 export const NOTE_CONTENT_ENTRY_MAX_BYTES = 256 * 1024 export const NOTE_CONTENT_CACHE_MAX_BYTES = 1024 * 1024 @@ -81,6 +82,7 @@ function retainResolvedNoteContent(entry: NoteContentCacheEntry, content: string entry.value = content entry.byteSize = byteSize rememberNoteContent(entry) + dispatchResolvedNoteContent(entry.path) } function getNoteContentCommandPayload(path: string): { path: string; vaultPath?: string } { @@ -94,6 +96,17 @@ function getNoteContentCommandPayload(path: string): { path: string; vaultPath?: : { path } } +function getValidateNoteContentCommandPayload(path: string, content: string): { path: string; content: string; vaultPath?: string } { + return { ...getNoteContentCommandPayload(path), content } +} + +function dispatchResolvedNoteContent(path: string): void { + if (typeof window === 'undefined') return + window.dispatchEvent(new CustomEvent(NOTE_CONTENT_CACHE_RESOLVED_EVENT, { + detail: { path }, + })) +} + function requestNoteContent({ path }: Pick): NoteContentCacheEntry { const cacheEntry: NoteContentCacheEntry = { path, @@ -143,6 +156,7 @@ export function cacheNoteContent(path: string, content: string): void { value: content, byteSize, }) + dispatchResolvedNoteContent(path) } /** Clear the prefetch cache. Call on vault reload to prevent stale content. */ @@ -150,8 +164,27 @@ export function clearPrefetchCache(): void { prefetchCache.clear() } -function getCachedNoteContent(path: string): string | null { - return prefetchCache.get(path)?.value ?? null +export function getResolvedCachedNoteContent(path: string): { path: string; content: string } | null { + const entry = prefetchCache.get(path) + if (!entry || entry.value === null) return null + return { path: entry.path, content: entry.value } +} + +function hasResolvedCachedContent(entry: NoteContentCacheEntry | null): entry is NoteContentCacheEntry & { value: string } { + if (!entry) return false + return entry.value !== null +} + +function getCachedNoteContentEntry(path: string): NoteContentCacheEntry | null { + return prefetchCache.get(path) ?? null +} + +async function validateCachedNoteContent(entry: NoteContentCacheEntry): Promise { + if (entry.value === null) return false + const payload = getValidateNoteContentCommandPayload(entry.path, entry.value) + return isTauri() + ? invoke('validate_note_content', payload) + : mockInvoke('validate_note_content', payload) } async function loadNoteContent(path: string, forceFresh = false): Promise { @@ -159,6 +192,34 @@ async function loadNoteContent(path: string, forceFresh = false): Promise { + if (entry.value === null) return null + markNoteOpenTrace(entry.path, 'freshnessCheckStart') + const isFresh = await validateCachedNoteContent(entry) + markNoteOpenTrace(entry.path, 'freshnessCheckEnd') + if (isFresh) { + rememberNoteContent(entry) + return entry.value + } + prefetchCache.delete(entry.path) + return null +} + +async function loadContentForOpen(options: { + path: string + forceReload: boolean + cachedEntry: NoteContentCacheEntry | null +}): Promise { + const { path, forceReload, cachedEntry } = options + + if (!forceReload && hasResolvedCachedContent(cachedEntry)) { + const cachedContent = await loadCachedContentIfFresh(cachedEntry) + if (cachedContent !== null) return cachedContent + } + + return loadNoteContent(path, forceReload || hasResolvedCachedContent(cachedEntry)) +} + export type { Tab } interface TabManagementOptions { @@ -229,29 +290,24 @@ function isAlreadyViewingPath( function startEntryNavigation(options: { entry: VaultEntry navSeqRef: React.MutableRefObject - tabsRef: React.MutableRefObject activeTabPathRef: React.MutableRefObject - setTabs: React.Dispatch> setActiveTabPath: React.Dispatch> }) { const { entry, navSeqRef, - tabsRef, activeTabPathRef, - setTabs, setActiveTabPath, } = options const seq = ++navSeqRef.current - const cachedContent = getCachedNoteContent(entry.path) + const cachedEntry = getCachedNoteContentEntry(entry.path) syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path) - if (cachedContent !== null) { + if (hasResolvedCachedContent(cachedEntry)) { markNoteOpenTrace(entry.path, 'cacheReady') - setSingleTab(tabsRef, setTabs, { entry, content: cachedContent }) } - return { seq, cachedContent } + return { seq, cachedEntry } } function openBinaryEntry(options: { @@ -307,25 +363,24 @@ function isUnreadableNoteContentError(error: unknown): boolean { function shouldApplyLoadedEntry(options: { seq: number navSeqRef: React.MutableRefObject - cachedContent: string | null - content: string forceReload: boolean activeTabPathRef: React.MutableRefObject + tabsRef: React.MutableRefObject path: string }) { const { seq, navSeqRef, - cachedContent, - content, forceReload, activeTabPathRef, + tabsRef, path, } = options if (navSeqRef.current !== seq) return false if (forceReload) return true - return cachedContent !== content || !pathsMatch(activeTabPathRef.current, path) + if (!pathsMatch(activeTabPathRef.current, path)) return true + return !tabsRef.current.some((tab) => pathsMatch(tab.entry.path, path)) } type EntryLoadFailureKind = @@ -494,29 +549,27 @@ async function loadTextEntry(options: Required any> = { reload_vault_entry: (args: { path: string }) => MOCK_ENTRIES.find(e => e.path === args.path) ?? { path: args.path, title: 'Unknown', filename: 'unknown.md', aliases: [], belongsTo: [], relatedTo: [], archived: false, snippet: '', wordCount: 0, fileSize: 0, relationships: {}, outgoingLinks: [], properties: {} }, sync_note_title: () => false, get_note_content: (args: { path: string }) => MOCK_CONTENT[args.path] ?? '', + validate_note_content: (args: { path: string; content: string }) => (MOCK_CONTENT[args.path] ?? '') === args.content, get_all_content: () => MOCK_CONTENT, get_file_history: (args: { path: string }) => mockFileHistory(args.path), get_modified_files: () => { diff --git a/src/mock-tauri/vault-api.test.ts b/src/mock-tauri/vault-api.test.ts index 2e019ca2..309be6ec 100644 --- a/src/mock-tauri/vault-api.test.ts +++ b/src/mock-tauri/vault-api.test.ts @@ -58,4 +58,29 @@ describe('tryVaultApi', () => { await expect(tryVaultApi('get_note_content', { path: '/fixture/alpha.md' })).resolves.toBe('# Alpha Project') expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/api/vault/ping')).toHaveLength(1) }) + + it('validates cached note content through the vault API', async () => { + const fetchMock = vi.fn(async (input: string | URL) => { + const url = String(input) + if (url === '/api/vault/ping') { + return jsonResponse({ ok: true }) + } + if (url === '/api/vault/content?path=%2Ffixture%2Falpha.md') { + return jsonResponse({ content: '# Alpha Project' }) + } + throw new Error(`Unexpected fetch: ${url}`) + }) + globalThis.fetch = fetchMock as typeof fetch + + const { tryVaultApi } = await import('./vault-api') + + await expect(tryVaultApi('validate_note_content', { + path: '/fixture/alpha.md', + content: '# Alpha Project', + })).resolves.toBe(true) + await expect(tryVaultApi('validate_note_content', { + path: '/fixture/alpha.md', + content: '# Stale', + })).resolves.toBe(false) + }) }) diff --git a/src/mock-tauri/vault-api.ts b/src/mock-tauri/vault-api.ts index 30c24cbb..4caefe9a 100644 --- a/src/mock-tauri/vault-api.ts +++ b/src/mock-tauri/vault-api.ts @@ -46,6 +46,8 @@ const VAULT_API_COMMANDS: Record) => Vaul args.path ? { url: `/api/vault/entry?path=${encodeURIComponent(args.path as string)}` } : null, get_note_content: (args) => args.path ? { url: `/api/vault/content?path=${encodeURIComponent(args.path as string)}` } : null, + validate_note_content: (args) => + args.path ? { url: `/api/vault/content?path=${encodeURIComponent(args.path as string)}` } : null, get_all_content: (args) => args.path ? { url: `/api/vault/all-content?path=${encodeURIComponent(args.path as string)}` } : null, save_note_content: (args) => @@ -113,7 +115,9 @@ export async function tryVaultApi(cmd: string, args?: Record try { const data = await fetchVaultApiResponse(request) if (data === undefined) return undefined - return (cmd === 'get_note_content' ? data.content : data) as T + if (cmd === 'get_note_content') return data.content as T + if (cmd === 'validate_note_content') return (data.content === args?.content) as T + return data as T } catch (err) { console.warn(`[mock-tauri] Vault API call failed for ${cmd}, falling back to mock:`, err) return undefined diff --git a/src/utils/noteOpenPerformance.extra.test.ts b/src/utils/noteOpenPerformance.extra.test.ts index d4f3e09f..8fefb879 100644 --- a/src/utils/noteOpenPerformance.extra.test.ts +++ b/src/utils/noteOpenPerformance.extra.test.ts @@ -33,7 +33,7 @@ describe('noteOpenPerformance additional coverage', () => { finishNoteOpenTrace('/vault/missing-stages.md') expect(debugSpy).toHaveBeenCalledWith( - '[perf] noteOpen path=/vault/missing-stages.md source=quick-open total=25.0ms beforeNavigate=n/a contentLoad=n/a editorSwap=25.0ms cache=miss', + '[perf] noteOpen path=/vault/missing-stages.md source=quick-open total=25.0ms beforeNavigate=n/a freshnessCheck=n/a contentLoad=n/a editorSwap=25.0ms cache=miss', ) }) diff --git a/src/utils/noteOpenPerformance.test.ts b/src/utils/noteOpenPerformance.test.ts index a704e38c..f7e20125 100644 --- a/src/utils/noteOpenPerformance.test.ts +++ b/src/utils/noteOpenPerformance.test.ts @@ -33,18 +33,22 @@ describe('noteOpenPerformance', () => { .mockReturnValueOnce(170) .mockReturnValueOnce(200) .mockReturnValueOnce(245) + .mockReturnValueOnce(255) .mockReturnValueOnce(290) + .mockReturnValueOnce(315) beginNoteOpenTrace('/vault/note.md', 'sidebar') markNoteOpenTrace('/vault/note.md', 'beforeNavigateStart') markNoteOpenTrace('/vault/note.md', 'beforeNavigateEnd') markNoteOpenTrace('/vault/note.md', 'cacheReady') + markNoteOpenTrace('/vault/note.md', 'freshnessCheckStart') + markNoteOpenTrace('/vault/note.md', 'freshnessCheckEnd') markNoteOpenTrace('/vault/note.md', 'contentLoadStart') markNoteOpenTrace('/vault/note.md', 'contentLoadEnd') finishNoteOpenTrace('/vault/note.md') expect(debugSpy).toHaveBeenCalledWith( - '[perf] noteOpen path=/vault/note.md source=sidebar total=190.0ms beforeNavigate=30.0ms contentLoad=45.0ms editorSwap=45.0ms cache=hit', + '[perf] noteOpen path=/vault/note.md source=sidebar total=215.0ms beforeNavigate=30.0ms freshnessCheck=45.0ms contentLoad=35.0ms editorSwap=25.0ms cache=hit', ) }) diff --git a/src/utils/noteOpenPerformance.ts b/src/utils/noteOpenPerformance.ts index 95150877..904a657e 100644 --- a/src/utils/noteOpenPerformance.ts +++ b/src/utils/noteOpenPerformance.ts @@ -2,6 +2,8 @@ type NoteOpenStage = | 'beforeNavigateStart' | 'beforeNavigateEnd' | 'cacheReady' + | 'freshnessCheckStart' + | 'freshnessCheckEnd' | 'contentLoadStart' | 'contentLoadEnd' | 'editorSwapped' @@ -70,14 +72,17 @@ export function finishNoteOpenTrace(path: string): void { trace.marks.editorSwapped = performance.now() const total = trace.marks.editorSwapped - trace.startedAt const beforeNavigate = measureDuration(trace, 'beforeNavigateStart', 'beforeNavigateEnd') + const freshnessCheck = measureDuration(trace, 'freshnessCheckStart', 'freshnessCheckEnd') const contentLoad = measureDuration(trace, 'contentLoadStart', 'contentLoadEnd') const editorSwap = measureDuration(trace, 'contentLoadEnd', 'editorSwapped') + ?? measureDuration(trace, 'freshnessCheckEnd', 'editorSwapped') ?? measureDuration(trace, 'beforeNavigateEnd', 'editorSwapped') ?? measureDuration(trace, 'startedAt', 'editorSwapped') logPerf( `noteOpen path=${path} source=${trace.source} total=${formatDuration(total)} ` + `beforeNavigate=${formatDuration(beforeNavigate)} ` + + `freshnessCheck=${formatDuration(freshnessCheck)} ` + `contentLoad=${formatDuration(contentLoad)} ` + `editorSwap=${formatDuration(editorSwap)} ` + `cache=${trace.marks.cacheReady !== undefined ? 'hit' : 'miss'}`, diff --git a/tests/helpers/fixtureVault.ts b/tests/helpers/fixtureVault.ts index 1836824a..1b450ddd 100644 --- a/tests/helpers/fixtureVault.ts +++ b/tests/helpers/fixtureVault.ts @@ -293,6 +293,12 @@ async function installFixtureVaultInitScript({ page, vaultPath, isGitRepo }: Fix ) as { content: string } return data.content }, + validate_note_content: async (commandArgs?: FixtureCommandArgs) => { + const data = await readJson( + `/api/vault/content?path=${encodeURIComponent(readCommandString(commandArgs, 'path'))}`, + ) as { content: string } + return data.content === readCommandString(commandArgs, 'content') + }, get_all_content: (commandArgs?: FixtureCommandArgs) => readJson( `/api/vault/all-content?path=${encodeURIComponent(readCommandString(commandArgs, 'path', resolvedVaultPath))}`,