fix: warm parsed blocks for large notes

This commit is contained in:
lucaronin
2026-05-14 17:08:03 +02:00
parent cf03c01b75
commit 7174061b2a
4 changed files with 356 additions and 235 deletions

View File

@@ -0,0 +1,143 @@
import { renderHook, act } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MutableRefObject } from 'react'
import type { VaultEntry } from '../types'
import { cacheNoteContent, clearPrefetchCache } from './useTabManagement'
import {
PARSED_BLOCK_PRELOAD_DELAY_MS,
PARSED_BLOCK_PRELOAD_MIN_BYTES,
useParsedBlockPreload,
} from './editorParsedBlockPreload'
type RefSet = {
activeTabPathRef: MutableRefObject<string | null>
editorMountedRef: MutableRefObject<boolean>
foregroundWorkAtRef: MutableRefObject<number>
rawModeRef: MutableRefObject<boolean>
}
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
return {
path: '/vault/large.md',
filename: 'large.md',
title: 'Large',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: 'Active',
archived: false,
modifiedAt: 1_700_000_000,
createdAt: 1_700_000_000,
fileSize: PARSED_BLOCK_PRELOAD_MIN_BYTES + 1,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: null,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: true,
fileKind: 'markdown',
...overrides,
}
}
function makeRefs(overrides: Partial<{
activeTabPath: string | null
editorMounted: boolean
foregroundWorkAt: number
rawMode: boolean
}> = {}): RefSet {
return {
activeTabPathRef: { current: overrides.activeTabPath ?? '/vault/open.md' },
editorMountedRef: { current: overrides.editorMounted ?? true },
foregroundWorkAtRef: { current: overrides.foregroundWorkAt ?? 0 },
rawModeRef: { current: overrides.rawMode ?? false },
}
}
function renderParsedPreload(refs: RefSet, prepareParsedBlocks: (event: {
entry: VaultEntry | null
path: string
content: string
}) => Promise<void>) {
return renderHook(() => useParsedBlockPreload({
...refs,
prepareParsedBlocks,
}))
}
async function emitResolvedContent(entry: VaultEntry, content = '# Large\n\nBody'): Promise<void> {
await act(async () => {
cacheNoteContent(entry.path, content, entry)
await vi.advanceTimersByTimeAsync(PARSED_BLOCK_PRELOAD_DELAY_MS)
})
}
describe('useParsedBlockPreload', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-14T12:00:00Z'))
clearPrefetchCache()
})
afterEach(() => {
clearPrefetchCache()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('prepares parsed blocks for a warmed large markdown note after foreground work is idle', async () => {
const refs = makeRefs()
const prepareParsedBlocks = vi.fn(async () => {})
const entry = makeEntry()
renderParsedPreload(refs, prepareParsedBlocks)
await emitResolvedContent(entry, '# Large\n\nPrepared body.')
expect(prepareParsedBlocks).toHaveBeenCalledWith({
entry,
path: entry.path,
content: '# Large\n\nPrepared body.',
})
})
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 })
const prepareParsedBlocks = vi.fn(async () => {})
renderParsedPreload(refs, prepareParsedBlocks)
await emitResolvedContent(activeEntry)
await emitResolvedContent(makeEntry({ path: '/vault/small.md', fileSize: PARSED_BLOCK_PRELOAD_MIN_BYTES - 1 }))
await emitResolvedContent(makeEntry({ path: '/vault/file.bin', fileKind: 'binary' }))
expect(prepareParsedBlocks).not.toHaveBeenCalled()
})
it('drops a candidate that becomes active before the idle preparse starts', async () => {
const entry = makeEntry({ path: '/vault/soon-active.md' })
const refs = makeRefs()
const prepareParsedBlocks = vi.fn(async () => {})
renderParsedPreload(refs, prepareParsedBlocks)
cacheNoteContent(entry.path, '# Soon Active\n\nBody', entry)
refs.activeTabPathRef.current = entry.path
await act(async () => {
await vi.advanceTimersByTimeAsync(PARSED_BLOCK_PRELOAD_DELAY_MS)
})
expect(prepareParsedBlocks).not.toHaveBeenCalled()
})
})

View File

@@ -5,7 +5,7 @@ import { subscribeNoteContentResolved } from './noteContentCache'
export const PARSED_BLOCK_PRELOAD_MIN_BYTES = 32 * 1024
export const PARSED_BLOCK_PRELOAD_DELAY_MS = 1800
export const PARSED_BLOCK_PRELOAD_FOREGROUND_IDLE_MS = 1500
export const PARSED_BLOCK_PRELOAD_ENABLED = false
export const PARSED_BLOCK_PRELOAD_ENABLED = true
type PrepareParsedBlocks = (event: NoteContentResolvedEvent) => Promise<void>

