From 7bbff355f30b323c11263c3d82192bb8368a7966 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 7 May 2026 06:41:50 +0200 Subject: [PATCH] fix: normalize note open entries --- docs/ABSTRACTIONS.md | 2 +- src/hooks/useTabManagement.test.ts | 35 +++++++++ src/hooks/useTabManagement.ts | 71 +++++++++++++------ .../missing-string-metadata-open-note.spec.ts | 2 +- 4 files changed, 86 insertions(+), 24 deletions(-) diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 8ee9f3ae..b24a4e95 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -166,7 +166,7 @@ Asset previewability is inferred in the renderer from the filename extension (`s ### Note Content Freshness -The renderer may cache recently opened or preloaded markdown content, but cached content is only a performance hint. `useTabManagement` can reuse cached text immediately when it carries the same `modifiedAt` and `fileSize` identity as the current `VaultEntry`; otherwise it 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. Background note prefetch is bounded to a small number of concurrent native reads, and a note opened while queued is promoted to foreground instead of waiting behind the prefetch backlog. +The renderer may cache recently opened or preloaded markdown content, but cached content is only a performance hint. `useTabManagement` can reuse cached text immediately when it carries the same `modifiedAt` and `fileSize` identity as the current `VaultEntry`; otherwise it 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. Background note prefetch is bounded to a small number of concurrent native reads, and a note opened while queued is promoted to foreground instead of waiting behind the prefetch backlog. Note-open entry objects are re-normalized at the tab boundary, so transient reload or bridge payloads with missing display metadata fall back to filename/title defaults before editor chrome renders; entries without a usable path are ignored instead of opening a broken tab. `useEditorTabSwap` may reuse BlockNote blocks that were already opened successfully or warmed from prefetched raw content, keyed by vault, path, and exact source content. Background warming is limited to likely next large Markdown notes and defers while the editor is unmounted, raw mode is active, or recent typing/navigation is still inside the foreground idle window. Every async editor swap carries a generation and source-content token so stale conversion results cannot overwrite newer file content or dirty editor state. diff --git a/src/hooks/useTabManagement.test.ts b/src/hooks/useTabManagement.test.ts index 40a92ad9..5d9eb030 100644 --- a/src/hooks/useTabManagement.test.ts +++ b/src/hooks/useTabManagement.test.ts @@ -133,6 +133,41 @@ describe('useTabManagement (single-note model)', () => { expectSingleActiveTab(result, '/vault/note/a.md') }) + it('normalizes partially hydrated note metadata before opening after reload churn', async () => { + const partialEntry = { + path: '/vault/note/apple-mail.md', + title: undefined, + filename: undefined, + aliases: undefined, + outgoingLinks: undefined, + } as unknown as VaultEntry + + const { result } = renderHook(() => useTabManagement()) + await act(async () => { + await result.current.handleSelectNote(partialEntry) + }) + + expectSingleActiveTab(result, '/vault/note/apple-mail.md') + expect(result.current.tabs[0].entry).toEqual(expect.objectContaining({ + filename: 'apple-mail.md', + title: 'apple-mail', + aliases: [], + outgoingLinks: [], + })) + }) + + it('ignores note-open requests without a usable path', async () => { + const { result } = renderHook(() => useTabManagement()) + + await act(async () => { + await result.current.handleSelectNote(makeEntry({ path: ' ' })) + }) + + expect(result.current.tabs).toEqual([]) + expect(result.current.activeTabPath).toBeNull() + expect(vi.mocked(mockInvoke)).not.toHaveBeenCalled() + }) + it('switches the active path immediately while the next note is still loading', async () => { let resolveContent: (value: string) => void vi.mocked(mockInvoke).mockImplementationOnce( diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index 96c34112..13f7d567 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -22,6 +22,7 @@ import { } from './noteContentCache' import { clearParsedNoteBlockCache } from './editorParsedBlockCache' import { notePathsMatch } from '../utils/notePathIdentity' +import { normalizeVaultEntry } from '../utils/vaultMetadataNormalization' interface Tab { entry: VaultEntry @@ -61,6 +62,7 @@ interface TabManagementOptions { interface NavigateToEntryOptions { entry: VaultEntry + sourceEntry?: VaultEntry forceReload?: boolean navSeqRef: React.MutableRefObject tabsRef: React.MutableRefObject @@ -109,6 +111,16 @@ function clearTabs( setTabs([]) } +function normalizeOpenEntry(entry: VaultEntry): VaultEntry | null { + const path = typeof entry.path === 'string' ? entry.path.trim() : '' + if (!path) return null + return normalizeVaultEntry({ ...entry, path }) +} + +function callbackEntryForLoadFailure(entry: VaultEntry, sourceEntry?: VaultEntry): VaultEntry { + return sourceEntry ? { ...sourceEntry, path: entry.path } : entry +} + function isAlreadyViewingPath( tabsRef: React.MutableRefObject, activeTabPathRef: React.MutableRefObject, @@ -240,6 +252,7 @@ function runEntryFailureCallback(options: { function handleRecoverableEntryLoadFailure(options: { kind: RecoverableEntryLoadFailureKind entry: VaultEntry + callbackEntry: VaultEntry tabsRef: React.MutableRefObject activeTabPathRef: React.MutableRefObject setTabs: React.Dispatch> @@ -252,6 +265,7 @@ function handleRecoverableEntryLoadFailure(options: { const { kind, entry, + callbackEntry, tabsRef, activeTabPathRef, setTabs, @@ -277,7 +291,7 @@ function handleRecoverableEntryLoadFailure(options: { if (kind === 'missing-active-vault') { runEntryFailureCallback({ callback: onMissingActiveVault, - entry, + entry: callbackEntry, error, warning: 'Failed to handle missing active vault:', }) @@ -287,7 +301,7 @@ function handleRecoverableEntryLoadFailure(options: { if (kind === 'missing-path') { runEntryFailureCallback({ callback: onMissingNotePath, - entry, + entry: callbackEntry, error, warning: 'Failed to handle missing note path:', }) @@ -297,7 +311,7 @@ function handleRecoverableEntryLoadFailure(options: { if (kind === 'unreadable-content') { runEntryFailureCallback({ callback: onUnreadableNoteContent, - entry, + entry: callbackEntry, error, warning: 'Failed to handle unreadable note content:', }) @@ -306,6 +320,7 @@ function handleRecoverableEntryLoadFailure(options: { function handleEntryLoadFailure(options: { entry: VaultEntry + callbackEntry: VaultEntry seq: number navSeqRef: React.MutableRefObject tabsRef: React.MutableRefObject @@ -319,6 +334,7 @@ function handleEntryLoadFailure(options: { }) { const { entry, + callbackEntry, seq, navSeqRef, tabsRef, @@ -339,6 +355,7 @@ function handleEntryLoadFailure(options: { handleRecoverableEntryLoadFailure({ kind: failureKind, entry, + callbackEntry, tabsRef, activeTabPathRef, setTabs, @@ -372,6 +389,7 @@ function reopenAlreadyViewingEntry({ async function loadTextEntry(options: Required> & NavigateToEntryOptions) { const { entry, + sourceEntry, forceReload, navSeqRef, tabsRef, @@ -411,6 +429,7 @@ async function loadTextEntry(options: Required { - requestedActiveTabPathRef.current = entry.path - const alreadyViewingDirtyEntry = notePathsMatch(entry.path, activeTabPathRef.current) - && !!hasUnsavedChanges?.(entry.path) + const openEntry = normalizeOpenEntry(entry) + if (!openEntry) return + requestedActiveTabPathRef.current = openEntry.path + const alreadyViewingDirtyEntry = notePathsMatch(openEntry.path, activeTabPathRef.current) + && !!hasUnsavedChanges?.(openEntry.path) if (!alreadyViewingDirtyEntry) { - beginNoteOpenTrace(entry.path, 'select-note') + beginNoteOpenTrace(openEntry.path, 'select-note') } - const navigated = await executeNavigationWithBoundary(entry.path, () => navigateToEntry({ - entry, + const navigated = await executeNavigationWithBoundary(openEntry.path, () => navigateToEntry({ + entry: openEntry, + sourceEntry: entry, navSeqRef, tabsRef, activeTabPathRef, @@ -500,7 +522,7 @@ export function useTabManagement(options: TabManagementOptions = {}) { onUnreadableNoteContent, })) if (!navigated) { - resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, entry.path) + resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, openEntry.path) } }, [executeNavigationWithBoundary, hasUnsavedChanges, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent]) @@ -511,24 +533,29 @@ export function useTabManagement(options: TabManagementOptions = {}) { /** Open a tab with known content — no IPC round-trip. Used for newly created notes. */ const openTabWithContent = useCallback((entry: VaultEntry, content: string) => { - requestedActiveTabPathRef.current = entry.path - void executeNavigationWithBoundary(entry.path, () => { - cacheNoteContent(entry.path, content, entry) - setSingleTab(tabsRef, setTabs, { entry, content }) - syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path) + const openEntry = normalizeOpenEntry(entry) + if (!openEntry) return + requestedActiveTabPathRef.current = openEntry.path + void executeNavigationWithBoundary(openEntry.path, () => { + cacheNoteContent(openEntry.path, content, openEntry) + setSingleTab(tabsRef, setTabs, { entry: openEntry, content }) + syncActiveTabPath(activeTabPathRef, setActiveTabPath, openEntry.path) }).then((navigated) => { - if (!navigated) resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, entry.path) + if (!navigated) resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, openEntry.path) }) }, [executeNavigationWithBoundary]) const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => { - requestedActiveTabPathRef.current = entry.path - const replacingDifferentEntry = !notePathsMatch(entry.path, activeTabPathRef.current) + const openEntry = normalizeOpenEntry(entry) + if (!openEntry) return + requestedActiveTabPathRef.current = openEntry.path + const replacingDifferentEntry = !notePathsMatch(openEntry.path, activeTabPathRef.current) if (replacingDifferentEntry) { - beginNoteOpenTrace(entry.path, 'replace-active-tab') + beginNoteOpenTrace(openEntry.path, 'replace-active-tab') } - const navigated = await executeNavigationWithBoundary(entry.path, () => navigateToEntry({ - entry, + const navigated = await executeNavigationWithBoundary(openEntry.path, () => navigateToEntry({ + entry: openEntry, + sourceEntry: entry, forceReload: !replacingDifferentEntry, navSeqRef, tabsRef, @@ -540,7 +567,7 @@ export function useTabManagement(options: TabManagementOptions = {}) { onUnreadableNoteContent, })) if (!navigated) { - resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, entry.path) + resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, openEntry.path) } }, [executeNavigationWithBoundary, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent]) diff --git a/tests/smoke/missing-string-metadata-open-note.spec.ts b/tests/smoke/missing-string-metadata-open-note.spec.ts index 27b70726..2ae73575 100644 --- a/tests/smoke/missing-string-metadata-open-note.spec.ts +++ b/tests/smoke/missing-string-metadata-open-note.spec.ts @@ -119,7 +119,7 @@ test('@smoke note open tolerates missing string metadata from the vault scan', a expect(errors).toHaveLength(0) }) -test('note open after vault reload tolerates missing suggestion metadata', async ({ page }) => { +test('@smoke note open after vault reload tolerates missing suggestion metadata', async ({ page }) => { const errors = collectMissingMetadataCrashes(page) const noteList = page.getByTestId('note-list-container')