diff --git a/src/components/NoteList.keyboard.test.tsx b/src/components/NoteList.keyboard.test.tsx index 8b45cb0e..ceeb7f79 100644 --- a/src/components/NoteList.keyboard.test.tsx +++ b/src/components/NoteList.keyboard.test.tsx @@ -1,24 +1,32 @@ import { useState } from 'react' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { NoteList } from './NoteList' import { allSelection, + makeIndexedEntry, mockEntries, } from '../test-utils/noteListTestUtils' import type { SidebarSelection, VaultEntry } from '../types' import * as tabManagement from '../hooks/useTabManagement' function NoteListKeyboardHarness({ + entries = mockEntries, + initialSelectedNote = null, onOpen, onEnterNeighborhood = () => {}, + selectedNoteOverride, selection = allSelection, }: { + entries?: VaultEntry[] + initialSelectedNote?: VaultEntry | null onOpen: (entry: VaultEntry) => void onEnterNeighborhood?: (entry: VaultEntry) => void + selectedNoteOverride?: VaultEntry selection?: SidebarSelection }) { - const [selectedNote, setSelectedNote] = useState(null) + const [selectedNote, setSelectedNote] = useState(initialSelectedNote) + const visibleSelectedNote = selectedNoteOverride ?? selectedNote const handleOpen = (entry: VaultEntry) => { setSelectedNote(entry) @@ -27,9 +35,9 @@ function NoteListKeyboardHarness({ return ( {}} onSelectNote={handleOpen} @@ -117,4 +125,53 @@ describe('NoteList keyboard activation', () => { expect(prefetchSpy).toHaveBeenCalledWith(mockEntries[1]) }) + + it('keeps repeated large-note navigation broad for raw prefetch but narrow for parsed warmup', async () => { + vi.useFakeTimers() + try { + const largeEntries = Array.from({ length: 20 }, (_, index) => makeIndexedEntry(index, { + fileSize: 40 * 1024, + })) + const onOpen = vi.fn() + const prefetchSpy = vi.spyOn(tabManagement, 'prefetchNoteContent').mockImplementation(() => {}) + const { rerender } = render( + , + ) + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_500) + }) + + expect(prefetchSpy).toHaveBeenCalledTimes(6) + expect(prefetchSpy.mock.calls[0][1]).toEqual({ parsedBlockPreload: true }) + for (const call of prefetchSpy.mock.calls.slice(1)) { + expect(call[1]).toEqual({ parsedBlockPreload: false }) + } + + prefetchSpy.mockClear() + rerender( + , + ) + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_500) + }) + + expect(prefetchSpy).toHaveBeenCalledTimes(6) + expect(prefetchSpy.mock.calls[0][1]).toEqual({ parsedBlockPreload: true }) + for (const call of prefetchSpy.mock.calls.slice(1)) { + expect(call[1]).toEqual({ parsedBlockPreload: false }) + } + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/components/note-list/useNoteListModel.tsx b/src/components/note-list/useNoteListModel.tsx index 309ea1c4..fc4b0b4c 100644 --- a/src/components/note-list/useNoteListModel.tsx +++ b/src/components/note-list/useNoteListModel.tsx @@ -83,8 +83,9 @@ function useLikelyNextPreload(entries: VaultEntry[], selectedNotePath: string | const preloadNext = () => { const entry = candidates.at(candidateIndex) if (!entry) return + const parsedBlockPreload = candidateIndex === 0 candidateIndex += 1 - prefetchNoteContent(entry) + prefetchNoteContent(entry, { parsedBlockPreload }) stepTimer = window.setTimeout(preloadNext, LIKELY_NEXT_PRELOAD_STEP_DELAY_MS) } preloadNext() diff --git a/src/hooks/editorParsedBlockPreload.test.tsx b/src/hooks/editorParsedBlockPreload.test.tsx index 9d52dcf7..77d8d6f3 100644 --- a/src/hooks/editorParsedBlockPreload.test.tsx +++ b/src/hooks/editorParsedBlockPreload.test.tsx @@ -8,6 +8,7 @@ import { PARSED_BLOCK_PRELOAD_MIN_BYTES, useParsedBlockPreload, } from './editorParsedBlockPreload' +import type { NoteContentRequestOptions } from './noteContentCache' type RefSet = { activeTabPathRef: MutableRefObject @@ -78,9 +79,13 @@ function renderParsedPreload(refs: RefSet, prepareParsedBlocks: (event: { })) } -async function emitResolvedContent(entry: VaultEntry, content = '# Large\n\nBody'): Promise { +async function emitResolvedContent( + entry: VaultEntry, + content = '# Large\n\nBody', + options?: NoteContentRequestOptions, +): Promise { await act(async () => { - cacheNoteContent(entry.path, content, entry) + cacheNoteContent(entry.path, content, entry, options) await vi.advanceTimersByTimeAsync(PARSED_BLOCK_PRELOAD_DELAY_MS) }) } @@ -110,9 +115,21 @@ describe('useParsedBlockPreload', () => { entry, path: entry.path, content: '# Large\n\nPrepared body.', + parsedBlockPreload: true, }) }) + it('skips raw prefetches that are not parsed-block warmup candidates', async () => { + const refs = makeRefs() + const prepareParsedBlocks = vi.fn(async () => {}) + const entry = makeEntry({ path: '/vault/raw-only.md' }) + + renderParsedPreload(refs, prepareParsedBlocks) + await emitResolvedContent(entry, '# Raw only\n\nPrepared body.', { parsedBlockPreload: false }) + + expect(prepareParsedBlocks).not.toHaveBeenCalled() + }) + it('skips entries that should not compete with the foreground editor', async () => { const activeEntry = makeEntry({ path: '/vault/active.md' }) const refs = makeRefs({ activeTabPath: activeEntry.path }) diff --git a/src/hooks/editorParsedBlockPreload.ts b/src/hooks/editorParsedBlockPreload.ts index c7065973..4099003f 100644 --- a/src/hooks/editorParsedBlockPreload.ts +++ b/src/hooks/editorParsedBlockPreload.ts @@ -19,6 +19,7 @@ interface ParsedBlockPreloadOptions { function canPreloadParsedBlocks(event: NoteContentResolvedEvent, activeTabPath: string | null): boolean { if (!PARSED_BLOCK_PRELOAD_ENABLED) return false + if (!event.parsedBlockPreload) return false const { entry } = event if (!entry || entry.path === activeTabPath) return false if ((entry.fileKind ?? 'markdown') !== 'markdown') return false diff --git a/src/hooks/noteContentCache.ts b/src/hooks/noteContentCache.ts index 40c3b3d5..b93196a3 100644 --- a/src/hooks/noteContentCache.ts +++ b/src/hooks/noteContentCache.ts @@ -20,6 +20,8 @@ export interface NoteContentCacheEntry { byteSize: number identity: NoteContentIdentity | null vaultPath?: string + parsedBlockPreload: boolean + parsedBlockPreloadNotified: boolean requestState?: NoteContentRequestState startRequest?: () => void cancelRequest?: () => void @@ -29,6 +31,11 @@ export interface NoteContentResolvedEvent { entry: VaultEntry | null path: NotePath content: string + parsedBlockPreload: boolean +} + +export interface NoteContentRequestOptions { + parsedBlockPreload?: boolean } type NoteContentResolvedListener = (event: NoteContentResolvedEvent) => void @@ -64,6 +71,10 @@ function emitNoteContentResolved(event: NoteContentResolvedEvent): void { } } +function shouldRequestParsedBlockPreload(options?: NoteContentRequestOptions): boolean { + return options?.parsedBlockPreload ?? true +} + function measureNoteContentBytes(content: string): number { return contentSizeEncoder ? contentSizeEncoder.encode(content).byteLength : content.length } @@ -154,7 +165,13 @@ function retainResolvedNoteContent(entry: NoteContentCacheEntry, content: string entry.value = content entry.byteSize = byteSize rememberNoteContent(entry) - emitNoteContentResolved({ entry: sourceEntry, path: entry.path, content }) + if (entry.parsedBlockPreload) entry.parsedBlockPreloadNotified = true + emitNoteContentResolved({ + entry: sourceEntry, + path: entry.path, + content, + parsedBlockPreload: entry.parsedBlockPreload, + }) } function getNoteContentCommandPayload(path: string, vaultPath?: string): { path: string; vaultPath?: string } { @@ -222,11 +239,25 @@ function markRequestSettled(entry: NoteContentCacheEntry, state: Extract { + void requestNoteContent(target, 'prefetch', options).promise.catch((error) => { if (isCanceledNoteContentRequest(error) || isNoActiveVaultSelectedError(error) || isUnreadableNoteContentError(error)) return console.warn('Failed to prefetch note content:', error) }) } -export function cacheNoteContent(path: string, content: string, entry?: VaultEntry): void { +export function cacheNoteContent( + path: string, + content: string, + entry?: VaultEntry, + options?: NoteContentRequestOptions, +): void { const byteSize = measureNoteContentBytes(content) if (byteSize > NOTE_CONTENT_ENTRY_MAX_BYTES) { prefetchCache.delete(path) return } - rememberNoteContent({ + const cacheEntry = rememberNoteContent({ path, promise: Promise.resolve(content), value: content, byteSize, identity: entry ? noteContentIdentity(entry) : null, vaultPath: entry ? workspacePathForEntry(entry) : undefined, + parsedBlockPreload: shouldRequestParsedBlockPreload(options), + parsedBlockPreloadNotified: false, + }) + if (cacheEntry.parsedBlockPreload) cacheEntry.parsedBlockPreloadNotified = true + emitNoteContentResolved({ + entry: entry ?? null, + path, + content, + parsedBlockPreload: cacheEntry.parsedBlockPreload, }) - emitNoteContentResolved({ entry: entry ?? null, path, content }) } export function clearNoteContentCache(): void { diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index 923ac1ca..62b768f8 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -19,6 +19,7 @@ import { NOTE_CONTENT_ENTRY_MAX_BYTES, NOTE_CONTENT_PREFETCH_CONCURRENCY, prefetchNoteContent as prefetchNoteContentInMemory, + type NoteContentRequestOptions, } from './noteContentCache' import { clearParsedNoteBlockCache } from './editorParsedBlockCache' import { notePathsMatch } from '../utils/notePathIdentity' @@ -36,12 +37,17 @@ export { NOTE_CONTENT_PREFETCH_CONCURRENCY, } -export function prefetchNoteContent(target: string | VaultEntry): void { - prefetchNoteContentInMemory(target) +export function prefetchNoteContent(target: string | VaultEntry, options?: NoteContentRequestOptions): void { + prefetchNoteContentInMemory(target, options) } -export function cacheNoteContent(path: string, content: string, entry?: VaultEntry): void { - cacheNoteContentInMemory(path, content, entry) +export function cacheNoteContent( + path: string, + content: string, + entry?: VaultEntry, + options?: NoteContentRequestOptions, +): void { + cacheNoteContentInMemory(path, content, entry, options) } /** Clear note-open caches. Call on vault reload to prevent stale content. */