fix: bound parsed note-list preload warmups
This commit is contained in:
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user