fix: bound parsed note-list preload warmups

This commit is contained in:
lucaronin
2026-05-15 03:16:38 +02:00
parent c7b7b97022
commit 3d2efcedeb
6 changed files with 156 additions and 21 deletions

View File

@@ -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<VaultEntry | null>(null)
const [selectedNote, setSelectedNote] = useState<VaultEntry | null>(initialSelectedNote)
const visibleSelectedNote = selectedNoteOverride ?? selectedNote
const handleOpen = (entry: VaultEntry) => {
setSelectedNote(entry)
@@ -27,9 +35,9 @@ function NoteListKeyboardHarness({
return (
<NoteList
entries={mockEntries}
entries={entries}
selection={selection}
selectedNote={selectedNote}
selectedNote={visibleSelectedNote}
noteListFilter="open"
onNoteListFilterChange={() => {}}
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(
<NoteListKeyboardHarness
entries={largeEntries}
onOpen={onOpen}
selectedNoteOverride={largeEntries[8]}
/>,
)
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(
<NoteListKeyboardHarness
entries={largeEntries}
onOpen={onOpen}
selectedNoteOverride={largeEntries[9]}
/>,
)
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()
}
})
})

View File

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

View File

@@ -8,6 +8,7 @@ import {
PARSED_BLOCK_PRELOAD_MIN_BYTES,
useParsedBlockPreload,
} from './editorParsedBlockPreload'
import type { NoteContentRequestOptions } from './noteContentCache'
type RefSet = {
activeTabPathRef: MutableRefObject<string | null>
@@ -78,9 +79,13 @@ function renderParsedPreload(refs: RefSet, prepareParsedBlocks: (event: {
}))
}
async function emitResolvedContent(entry: VaultEntry, content = '# Large\n\nBody'): Promise<void> {
async function emitResolvedContent(
entry: VaultEntry,
content = '# Large\n\nBody',
options?: NoteContentRequestOptions,
): Promise<void> {
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 })

View File

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

View File

@@ -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<NoteCon
entry.cancelRequest = undefined
}
function createNoteContentRequest(target: string | VaultEntry): NoteContentCacheEntry {
function requestParsedBlockPreload(entry: NoteContentCacheEntry, sourceEntry: VaultEntry | null): void {
entry.parsedBlockPreload = true
if (entry.value === null || entry.parsedBlockPreloadNotified) return
entry.parsedBlockPreloadNotified = true
emitNoteContentResolved({
entry: sourceEntry,
path: entry.path,
content: entry.value,
parsedBlockPreload: true,
})
}
function createNoteContentRequest(target: string | VaultEntry, options?: NoteContentRequestOptions): NoteContentCacheEntry {
const path = targetPath(target)
const sourceEntry = targetEntry(target)
const vaultPath = targetVaultPath(target)
const identity = targetIdentity(target)
const parsedBlockPreload = shouldRequestParsedBlockPreload(options)
const cacheEntry: NoteContentCacheEntry = {
path,
promise: Promise.resolve(''),
@@ -234,6 +265,8 @@ function createNoteContentRequest(target: string | VaultEntry): NoteContentCache
byteSize: 0,
identity,
vaultPath,
parsedBlockPreload,
parsedBlockPreloadNotified: false,
requestState: 'queued',
}
let started = false
@@ -305,8 +338,12 @@ function enqueuePrefetchRequest(entry: NoteContentCacheEntry): void {
runQueuedPrefetches()
}
function requestNoteContent(target: string | VaultEntry, mode: NoteContentRequestMode = 'foreground'): NoteContentCacheEntry {
const cacheEntry = rememberNoteContent(createNoteContentRequest(target))
function requestNoteContent(
target: string | VaultEntry,
mode: NoteContentRequestMode = 'foreground',
options?: NoteContentRequestOptions,
): NoteContentCacheEntry {
const cacheEntry = rememberNoteContent(createNoteContentRequest(target, options))
if (mode === 'prefetch') {
enqueuePrefetchRequest(cacheEntry)
} else {
@@ -315,35 +352,51 @@ function requestNoteContent(target: string | VaultEntry, mode: NoteContentReques
return cacheEntry
}
export function prefetchNoteContent(target: string | VaultEntry): void {
export function prefetchNoteContent(target: string | VaultEntry, options?: NoteContentRequestOptions): void {
const path = targetPath(target)
const identity = targetIdentity(target)
const vaultPath = targetVaultPath(target)
const existing = prefetchCache.get(path)
if (existing && shouldReuseExistingRequest(existing, identity, vaultPath)) return
if (existing && shouldReuseExistingRequest(existing, identity, vaultPath)) {
if (shouldRequestParsedBlockPreload(options)) requestParsedBlockPreload(existing, targetEntry(target))
return
}
void requestNoteContent(target, 'prefetch').promise.catch((error) => {
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 {

View File

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