From 7174061b2ac4f3f3680afabda3267f02461105aa Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 14 May 2026 17:08:03 +0200 Subject: [PATCH] fix: warm parsed blocks for large notes --- src/hooks/editorParsedBlockPreload.test.tsx | 143 ++++++++++++ src/hooks/editorParsedBlockPreload.ts | 2 +- src/hooks/editorTabContent.test.ts | 212 +++++++++++++++++- src/hooks/useEditorTabSwap.test.ts | 234 +------------------- 4 files changed, 356 insertions(+), 235 deletions(-) create mode 100644 src/hooks/editorParsedBlockPreload.test.tsx diff --git a/src/hooks/editorParsedBlockPreload.test.tsx b/src/hooks/editorParsedBlockPreload.test.tsx new file mode 100644 index 00000000..9d52dcf7 --- /dev/null +++ b/src/hooks/editorParsedBlockPreload.test.tsx @@ -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 + editorMountedRef: MutableRefObject + foregroundWorkAtRef: MutableRefObject + rawModeRef: MutableRefObject +} + +function makeEntry(overrides: Partial = {}): 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) { + return renderHook(() => useParsedBlockPreload({ + ...refs, + prepareParsedBlocks, + })) +} + +async function emitResolvedContent(entry: VaultEntry, content = '# Large\n\nBody'): Promise { + 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() + }) +}) diff --git a/src/hooks/editorParsedBlockPreload.ts b/src/hooks/editorParsedBlockPreload.ts index 8eff7542..c7065973 100644 --- a/src/hooks/editorParsedBlockPreload.ts +++ b/src/hooks/editorParsedBlockPreload.ts @@ -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 diff --git a/src/hooks/editorTabContent.test.ts b/src/hooks/editorTabContent.test.ts index 51f9a321..dd64230a 100644 --- a/src/hooks/editorTabContent.test.ts +++ b/src/hooks/editorTabContent.test.ts @@ -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>, + 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', () => { diff --git a/src/hooks/useEditorTabSwap.test.ts b/src/hooks/useEditorTabSwap.test.ts index 61c5706f..15040a67 100644 --- a/src/hooks/useEditorTabSwap.test.ts +++ b/src/hooks/useEditorTabSwap.test.ts @@ -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>, - 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')