import { describe, it, expect, vi, afterEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import { RICH_EDITOR_CHANGE_DEBOUNCE_MS, useEditorTabSwap } from './useEditorTabSwap' import { cacheParsedNoteBlocks, clearParsedNoteBlockCache } from './editorParsedBlockCache' const blocksA = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }] function makeTextParagraphBlock(text: string) { return { type: 'paragraph', content: [{ type: 'text', text, styles: {} }], children: [] } } function makeTab(path: string, title: string) { return { entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never, content: `---\ntitle: ${title}\n---\n\n# ${title}\n\nBody of ${title}.`, } } function makeUntitledTab(path: string, title = 'Untitled Note 1', remainder = '') { return { entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never, content: `---\ntype: Note\nstatus: Active\n---\n\n# \n\n${remainder}`, } } function makeBlankBodyTab(path: string, title = 'Untitled Note 1') { return { entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never, content: '---\ntype: Note\nstatus: Active\n---\n', } } function makeMockEditor(docRef: { current: unknown[] }) { const editor = { document: docRef.current, get prosemirrorView() { return {} }, onMount: (cb: () => void) => { cb(); return () => {} }, replaceBlocks: vi.fn((_old, newBlocks) => { docRef.current = newBlocks }), insertBlocks: vi.fn(), blocksToMarkdownLossy: vi.fn(() => ''), blocksToHTMLLossy: vi.fn(() => ''), tryParseMarkdownToBlocks: vi.fn(() => blocksA), _tiptapEditor: { state: { doc: { content: { size: 8 } } }, commands: { setContent: vi.fn(), setTextSelection: vi.fn(), }, }, _docRef: docRef, } Object.defineProperty(editor, 'document', { get: () => docRef.current }) return editor } function makeLongNoteBlocks(wordCount: number) { const words = Array.from({ length: wordCount }, (_, index) => `word${index}`) const paragraphs: unknown[] = [] for (let index = 0; index < words.length; index += 20) { paragraphs.push({ type: 'paragraph', content: [{ type: 'text', text: words.slice(index, index + 20).join(' '), styles: {} }], children: [], }) } return paragraphs } async function flushEditorTick() { await act(() => new Promise((resolve) => setTimeout(resolve, 0))) } function createDeferred() { let resolve!: (value: T) => void const promise = new Promise((res) => { resolve = res }) return { promise, resolve } } function installEditorDomSpies(scrollTop = 0) { const scrollEl = { scrollTop } const frameSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0) return 0 }) vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element) return { scrollEl, frameSpy } } function flushQueuedFrames(frameCallbacks: FrameRequestCallback[]) { act(() => { for (const callback of frameCallbacks.splice(0)) { callback(0) } }) } type SwapHarnessProps = { tabs: ReturnType[] activeTabPath: string | null rawMode?: boolean vaultPath?: string } async function createSwapHarness(options: { initialProps: SwapHarnessProps onContentChange?: (path: string, content: string) => void setupEditor?: (editor: ReturnType) => void }) { installEditorDomSpies() const docRef = { current: blocksA as unknown[] } const mockEditor = makeMockEditor(docRef) options.setupEditor?.(mockEditor) let currentProps = options.initialProps const rendered = renderHook( (props: SwapHarnessProps) => useEditorTabSwap({ ...props, editor: mockEditor as never, onContentChange: options.onContentChange, }), { initialProps: currentProps }, ) await flushEditorTick() return { ...rendered, docRef, mockEditor, async rerenderWith(nextProps: Partial) { currentProps = { ...currentProps, ...nextProps } rendered.rerender(currentProps) await flushEditorTick() }, } } describe('useEditorTabSwap raw mode sync', () => { afterEach(() => { clearParsedNoteBlockCache() vi.restoreAllMocks() }) it('swaps in the new note when the path updates before tabs catch up', async () => { const tabA = makeTab('a.md', 'Note A') const tabB = makeTab('b.md', 'March 2024') const { mockEditor, rerenderWith } = await createSwapHarness({ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, }) mockEditor.tryParseMarkdownToBlocks.mockClear() mockEditor.replaceBlocks.mockClear() await rerenderWith({ tabs: [tabA], activeTabPath: 'b.md' }) expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled() await rerenderWith({ tabs: [tabB] }) expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith( expect.stringContaining('March 2024'), ) expect(mockEditor.replaceBlocks).toHaveBeenCalled() }) it('signals when the target tab content has been applied', async () => { const swapListener = vi.fn() window.addEventListener('laputa:editor-tab-swapped', swapListener) const tabA = makeTab('a.md', 'Note A') const tabB = makeTab('b.md', 'March 2024') const { rerenderWith } = await createSwapHarness({ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, }) swapListener.mockClear() await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' }) expect(swapListener).toHaveBeenCalledTimes(1) const event = swapListener.mock.calls[0][0] as CustomEvent expect(event.detail.path).toBe('b.md') window.removeEventListener('laputa:editor-tab-swapped', swapListener) }) it('hard-resets the editor when the target note body is blank', async () => { const populatedTab = makeTab('a.md', 'Note A') const untitledTab = makeBlankBodyTab('untitled.md') const { mockEditor, rerenderWith } = await createSwapHarness({ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false }, }) mockEditor._tiptapEditor.commands.setContent.mockClear() mockEditor.replaceBlocks.mockClear() await rerenderWith({ tabs: [untitledTab], activeTabPath: 'untitled.md' }) expect(mockEditor._tiptapEditor.commands.setContent).not.toHaveBeenCalled() expect(mockEditor.replaceBlocks).toHaveBeenCalledWith( expect.any(Array), [expect.objectContaining({ id: expect.any(String), type: 'paragraph' })], ) }) it('renders empty H1 untitled notes via repaired BlockNote blocks', async () => { const populatedTab = makeTab('a.md', 'Note A') const untitledTab = makeUntitledTab('untitled.md') const { mockEditor, rerenderWith } = await createSwapHarness({ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false }, }) mockEditor.tryParseMarkdownToBlocks.mockClear() mockEditor.replaceBlocks.mockClear() mockEditor._tiptapEditor.commands.setContent.mockClear() await rerenderWith({ tabs: [untitledTab], activeTabPath: 'untitled.md' }) expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled() expect(mockEditor._tiptapEditor.commands.setContent).not.toHaveBeenCalled() expect(mockEditor.replaceBlocks).toHaveBeenCalledWith( expect.any(Array), [ expect.objectContaining({ id: expect.any(String), type: 'heading' }), expect.objectContaining({ id: expect.any(String), type: 'paragraph' }), ], ) }) it('renders empty H1 typed notes with template content under the title as repaired blocks', async () => { const populatedTab = makeTab('a.md', 'Note A') const typedUntitledTab = makeUntitledTab('untitled-note-123.md', 'Untitled Project 1', '## Objective\n\n') const { mockEditor, rerenderWith } = await createSwapHarness({ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false }, setupEditor: (editor) => { editor.tryParseMarkdownToBlocks.mockReturnValue([ { type: 'heading', props: { level: 2 }, content: [{ type: 'text', text: 'Objective', styles: {} }], children: [], }, ]) }, }) mockEditor.tryParseMarkdownToBlocks.mockClear() mockEditor._tiptapEditor.commands.setContent.mockClear() await rerenderWith({ tabs: [typedUntitledTab], activeTabPath: typedUntitledTab.entry.path }) expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith('## Objective\n\n') expect(mockEditor._tiptapEditor.commands.setContent).not.toHaveBeenCalled() expect(mockEditor.replaceBlocks).toHaveBeenCalledWith( expect.any(Array), [ expect.objectContaining({ id: expect.any(String), type: 'heading' }), expect.objectContaining({ id: expect.any(String), type: 'heading' }), ], ) }) it('keeps immediate typing when an untitled note template parse resolves late', async () => { const templateParse = createDeferred() const populatedTab = makeTab('a.md', 'Note A') const typedUntitledTab = makeUntitledTab('untitled-note-123.md', 'Untitled Project 1', '## Objective\n\n') const editorContainer = document.createElement('div') const editable = document.createElement('div') const userTypedBlocks = [ { type: 'heading', props: { level: 1 }, content: [{ type: 'text', text: 'Fast Title', styles: {} }], children: [], }, makeTextParagraphBlock('Typed before the template finished rendering'), ] editorContainer.className = 'editor__blocknote-container' editable.contentEditable = 'true' editorContainer.appendChild(editable) document.body.appendChild(editorContainer) try { const { docRef, mockEditor, result, rerenderWith } = await createSwapHarness({ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false }, setupEditor: (editor) => { editor.tryParseMarkdownToBlocks.mockReturnValue(templateParse.promise) }, }) mockEditor.replaceBlocks.mockClear() mockEditor.tryParseMarkdownToBlocks.mockClear() await rerenderWith({ tabs: [typedUntitledTab], activeTabPath: typedUntitledTab.entry.path }) await flushEditorTick() expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith('## Objective\n\n') docRef.current = userTypedBlocks act(() => { editable.dispatchEvent(new Event('input', { bubbles: true, cancelable: true })) result.current.handleEditorChange() }) await act(async () => { templateParse.resolve([makeTextParagraphBlock('Late template')]) await Promise.resolve() }) expect(mockEditor.replaceBlocks).not.toHaveBeenCalled() expect(docRef.current).toBe(userTypedBlocks) } finally { editorContainer.remove() } }) it('reuses cached editor blocks when reopening a recently visited note', async () => { const tabA = makeTab('a.md', 'Note A') const tabB = makeTab('b.md', 'Note B') const { mockEditor, rerenderWith } = await createSwapHarness({ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, }) await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' }) mockEditor.tryParseMarkdownToBlocks.mockClear() mockEditor.replaceBlocks.mockClear() await rerenderWith({ tabs: [tabA], activeTabPath: 'a.md' }) expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled() expect(mockEditor.replaceBlocks).toHaveBeenCalled() }) it('uses parsed block cache for a note that was warmed before opening', async () => { const tabA = makeTab('a.md', 'Note A') const tabB = makeTab('b.md', 'Note B') const warmedBlocks = [makeTextParagraphBlock('Warmed body')] cacheParsedNoteBlocks({ path: tabB.entry.path, sourceContent: tabB.content, blocks: warmedBlocks, scrollTop: 0, }) const { mockEditor, rerenderWith } = await createSwapHarness({ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, }) mockEditor.tryParseMarkdownToBlocks.mockClear() mockEditor.replaceBlocks.mockClear() await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' }) expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled() expect(mockEditor.replaceBlocks.mock.calls[0][1]).toEqual([ expect.objectContaining({ content: [{ type: 'text', text: 'Warmed body', styles: {} }], }), ]) }) it('clears stale editor DOM selection before switching notes', async () => { const tabA = makeTab('a.md', 'Note A') const tabB = makeTab('b.md', 'Note B') const { rerenderWith } = await createSwapHarness({ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, }) const container = document.createElement('div') container.className = 'editor__blocknote-container' const editable = document.createElement('div') editable.contentEditable = 'true' editable.textContent = 'Old note caret' container.appendChild(editable) document.body.appendChild(container) try { const range = document.createRange() range.setStart(editable.firstChild!, 4) range.collapse(true) const selection = window.getSelection()! selection.removeAllRanges() selection.addRange(range) editable.focus() expect(selection.rangeCount).toBe(1) await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' }) expect(window.getSelection()?.rangeCount).toBe(0) expect(document.activeElement).not.toBe(editable) } finally { container.remove() } }) it('resets stale TipTap selection before applying a switched note', async () => { const tabA = makeTab('a.md', 'Note A') const tabB = makeTab('b.md', 'Note B') const { mockEditor, rerenderWith } = await createSwapHarness({ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, setupEditor: (editor) => { editor._tiptapEditor.state.doc.content.size = 3 }, }) mockEditor._tiptapEditor.commands.setTextSelection.mockClear() await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' }) expect(mockEditor._tiptapEditor.commands.setTextSelection).toHaveBeenCalledWith(1) expect(mockEditor.replaceBlocks).toHaveBeenCalled() }) it('ignores stale same-path parse results when tab content refreshes', async () => { const staleParse = createDeferred() const freshParse = createDeferred() const tabA = makeTab('a.md', 'Note A') const refreshedTabA = { ...tabA, content: '---\ntitle: Note A\n---\n\n# Note A\n\nFresh filesystem content.', } const staleBlocks = [makeTextParagraphBlock('Stale content')] const freshBlocks = [makeTextParagraphBlock('Fresh filesystem content')] const { mockEditor, rerenderWith } = await createSwapHarness({ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, setupEditor: (editor) => { editor.tryParseMarkdownToBlocks .mockReturnValueOnce(staleParse.promise) .mockReturnValueOnce(freshParse.promise) }, }) await rerenderWith({ tabs: [refreshedTabA], activeTabPath: 'a.md' }) mockEditor.replaceBlocks.mockClear() await act(async () => { staleParse.resolve(staleBlocks) await Promise.resolve() }) expect(mockEditor.replaceBlocks).not.toHaveBeenCalled() await act(async () => { freshParse.resolve(freshBlocks) await Promise.resolve() }) expect(mockEditor.replaceBlocks).toHaveBeenCalledTimes(1) expect(mockEditor.replaceBlocks.mock.calls[0][1]).toEqual([ expect.objectContaining({ content: [{ type: 'text', text: 'Fresh filesystem content', styles: {} }], }), ]) }) it('re-parses when the active tab content changes without a path change', async () => { const tabA = makeTab('a.md', 'Note A') const refreshedTabA = { ...tabA, content: '---\ntitle: Note A\n---\n\n# Note A\n\nFresh after pull.', } const { mockEditor, rerenderWith } = await createSwapHarness({ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, }) mockEditor.tryParseMarkdownToBlocks.mockClear() mockEditor.replaceBlocks.mockClear() await rerenderWith({ tabs: [refreshedTabA], activeTabPath: 'a.md' }) expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith( expect.stringContaining('Fresh after pull.'), ) expect(mockEditor.replaceBlocks).toHaveBeenCalled() }) it('repairs parsed blocks with missing ids before applying them to the editor', async () => { const tabA = makeTab('a.md', 'Note A') const malformedTab = { ...makeTab('malformed.md', 'Malformed'), content: '---\ntitle: Malformed\n---\n\n# Malformed\n\n- Parent\n - Child', } const { mockEditor, rerenderWith } = await createSwapHarness({ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, setupEditor: (editor) => { editor.tryParseMarkdownToBlocks.mockReturnValue([ { type: 'bulletListItem', content: [{ type: 'text', text: 'Parent', styles: {} }], children: [ { type: 'bulletListItem', content: [{ type: 'text', text: 'Child', styles: {} }], children: [], }, ], }, ]) }, }) mockEditor.replaceBlocks.mockClear() await rerenderWith({ tabs: [malformedTab], activeTabPath: 'malformed.md' }) const appliedBlocks = mockEditor.replaceBlocks.mock.calls[0][1] expect(appliedBlocks).toEqual([ expect.objectContaining({ id: expect.any(String), children: [ expect.objectContaining({ id: expect.any(String), }), ], }), ]) }) it('replaces non-object malformed parsed blocks before applying them to the editor', async () => { const tabA = makeTab('a.md', 'Note A') const malformedTab = { ...makeTab('malformed.md', 'Malformed'), content: '---\ntitle: Malformed\n---\n\n# Malformed\n\nRecovered body.', } const { mockEditor, rerenderWith } = await createSwapHarness({ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, setupEditor: (editor) => { editor.tryParseMarkdownToBlocks.mockReturnValue([ null, 'dangling content', { type: 'paragraph', content: [{ type: 'text', text: 'Parent', styles: {} }], children: [ undefined, { type: 'paragraph', content: [{ type: 'text', text: 'Child', styles: {} }], children: [], }, ], }, ]) }, }) mockEditor.replaceBlocks.mockClear() await rerenderWith({ tabs: [malformedTab], activeTabPath: 'malformed.md' }) const appliedBlocks = mockEditor.replaceBlocks.mock.calls[0][1] expect(appliedBlocks).toEqual([ expect.objectContaining({ id: expect.any(String), type: 'paragraph', content: [], children: [] }), expect.objectContaining({ id: expect.any(String), type: 'paragraph', content: [], children: [] }), expect.objectContaining({ id: expect.any(String), content: [{ type: 'text', text: 'Parent', styles: {} }], children: [], }), expect.objectContaining({ id: expect.any(String), type: 'paragraph', content: [], children: [] }), expect.objectContaining({ id: expect.any(String), content: [{ type: 'text', text: 'Child', styles: {} }], children: [], }), ]) }) it('ignores editor change events before the pending tab swap applies a new untitled note', async () => { vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element) vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) const onContentChange = vi.fn() const docRef = { current: blocksA as unknown[] } const mockEditor = makeMockEditor(docRef) Object.defineProperty(mockEditor, 'document', { get: () => docRef.current }) const populatedTab = makeTab('a.md', 'Note A') const untitledTab = makeUntitledTab('untitled.md') const { result, rerender } = renderHook( ({ tabs, activeTabPath }) => useEditorTabSwap({ tabs, activeTabPath, editor: mockEditor as never, onContentChange, }), { initialProps: { tabs: [populatedTab], activeTabPath: 'a.md' } }, ) await act(() => new Promise(r => setTimeout(r, 0))) rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md' }) act(() => { result.current.handleEditorChange() }) expect(onContentChange).not.toHaveBeenCalled() await flushEditorTick() }) it('ignores delayed programmatic change events until a swapped note frame commits', async () => { vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element) const frameCallbacks: FrameRequestCallback[] = [] vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { frameCallbacks.push(cb) return frameCallbacks.length }) const onContentChange = vi.fn() const staleAlphaBlocks = [{ type: 'paragraph', content: [{ type: 'text', text: 'Stale Alpha body', styles: {} }], children: [], }] const docRef = { current: staleAlphaBlocks as unknown[] } const mockEditor = makeMockEditor(docRef) Object.defineProperty(mockEditor, 'document', { get: () => docRef.current }) mockEditor.blocksToMarkdownLossy.mockReturnValue('Stale Alpha body') const tabA = makeTab('a.md', 'Note A') const tabB = makeTab('b.md', 'Note B') const { result, rerender } = renderHook( ({ tabs, activeTabPath }) => useEditorTabSwap({ tabs, activeTabPath, editor: mockEditor as never, onContentChange, }), { initialProps: { tabs: [tabA], activeTabPath: 'a.md' } }, ) await act(() => new Promise(r => setTimeout(r, 0))) flushQueuedFrames(frameCallbacks) onContentChange.mockClear() mockEditor.replaceBlocks.mockImplementation(() => { // Simulate BlockNote reporting a programmatic change before its document // getter reflects the new note content. }) docRef.current = staleAlphaBlocks rerender({ tabs: [tabB], activeTabPath: 'b.md' }) await flushEditorTick() act(() => { result.current.handleEditorChange() result.current.flushPendingEditorChange() }) expect(onContentChange).not.toHaveBeenCalled() flushQueuedFrames(frameCallbacks) }) it('saves BlockNote image asset URLs as vault-relative attachment links', async () => { const tabA = makeTab('a.md', 'Note A') const onContentChange = vi.fn() const { docRef, mockEditor, result } = await createSwapHarness({ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false, vaultPath: '/vault' }, onContentChange, }) docRef.current = [{ type: 'image', props: { url: 'asset://localhost/%2Fvault%2Fattachments%2Fshot.png' }, children: [], }] mockEditor.blocksToMarkdownLossy.mockReturnValue( '![shot](asset://localhost/%2Fvault%2Fattachments%2Fshot.png)\n', ) act(() => { result.current.handleEditorChange() result.current.flushPendingEditorChange() }) expect(onContentChange).toHaveBeenCalledWith( 'a.md', '---\ntitle: Note A\n---\n![shot](attachments/shot.png)\n', ) }) it('flushes unsaved file attachment blocks as portable links before switching notes', async () => { const tabA = makeTab('a.md', 'Note A') const tabB = makeTab('b.md', 'Note B') const onContentChange = vi.fn() const { docRef, mockEditor, rerender, result } = await createSwapHarness({ initialProps: { tabs: [tabA, tabB], activeTabPath: 'a.md', rawMode: false, vaultPath: '/vault' }, onContentChange, }) docRef.current = [{ type: 'file', props: { name: 'project brief.pdf', url: '/vault/attachments/project brief.pdf', }, children: [], }] mockEditor.blocksToMarkdownLossy.mockClear() mockEditor.blocksToMarkdownLossy.mockReturnValue('unreachable lossy markdown') act(() => { result.current.handleEditorChange() }) expect(onContentChange).not.toHaveBeenCalled() act(() => { rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md', rawMode: false, vaultPath: '/vault' }) }) await act(async () => { await Promise.resolve() }) expect(onContentChange).toHaveBeenCalledWith( 'a.md', '---\ntitle: Note A\n---\n[project brief.pdf]()\n', ) expect(mockEditor.blocksToMarkdownLossy).not.toHaveBeenCalled() }) it('serializes rich inline math nodes back to Markdown on editor changes', async () => { vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element) vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) const onContentChange = vi.fn() const docRef = { current: blocksA as unknown[] } const mockEditor = makeMockEditor(docRef) Object.defineProperty(mockEditor, 'document', { get: () => docRef.current }) mockEditor.blocksToMarkdownLossy.mockImplementation((blocks: unknown[]) => ( (blocks as Array<{ content?: Array<{ text?: string }> }>) .map((block) => block.content?.map((item) => item.text ?? '').join('') ?? '') .join('\n\n') )) const tabA = makeTab('a.md', 'Note A') const { result } = renderHook( () => useEditorTabSwap({ tabs: [tabA], activeTabPath: 'a.md', editor: mockEditor as never, onContentChange, }), ) await act(() => new Promise(r => setTimeout(r, 0))) docRef.current = [{ type: 'paragraph', content: [ { type: 'text', text: 'Inline ', styles: {} }, { type: 'mathInline', props: { latex: 'x^2' } }, ], children: [], }] act(() => { result.current.handleEditorChange() }) expect(onContentChange).not.toHaveBeenCalled() act(() => { result.current.flushPendingEditorChange() }) expect(onContentChange).toHaveBeenCalledWith( 'a.md', '---\ntitle: Note A\n---\nInline $x^2$\n', ) }) it('coalesces rich-editor serialization for a 4000-word note', async () => { const tabA = makeTab('long.md', 'Long Note') const onContentChange = vi.fn() const { docRef, mockEditor, result } = await createSwapHarness({ initialProps: { tabs: [tabA], activeTabPath: 'long.md', rawMode: false }, onContentChange, }) const longBlocks = makeLongNoteBlocks(4200) docRef.current = longBlocks mockEditor.blocksToMarkdownLossy.mockImplementation((blocks: unknown[]) => ( (blocks as Array<{ content?: Array<{ text?: string }> }>) .map((block) => block.content?.map((item) => item.text ?? '').join('') ?? '') .join('\n\n') )) mockEditor.blocksToMarkdownLossy.mockClear() vi.useFakeTimers() try { act(() => { result.current.handleEditorChange() result.current.handleEditorChange() result.current.handleEditorChange() }) expect(mockEditor.blocksToMarkdownLossy).not.toHaveBeenCalled() expect(onContentChange).not.toHaveBeenCalled() act(() => { vi.advanceTimersByTime(RICH_EDITOR_CHANGE_DEBOUNCE_MS - 1) }) expect(mockEditor.blocksToMarkdownLossy).not.toHaveBeenCalled() act(() => { vi.advanceTimersByTime(1) }) expect(mockEditor.blocksToMarkdownLossy).toHaveBeenCalledTimes(1) expect(onContentChange).toHaveBeenCalledTimes(1) expect(onContentChange.mock.calls[0][1].split(/\s+/).length).toBeGreaterThan(4000) } finally { vi.useRealTimers() } }) it('flushes pending rich-editor content before switching notes', async () => { const tabA = makeTab('a.md', 'Note A') const tabB = makeTab('b.md', 'Note B') const onContentChange = vi.fn() const { docRef, mockEditor, rerender, result } = await createSwapHarness({ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, onContentChange, }) docRef.current = [{ type: 'paragraph', content: [{ type: 'text', text: 'Changed before switch', styles: {} }], children: [], }] mockEditor.blocksToMarkdownLossy.mockReturnValue('Changed before switch\n') vi.useFakeTimers() try { act(() => { result.current.handleEditorChange() }) expect(onContentChange).not.toHaveBeenCalled() act(() => { rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md', rawMode: false }) }) await act(async () => { await Promise.resolve() }) expect(onContentChange).toHaveBeenCalledWith( 'a.md', '---\ntitle: Note A\n---\nChanged before switch\n', ) } finally { vi.useRealTimers() } }) it('flushes pending rich-editor content before a switched-away tab is removed', async () => { const tabA = makeTab('a.md', 'Note A') const tabB = makeTab('b.md', 'Note B') const onContentChange = vi.fn() const { docRef, mockEditor, rerender, result } = await createSwapHarness({ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, onContentChange, }) docRef.current = [{ type: 'paragraph', content: [{ type: 'text', text: 'Changed before close', styles: {} }], children: [], }] mockEditor.blocksToMarkdownLossy.mockReturnValue('Changed before close\n') vi.useFakeTimers() try { act(() => { result.current.handleEditorChange() }) expect(onContentChange).not.toHaveBeenCalled() act(() => { rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false }) }) await act(async () => { await Promise.resolve() }) expect(onContentChange).toHaveBeenCalledWith( 'a.md', '---\ntitle: Note A\n---\nChanged before close\n', ) } finally { vi.useRealTimers() } }) it('reopens a switched-away note with the rich-editor content that was flushed during the switch', async () => { const tabA = makeTab('a.md', 'Note A') const tabB = makeTab('b.md', 'Note B') const savedContentByPath = new Map() const onContentChange = vi.fn((path: string, content: string) => { savedContentByPath.set(path, content) }) const { docRef, mockEditor, rerenderWith, result } = await createSwapHarness({ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, onContentChange, setupEditor: (editor) => { editor.tryParseMarkdownToBlocks.mockImplementation((markdown: string) => [ makeTextParagraphBlock(markdown.includes('Changed before switch') ? 'Changed before switch' : 'Parsed tab body'), ]) }, }) docRef.current = [makeTextParagraphBlock('Changed before switch')] mockEditor.blocksToMarkdownLossy.mockReturnValue('Changed before switch\n') act(() => { result.current.handleEditorChange() }) await rerenderWith({ tabs: [tabA, tabB], activeTabPath: 'b.md' }) const flushedTabAContent = savedContentByPath.get('a.md') expect(flushedTabAContent).toBe('---\ntitle: Note A\n---\nChanged before switch\n') mockEditor.replaceBlocks.mockClear() mockEditor.tryParseMarkdownToBlocks.mockClear() await rerenderWith({ tabs: [{ ...tabA, content: flushedTabAContent! }, tabB], activeTabPath: 'a.md', }) const appliedBlocks = mockEditor.replaceBlocks.mock.calls.at(-1)?.[1] as Array<{ content?: Array<{ text?: string }> }> const appliedText = appliedBlocks .flatMap(block => block.content?.map(part => part.text ?? '') ?? []) .join('\n') expect(appliedText).toContain('Changed before switch') }) it('rejects unserializable mixed paragraph and list content without crashing', async () => { const tabA = makeTab('mixed.md', 'Mixed Note') const onContentChange = vi.fn() const serializationError = new RangeError( 'Invalid content for node blockContainer: ', ) const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { docRef, mockEditor, result } = await createSwapHarness({ initialProps: { tabs: [tabA], activeTabPath: 'mixed.md', rawMode: false }, onContentChange, }) docRef.current = [{ type: 'paragraph', content: [{ type: 'text', text: 'User', styles: {} }], children: [{ type: 'bulletListItem', content: [{ type: 'text', text: 'Task', styles: {} }], children: [], }], }] mockEditor.blocksToMarkdownLossy.mockImplementation(() => { throw serializationError }) act(() => { result.current.handleEditorChange() }) expect(() => { act(() => { result.current.flushPendingEditorChange() }) }).not.toThrow() expect(onContentChange).not.toHaveBeenCalled() expect(consoleSpy).toHaveBeenCalledWith( '[editor] Skipped editor change because BlockNote document could not be serialized:', serializationError, ) }) it('re-parses from tab.content when rawMode transitions from true to false', async () => { vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element) vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) const docRef = { current: blocksA as unknown[] } const mockEditor = makeMockEditor(docRef) Object.defineProperty(mockEditor, 'document', { get: () => docRef.current }) const tabA = makeTab('a.md', 'Note A') const { rerender } = renderHook( ({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({ tabs, activeTabPath, editor: mockEditor as never, rawMode, }), { initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } }, ) // Initial load — parses and caches blocks await act(() => new Promise(r => setTimeout(r, 0))) // Enter raw mode rerender({ tabs: [tabA], activeTabPath: 'a.md', rawMode: true }) await act(() => new Promise(r => setTimeout(r, 0))) // Simulate raw editing: tab content was updated externally const updatedTab = { ...tabA, content: '---\ntitle: Updated Title\n---\n\n# Updated Title\n\nNew body content.', } mockEditor.tryParseMarkdownToBlocks.mockClear() mockEditor.replaceBlocks.mockClear() // Exit raw mode with updated content rerender({ tabs: [updatedTab], activeTabPath: 'a.md', rawMode: false }) await act(() => new Promise(r => setTimeout(r, 0))) // Verify re-parse happened with updated body content expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith( expect.stringContaining('Updated Title'), ) expect(mockEditor.replaceBlocks).toHaveBeenCalled() }) it('keeps formula Markdown visible when raw mode exits into a parser failure', async () => { vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element) vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) const parseError = new Error('BlockNote parser failed on math') const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const formulaTab = { ...makeTab('a.md', 'Formula'), content: [ '---', 'title: Formula', '---', '', '# Formula', '', 'Pasted math:', '', '$$', '\\begin{aligned}', 'x &= y + 1', '\\end{aligned}', '$$', ].join('\n'), } const { mockEditor, rerenderWith } = await createSwapHarness({ initialProps: { tabs: [makeTab('a.md', 'Formula')], activeTabPath: 'a.md', rawMode: false }, setupEditor: (editor) => { editor.tryParseMarkdownToBlocks.mockImplementation((markdown: string) => { if (markdown.includes('@@TOLARIA_MATH_BLOCK:')) throw parseError return blocksA }) }, }) await rerenderWith({ rawMode: true }) mockEditor.replaceBlocks.mockClear() await rerenderWith({ tabs: [formulaTab], rawMode: false }) const appliedBlocks = mockEditor.replaceBlocks.mock.calls[0]?.[1] as Array<{ content?: Array<{ text?: string }> }> const renderedText = appliedBlocks .flatMap(block => block.content?.map(part => part.text ?? '') ?? []) .join('\n') expect(renderedText).toContain('# Formula') expect(renderedText).toContain('Pasted math:') expect(renderedText).toContain('\\begin{aligned}') expect(warnSpy).toHaveBeenCalledWith( '[editor] Rendering a.md as plain Markdown because BlockNote could not parse it:', parseError, ) }) it('does not skip swap when rawMode is on (editor hidden)', async () => { vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element) vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) const docRef = { current: blocksA as unknown[] } const mockEditor = makeMockEditor(docRef) Object.defineProperty(mockEditor, 'document', { get: () => docRef.current }) const tabA = makeTab('a.md', 'Note A') const { rerender } = renderHook( ({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({ tabs, activeTabPath, editor: mockEditor as never, rawMode, }), { initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } }, ) await act(() => new Promise(r => setTimeout(r, 0))) mockEditor.replaceBlocks.mockClear() // Enter raw mode and update content const updatedTab = { ...tabA, content: '---\ntitle: Changed\n---\n\n# Changed\n\nEdited.' } rerender({ tabs: [updatedTab], activeTabPath: 'a.md', rawMode: true }) await act(() => new Promise(r => setTimeout(r, 0))) // While in raw mode, the editor should NOT be updated expect(mockEditor.replaceBlocks).not.toHaveBeenCalled() }) it('preserves content through multiple BlockNote→raw→BlockNote cycles', async () => { vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element) vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) const docRef = { current: blocksA as unknown[] } const mockEditor = makeMockEditor(docRef) Object.defineProperty(mockEditor, 'document', { get: () => docRef.current }) const tabA = makeTab('a.md', 'Note A') const { rerender } = renderHook( ({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({ tabs, activeTabPath, editor: mockEditor as never, rawMode, }), { initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } }, ) await act(() => new Promise(r => setTimeout(r, 0))) // Cycle 1: raw mode on → edit → raw mode off rerender({ tabs: [tabA], activeTabPath: 'a.md', rawMode: true }) await act(() => new Promise(r => setTimeout(r, 0))) const edit1 = { ...tabA, content: '---\ntitle: Edit 1\n---\n\n# Edit 1\n\nFirst edit.' } mockEditor.tryParseMarkdownToBlocks.mockClear() rerender({ tabs: [edit1], activeTabPath: 'a.md', rawMode: false }) await act(() => new Promise(r => setTimeout(r, 0))) expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith( expect.stringContaining('Edit 1'), ) // Cycle 2: raw mode on → edit → raw mode off rerender({ tabs: [edit1], activeTabPath: 'a.md', rawMode: true }) await act(() => new Promise(r => setTimeout(r, 0))) const edit2 = { ...tabA, content: '---\ntitle: Edit 2\n---\n\n# Edit 2\n\nSecond edit.' } mockEditor.tryParseMarkdownToBlocks.mockClear() rerender({ tabs: [edit2], activeTabPath: 'a.md', rawMode: false }) await act(() => new Promise(r => setTimeout(r, 0))) expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith( expect.stringContaining('Edit 2'), ) }) }) describe('useEditorTabSwap scroll position', () => { afterEach(() => { vi.restoreAllMocks() }) it('restores each note scroll position when switching tabs', async () => { const { scrollEl } = installEditorDomSpies() const docRef = { current: blocksA as unknown[] } const mockEditor = makeMockEditor(docRef) const tabA = makeTab('a.md', 'Note A') const tabB = makeTab('b.md', 'Note B') const rendered = renderHook( ({ tabs, activeTabPath }) => useEditorTabSwap({ tabs, activeTabPath, editor: mockEditor as never, }), { initialProps: { tabs: [tabA, tabB], activeTabPath: 'a.md' } }, ) await flushEditorTick() scrollEl.scrollTop = 240 rendered.rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md' }) await flushEditorTick() expect(scrollEl.scrollTop).toBe(0) scrollEl.scrollTop = 75 rendered.rerender({ tabs: [tabA, tabB], activeTabPath: 'a.md' }) await flushEditorTick() expect(scrollEl.scrollTop).toBe(240) rendered.rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md' }) await flushEditorTick() expect(scrollEl.scrollTop).toBe(75) }) it('defaults to scroll top 0 for newly opened note', async () => { const scrollEl = { scrollTop: 0 } vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element) const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) const docRef = { current: blocksA as unknown[] } const mockEditor = makeMockEditor(docRef) Object.defineProperty(mockEditor, 'document', { get: () => docRef.current }) const tabA = makeTab('a.md', 'Note A') renderHook( ({ tabs, activeTabPath }) => useEditorTabSwap({ tabs, activeTabPath, editor: mockEditor as never, }), { initialProps: { tabs: [tabA], activeTabPath: 'a.md' } }, ) await act(() => new Promise(r => setTimeout(r, 0))) // For a fresh note, scroll should go to 0 expect(rAF).toHaveBeenCalled() expect(scrollEl.scrollTop).toBe(0) }) })