View File

@@ -1,5 +1,215 @@
import { describe, expect, it } from 'vitest'
import { slugifyPathStem } from './editorTabContent'
import {
extractEditorBody,
getH1TextFromBlocks,
normalizeParsedImageBlocks,
replaceTitleInFrontmatter,
slugifyPathStem,
} from './editorTabContent'
function makeHeadingBlocks(
content: Array<Record<string, unknown>>,
level = 1,
) {
return [{
type: 'heading',
props: { level },
content,
}]
}
describe('extractEditorBody', () => {
it('strips frontmatter and preserves H1 heading for new note content', () => {
const content = '---\ntitle: Untitled note\ntype: Note\nstatus: Active\n---\n\n# Untitled note\n\n'
expect(extractEditorBody(content)).toBe('# Untitled note\n\n')
})
it('strips frontmatter and preserves H1 with body content', () => {
const content = '---\ntitle: Test\n---\n# Test\n\nBody text here.'
expect(extractEditorBody(content)).toBe('# Test\n\nBody text here.')
})
it('preserves H1 and body content after frontmatter', () => {
const content = '---\ntitle: My Note\ntype: Note\n---\n\n# My Note\n\nFirst paragraph.\n\nSecond paragraph.'
expect(extractEditorBody(content)).toBe('# My Note\n\nFirst paragraph.\n\nSecond paragraph.')
})
it('handles content without frontmatter', () => {
const content = '# Just a Heading\n\nSome body text.'
expect(extractEditorBody(content)).toBe('# Just a Heading\n\nSome body text.')
})
it('handles content without frontmatter or heading', () => {
const content = 'Just plain text.'
expect(extractEditorBody(content)).toBe('Just plain text.')
})
it('handles completely empty content', () => {
expect(extractEditorBody('')).toBe('')
})
it('handles frontmatter-only content', () => {
const content = '---\ntitle: Empty\n---\n'
expect(extractEditorBody(content)).toBe('')
})
it('preserves wikilinks in body', () => {
const content = '---\ntitle: Test\n---\n\n# Test\n\nSee [[Other Note]] for details.'
expect(extractEditorBody(content)).toBe('# Test\n\nSee [[Other Note]] for details.')
})
it('preserves non-leading headings', () => {
const content = '---\ntitle: Test\n---\n\nSome intro text.\n\n# A Heading\n\nMore text.'
expect(extractEditorBody(content)).toBe('Some intro text.\n\n# A Heading\n\nMore text.')
})
it('preserves H1 for buildNoteContent output', () => {
const content = '---\ntitle: My Project\ntype: Project\nstatus: Active\n---\n\n# My Project\n\n'
expect(extractEditorBody(content)).toBe('# My Project\n\n')
})
it('preserves an empty H1 for untitled-note content', () => {
const content = '---\ntype: Note\nstatus: Active\n---\n\n# \n\n'
expect(extractEditorBody(content)).toBe('# \n\n')
})
})
describe('getH1TextFromBlocks', () => {
it('returns text from H1 heading block', () => {
const blocks = makeHeadingBlocks([{ type: 'text', text: 'My Title', styles: {} }])
expect(getH1TextFromBlocks(blocks)).toBe('My Title')
})
it('returns null for empty blocks', () => {
expect(getH1TextFromBlocks([])).toBeNull()
})
it('returns null for non-heading first block', () => {
const blocks = [{
type: 'paragraph',
content: [{ type: 'text', text: 'Just text' }],
}]
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('returns null for H2 heading', () => {
const blocks = makeHeadingBlocks([{ type: 'text', text: 'Subtitle' }], 2)
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('concatenates multiple text spans', () => {
const blocks = makeHeadingBlocks([
{ type: 'text', text: 'Hello ' },
{ type: 'text', text: 'World' },
])
expect(getH1TextFromBlocks(blocks)).toBe('Hello World')
})
it('returns null for empty H1 content', () => {
const blocks = makeHeadingBlocks([])
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('returns null for whitespace-only H1', () => {
const blocks = makeHeadingBlocks([{ type: 'text', text: ' ' }])
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('returns null when blocks is null/undefined', () => {
expect(getH1TextFromBlocks(null as unknown as unknown[])).toBeNull()
expect(getH1TextFromBlocks(undefined as unknown as unknown[])).toBeNull()
})
it('filters non-text inline content', () => {
const blocks = makeHeadingBlocks([
{ type: 'text', text: 'Title' },
{ type: 'wikilink', props: { target: 'linked' } },
])
expect(getH1TextFromBlocks(blocks)).toBe('Title')
})
})
describe('replaceTitleInFrontmatter', () => {
it('replaces title value in frontmatter', () => {
const fm = '---\ntitle: Old Title\ntype: Note\n---\n\n'
expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntitle: New Title\ntype: Note\n---\n\n')
})
it('handles title with extra spaces after colon', () => {
const fm = '---\ntitle: Old Title\n---\n'
expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntitle: New Title\n---\n')
})
it('returns unchanged frontmatter when no title line exists', () => {
const fm = '---\ntype: Note\n---\n'
expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntype: Note\n---\n')
})
it('replaces only the title line, not other fields', () => {
const fm = '---\ntitle: Old\ntype: Note\nstatus: Active\n---\n\n'
expect(replaceTitleInFrontmatter(fm, 'Updated')).toBe('---\ntitle: Updated\ntype: Note\nstatus: Active\n---\n\n')
})
it('handles empty string as frontmatter', () => {
expect(replaceTitleInFrontmatter('', 'Title')).toBe('')
})
})
describe('normalizeParsedImageBlocks', () => {
it('clears broken-image fallback widths from local asset image blocks', () => {
const blocks = normalizeParsedImageBlocks([{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fshot.png',
previewWidth: 16,
},
}])
expect(blocks).toEqual([{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fshot.png',
previewWidth: undefined,
},
}])
})
it('preserves explicit image widths and non-local image URLs', () => {
const blocks = normalizeParsedImageBlocks([
{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fresized.png',
previewWidth: 240,
},
},
{
type: 'image',
props: {
url: 'https://example.test/preview.png',
previewWidth: 16,
},
},
])
expect(blocks).toEqual([
{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fresized.png',
previewWidth: 240,
},
},
{
type: 'image',
props: {
url: 'https://example.test/preview.png',
previewWidth: 16,
},
},
])
})
})
describe('slugifyPathStem', () => {
it('preserves Unicode title stems for untitled rename detection', () => {

View File

@@ -1,203 +1,8 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter, RICH_EDITOR_CHANGE_DEBOUNCE_MS, useEditorTabSwap } from './useEditorTabSwap'
import { normalizeParsedImageBlocks } from './editorTabContent'
import { cacheNoteContent, clearPrefetchCache } from './useTabManagement'
import { RICH_EDITOR_CHANGE_DEBOUNCE_MS, useEditorTabSwap } from './useEditorTabSwap'
import { cacheParsedNoteBlocks, clearParsedNoteBlockCache } from './editorParsedBlockCache'
describe('extractEditorBody', () => {
it('strips frontmatter and preserves H1 heading for new note content', () => {
const content = '---\ntitle: Untitled note\ntype: Note\nstatus: Active\n---\n\n# Untitled note\n\n'
expect(extractEditorBody(content)).toBe('# Untitled note\n\n')
})
it('strips frontmatter and preserves H1 with body content', () => {
const content = '---\ntitle: Test\n---\n# Test\n\nBody text here.'
expect(extractEditorBody(content)).toBe('# Test\n\nBody text here.')
})
it('preserves H1 and body content after frontmatter', () => {
const content = '---\ntitle: My Note\ntype: Note\n---\n\n# My Note\n\nFirst paragraph.\n\nSecond paragraph.'
expect(extractEditorBody(content)).toBe('# My Note\n\nFirst paragraph.\n\nSecond paragraph.')
})
it('handles content without frontmatter', () => {
const content = '# Just a Heading\n\nSome body text.'
expect(extractEditorBody(content)).toBe('# Just a Heading\n\nSome body text.')
})
it('handles content without frontmatter or heading', () => {
const content = 'Just plain text.'
expect(extractEditorBody(content)).toBe('Just plain text.')
})
it('handles completely empty content', () => {
expect(extractEditorBody('')).toBe('')
})
it('handles frontmatter-only content', () => {
const content = '---\ntitle: Empty\n---\n'
expect(extractEditorBody(content)).toBe('')
})
it('preserves wikilinks in body', () => {
const content = '---\ntitle: Test\n---\n\n# Test\n\nSee [[Other Note]] for details.'
expect(extractEditorBody(content)).toBe('# Test\n\nSee [[Other Note]] for details.')
})
it('preserves non-leading headings', () => {
const content = '---\ntitle: Test\n---\n\nSome intro text.\n\n# A Heading\n\nMore text.'
expect(extractEditorBody(content)).toBe('Some intro text.\n\n# A Heading\n\nMore text.')
})
it('preserves H1 for buildNoteContent output', () => {
const content = '---\ntitle: My Project\ntype: Project\nstatus: Active\n---\n\n# My Project\n\n'
expect(extractEditorBody(content)).toBe('# My Project\n\n')
})
it('preserves an empty H1 for untitled-note content', () => {
const content = '---\ntype: Note\nstatus: Active\n---\n\n# \n\n'
expect(extractEditorBody(content)).toBe('# \n\n')
})
})
describe('getH1TextFromBlocks', () => {
it('returns text from H1 heading block', () => {
const blocks = makeHeadingBlocks([{ type: 'text', text: 'My Title', styles: {} }])
expect(getH1TextFromBlocks(blocks)).toBe('My Title')
})
it('returns null for empty blocks', () => {
expect(getH1TextFromBlocks([])).toBeNull()
})
it('returns null for non-heading first block', () => {
const blocks = [{
type: 'paragraph',
content: [{ type: 'text', text: 'Just text' }],
}]
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('returns null for H2 heading', () => {
const blocks = makeHeadingBlocks([{ type: 'text', text: 'Subtitle' }], 2)
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('concatenates multiple text spans', () => {
const blocks = makeHeadingBlocks([
{ type: 'text', text: 'Hello ' },
{ type: 'text', text: 'World' },
])
expect(getH1TextFromBlocks(blocks)).toBe('Hello World')
})
it('returns null for empty H1 content', () => {
const blocks = makeHeadingBlocks([])
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('returns null for whitespace-only H1', () => {
const blocks = makeHeadingBlocks([{ type: 'text', text: ' ' }])
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('returns null when blocks is null/undefined', () => {
expect(getH1TextFromBlocks(null as unknown as unknown[])).toBeNull()
expect(getH1TextFromBlocks(undefined as unknown as unknown[])).toBeNull()
})
it('filters non-text inline content', () => {
const blocks = makeHeadingBlocks([
{ type: 'text', text: 'Title' },
{ type: 'wikilink', props: { target: 'linked' } },
])
expect(getH1TextFromBlocks(blocks)).toBe('Title')
})
})
describe('replaceTitleInFrontmatter', () => {
it('replaces title value in frontmatter', () => {
const fm = '---\ntitle: Old Title\ntype: Note\n---\n\n'
expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntitle: New Title\ntype: Note\n---\n\n')
})
it('handles title with extra spaces after colon', () => {
const fm = '---\ntitle: Old Title\n---\n'
expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntitle: New Title\n---\n')
})
it('returns unchanged frontmatter when no title line exists', () => {
const fm = '---\ntype: Note\n---\n'
expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntype: Note\n---\n')
})
it('replaces only the title line, not other fields', () => {
const fm = '---\ntitle: Old\ntype: Note\nstatus: Active\n---\n\n'
expect(replaceTitleInFrontmatter(fm, 'Updated')).toBe('---\ntitle: Updated\ntype: Note\nstatus: Active\n---\n\n')
})
it('handles empty string as frontmatter', () => {
expect(replaceTitleInFrontmatter('', 'Title')).toBe('')
})
})
describe('normalizeParsedImageBlocks', () => {
it('clears broken-image fallback widths from local asset image blocks', () => {
const blocks = normalizeParsedImageBlocks([{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fshot.png',
previewWidth: 16,
},
}])
expect(blocks).toEqual([{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fshot.png',
previewWidth: undefined,
},
}])
})
it('preserves explicit image widths and non-local image URLs', () => {
const blocks = normalizeParsedImageBlocks([
{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fresized.png',
previewWidth: 240,
},
},
{
type: 'image',
props: {
url: 'https://example.test/preview.png',
previewWidth: 16,
},
},
])
expect(blocks).toEqual([
{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fresized.png',
previewWidth: 240,
},
},
{
type: 'image',
props: {
url: 'https://example.test/preview.png',
previewWidth: 16,
},
},
])
})
})
const blocksA = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }]
function makeTextParagraphBlock(text: string) {
@@ -248,17 +53,6 @@ function makeMockEditor(docRef: { current: unknown[] }) {
return editor
}
function makeHeadingBlocks(
content: Array<Record<string, unknown>>,
level = 1,
) {
return [{
type: 'heading',
props: { level },
content,
}]
}
function makeLongNoteBlocks(wordCount: number) {
const words = Array.from({ length: wordCount }, (_, index) => `word${index}`)
const paragraphs: unknown[] = []
@@ -512,32 +306,6 @@ describe('useEditorTabSwap raw mode sync', () => {
])
})
it('does not preparse prefetched note blocks in the background', async () => {
clearPrefetchCache()
const tabA = makeTab('a.md', 'Note A')
const tabB = {
...makeTab('b.md', 'Likely Next'),
content: '---\ntitle: Likely Next\n---\n\n# Likely Next\n\nPrepared body.',
}
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
})
mockEditor.tryParseMarkdownToBlocks.mockClear()
cacheNoteContent('b.md', tabB.content)
await flushEditorTick()
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
expect.stringContaining('Prepared body.'),
)
clearPrefetchCache()
})
it('clears stale editor DOM selection before switching notes', async () => {
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'Note B')