fix: normalize note open entries

This commit is contained in:
lucaronin
2026-05-07 06:41:50 +02:00
parent 8707c9e4be
commit 7bbff355f3
4 changed files with 86 additions and 24 deletions

View File

@@ -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.

View File

@@ -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(

View File

@@ -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<number>
tabsRef: React.MutableRefObject<Tab[]>
@@ -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<Tab[]>,
activeTabPathRef: React.MutableRefObject<string | null>,
@@ -240,6 +252,7 @@ function runEntryFailureCallback(options: {
function handleRecoverableEntryLoadFailure(options: {
kind: RecoverableEntryLoadFailureKind
entry: VaultEntry
callbackEntry: VaultEntry
tabsRef: React.MutableRefObject<Tab[]>
activeTabPathRef: React.MutableRefObject<string | null>
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
@@ -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<number>
tabsRef: React.MutableRefObject<Tab[]>
@@ -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<Pick<NavigateToEntryOptions, 'forceReload'>> & NavigateToEntryOptions) {
const {
entry,
sourceEntry,
forceReload,
navSeqRef,
tabsRef,
@@ -411,6 +429,7 @@ async function loadTextEntry(options: Required<Pick<NavigateToEntryOptions, 'for
} catch (err) {
handleEntryLoadFailure({
entry,
callbackEntry: callbackEntryForLoadFailure(entry, sourceEntry),
seq,
navSeqRef,
tabsRef,
@@ -481,14 +500,17 @@ export function useTabManagement(options: TabManagementOptions = {}) {
/** Open a note — replaces the current note (single-note model). */
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
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])

View File

@@ -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')