fix: normalize note open entries
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user