From b22da65d446f61c707b187d779360b9914e1c2cb Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 19 Apr 2026 11:29:04 +0200 Subject: [PATCH] fix: keep note switches instant --- src/hooks/useEditorTabSwap.test.ts | 248 +++++++++++++------------ src/hooks/useEditorTabSwap.ts | 282 +++++++++++++++++++---------- src/hooks/useTabManagement.test.ts | 14 ++ src/hooks/useTabManagement.ts | 149 +++++++++++++-- 4 files changed, 457 insertions(+), 236 deletions(-) diff --git a/src/hooks/useEditorTabSwap.test.ts b/src/hooks/useEditorTabSwap.test.ts index 0a7d5392..7715fd67 100644 --- a/src/hooks/useEditorTabSwap.test.ts +++ b/src/hooks/useEditorTabSwap.test.ts @@ -60,11 +60,7 @@ describe('extractEditorBody', () => { describe('getH1TextFromBlocks', () => { it('returns text from H1 heading block', () => { - const blocks = [{ - type: 'heading', - props: { level: 1 }, - content: [{ type: 'text', text: 'My Title', styles: {} }], - }] + const blocks = makeHeadingBlocks([{ type: 'text', text: 'My Title', styles: {} }]) expect(getH1TextFromBlocks(blocks)).toBe('My Title') }) @@ -81,41 +77,25 @@ describe('getH1TextFromBlocks', () => { }) it('returns null for H2 heading', () => { - const blocks = [{ - type: 'heading', - props: { level: 2 }, - content: [{ type: 'text', text: 'Subtitle' }], - }] + const blocks = makeHeadingBlocks([{ type: 'text', text: 'Subtitle' }], 2) expect(getH1TextFromBlocks(blocks)).toBeNull() }) it('concatenates multiple text spans', () => { - const blocks = [{ - type: 'heading', - props: { level: 1 }, - content: [ - { type: 'text', text: 'Hello ' }, - { type: 'text', text: 'World' }, - ], - }] + 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 = [{ - type: 'heading', - props: { level: 1 }, - content: [], - }] + const blocks = makeHeadingBlocks([]) expect(getH1TextFromBlocks(blocks)).toBeNull() }) it('returns null for whitespace-only H1', () => { - const blocks = [{ - type: 'heading', - props: { level: 1 }, - content: [{ type: 'text', text: ' ' }], - }] + const blocks = makeHeadingBlocks([{ type: 'text', text: ' ' }]) expect(getH1TextFromBlocks(blocks)).toBeNull() }) @@ -125,14 +105,10 @@ describe('getH1TextFromBlocks', () => { }) it('filters non-text inline content', () => { - const blocks = [{ - type: 'heading', - props: { level: 1 }, - content: [ - { type: 'text', text: 'Title' }, - { type: 'wikilink', props: { target: 'linked' } }, - ], - }] + const blocks = makeHeadingBlocks([ + { type: 'text', text: 'Title' }, + { type: 'wikilink', props: { target: 'linked' } }, + ]) expect(getH1TextFromBlocks(blocks)).toBe('Title') }) }) @@ -187,7 +163,7 @@ function makeBlankBodyTab(path: string, title = 'Untitled Note 1') { } function makeMockEditor(docRef: { current: unknown[] }) { - return { + const editor = { document: docRef.current, get prosemirrorView() { return {} }, onMount: (cb: () => void) => { cb(); return () => {} }, @@ -199,39 +175,92 @@ function makeMockEditor(docRef: { current: unknown[] }) { _tiptapEditor: { commands: { setContent: vi.fn() } }, _docRef: docRef, } + Object.defineProperty(editor, 'document', { get: () => docRef.current }) + return editor +} + +function makeHeadingBlocks( + content: Array>, + level = 1, +) { + return [{ + type: 'heading', + props: { level }, + content, + }] +} + +async function flushEditorTick() { + await act(() => new Promise((resolve) => setTimeout(resolve, 0))) +} + +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 } +} + +type SwapHarnessProps = { + tabs: ReturnType[] + activeTabPath: string | null + rawMode?: boolean +} + +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, + mockEditor, + async rerenderWith(nextProps: Partial) { + currentProps = { ...currentProps, ...nextProps } + rendered.rerender(currentProps) + await flushEditorTick() + }, + } } describe('useEditorTabSwap raw mode sync', () => { afterEach(() => { vi.restoreAllMocks() }) it('swaps in the new note when the path updates before tabs catch up', 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 tabB = makeTab('b.md', 'March 2024') - 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))) + const { mockEditor, rerenderWith } = await createSwapHarness({ + initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, + }) mockEditor.tryParseMarkdownToBlocks.mockClear() mockEditor.replaceBlocks.mockClear() - rerender({ tabs: [tabA], activeTabPath: 'b.md', rawMode: false }) - await act(() => new Promise(r => setTimeout(r, 0))) + await rerenderWith({ tabs: [tabA], activeTabPath: 'b.md' }) expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled() - rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false }) - await act(() => new Promise(r => setTimeout(r, 0))) + await rerenderWith({ tabs: [tabB] }) expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith( expect.stringContaining('March 2024'), @@ -240,31 +269,18 @@ describe('useEditorTabSwap raw mode sync', () => { }) it('signals when the target tab content has been applied', async () => { - vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element) - vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) - const swapListener = vi.fn() window.addEventListener('laputa:editor-tab-swapped', swapListener) - 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 tabB = makeTab('b.md', 'March 2024') - 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))) + const { rerenderWith } = await createSwapHarness({ + initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, + }) swapListener.mockClear() - rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false }) - await act(() => new Promise(r => setTimeout(r, 0))) + await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' }) expect(swapListener).toHaveBeenCalledTimes(1) const event = swapListener.mock.calls[0][0] as CustomEvent @@ -274,59 +290,33 @@ describe('useEditorTabSwap raw mode sync', () => { }) it('hard-resets the editor when the target note body is blank', 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 populatedTab = makeTab('a.md', 'Note A') const untitledTab = makeBlankBodyTab('untitled.md') - const { rerender } = renderHook( - ({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({ - tabs, activeTabPath, editor: mockEditor as never, rawMode, - }), - { initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } }, - ) - - await act(() => new Promise(r => setTimeout(r, 0))) + const { mockEditor, rerenderWith } = await createSwapHarness({ + initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false }, + }) mockEditor._tiptapEditor.commands.setContent.mockClear() mockEditor.replaceBlocks.mockClear() - rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md', rawMode: false }) - await act(() => new Promise(r => setTimeout(r, 0))) + await rerenderWith({ tabs: [untitledTab], activeTabPath: 'untitled.md' }) expect(mockEditor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('

') expect(mockEditor.replaceBlocks).not.toHaveBeenCalled() }) it('renders empty H1 untitled notes via TipTap HTML content', 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 populatedTab = makeTab('a.md', 'Note A') const untitledTab = makeUntitledTab('untitled.md') - const { rerender } = renderHook( - ({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({ - tabs, activeTabPath, editor: mockEditor as never, rawMode, - }), - { initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } }, - ) - - await act(() => new Promise(r => setTimeout(r, 0))) + const { mockEditor, rerenderWith } = await createSwapHarness({ + initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false }, + }) mockEditor.tryParseMarkdownToBlocks.mockClear() mockEditor.replaceBlocks.mockClear() mockEditor._tiptapEditor.commands.setContent.mockClear() - rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md', rawMode: false }) - await act(() => new Promise(r => setTimeout(r, 0))) + await rerenderWith({ tabs: [untitledTab], activeTabPath: 'untitled.md' }) expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled() expect(mockEditor.replaceBlocks).not.toHaveBeenCalled() @@ -334,35 +324,43 @@ describe('useEditorTabSwap raw mode sync', () => { }) it('renders empty H1 typed notes with template content under the title', 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) - mockEditor.blocksToHTMLLossy.mockReturnValue('

Objective

') - Object.defineProperty(mockEditor, 'document', { get: () => docRef.current }) - const populatedTab = makeTab('a.md', 'Note A') const typedUntitledTab = makeUntitledTab('untitled.md', 'Untitled Project 1', '## Objective\n\n') - const { rerender } = renderHook( - ({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({ - tabs, activeTabPath, editor: mockEditor as never, rawMode, - }), - { initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } }, - ) - - await act(() => new Promise(r => setTimeout(r, 0))) + const { mockEditor, rerenderWith } = await createSwapHarness({ + initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false }, + setupEditor: (editor) => { + editor.blocksToHTMLLossy.mockReturnValue('

Objective

') + }, + }) mockEditor.tryParseMarkdownToBlocks.mockClear() mockEditor._tiptapEditor.commands.setContent.mockClear() - rerender({ tabs: [typedUntitledTab], activeTabPath: 'untitled.md', rawMode: false }) - await act(() => new Promise(r => setTimeout(r, 0))) + await rerenderWith({ tabs: [typedUntitledTab], activeTabPath: 'untitled.md' }) expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith('## Objective\n\n') expect(mockEditor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('

Objective

') }) + 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('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 }) diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index 7dac35c7..84445009 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -11,7 +11,17 @@ interface Tab { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays type EditorBlocks = any[] -type CachedTabState = { blocks: EditorBlocks; scrollTop: number } +type CachedTabState = { blocks: EditorBlocks; scrollTop: number; sourceContent: string } +const TAB_STATE_CACHE_LIMIT = 24 + +interface TabSwapState { + cache: Map + prevPath: string | null + pathChanged: boolean + activeTab: Tab | undefined + previousTab: Tab | undefined + rawModeJustEnded: boolean +} interface UseEditorTabSwapOptions { tabs: Tab[] @@ -93,15 +103,19 @@ function readEditorScrollTop(): number { function cacheEditorState( cache: Map, path: string, - blocks: EditorBlocks, + nextState: CachedTabState, ) { - cache.set(path, { - blocks, - scrollTop: readEditorScrollTop(), - }) + if (cache.has(path)) cache.delete(path) + cache.set(path, nextState) + while (cache.size > TAB_STATE_CACHE_LIMIT) { + const oldestPath = cache.keys().next().value + if (!oldestPath) return + cache.delete(oldestPath) + } } -function buildFastPathBlocks(preprocessed: string): EditorBlocks | null { +function buildFastPathBlocks(options: { preprocessed: string }): EditorBlocks | null { + const { preprocessed } = options const trimmed = preprocessed.trim() if (!trimmed) { @@ -124,11 +138,13 @@ function buildFastPathBlocks(preprocessed: string): EditorBlocks | null { ] } -function isBlankBodyContent(content: string): boolean { +function isBlankBodyContent(options: { content: string }): boolean { + const { content } = options return extractEditorBody(content).trim() === '' } -function extractBodyRemainderAfterEmptyH1(content: string): string | null { +function extractBodyRemainderAfterEmptyH1(options: { content: string }): string | null { + const { content } = options const body = extractEditorBody(content) const [firstLine, secondLine, ...rest] = body.split('\n') if (!firstLine) return null @@ -166,23 +182,22 @@ async function resolveBlocksForTarget( content: string, ): Promise { const cached = cache.get(targetPath) - if (cached) return cached + if (cached?.sourceContent === content) return cached const body = extractEditorBody(content) const preprocessed = preProcessWikilinks(body) - const fastPathBlocks = buildFastPathBlocks(preprocessed) + const fastPathBlocks = buildFastPathBlocks({ preprocessed }) if (fastPathBlocks) { - const nextState = { blocks: fastPathBlocks, scrollTop: 0 } - cache.set(targetPath, nextState) + const nextState = { blocks: fastPathBlocks, scrollTop: 0, sourceContent: content } + cacheEditorState(cache, targetPath, nextState) return nextState } const parsed = await parseMarkdownBlocks(editor, preprocessed) const withWikilinks = injectWikilinks(parsed) - if (withWikilinks.length > 0) { - cache.set(targetPath, { blocks: withWikilinks, scrollTop: 0 }) - } - return { blocks: withWikilinks, scrollTop: 0 } + const nextState = { blocks: withWikilinks, scrollTop: 0, sourceContent: content } + cacheEditorState(cache, targetPath, nextState) + return nextState } function applyBlocksToEditor( @@ -262,7 +277,7 @@ async function resolveEmptyHeadingHtml( editor: ReturnType, content: string, ): Promise { - const remainder = extractBodyRemainderAfterEmptyH1(content) + const remainder = extractBodyRemainderAfterEmptyH1({ content }) if (remainder === null) return null if (!remainder.trim()) return '

' @@ -271,7 +286,11 @@ async function resolveEmptyHeadingHtml( return `

${editor.blocksToHTMLLossy(withWikilinks as typeof parsed)}` } -function findActiveTab(tabs: Tab[], activeTabPath: string | null): Tab | undefined { +function findActiveTab(options: { + tabs: Tab[] + activeTabPath: string | null +}): Tab | undefined { + const { tabs, activeTabPath } = options return activeTabPath ? tabs.find(tab => tab.entry.path === activeTabPath) : undefined @@ -282,11 +301,16 @@ function serializeEditorBody(editor: ReturnType): str return compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof editor.document)) } -function normalizeTabBody(content: string): string { +function normalizeTabBody(options: { content: string }): string { + const { content } = options return compactMarkdown(extractEditorBody(content)) } -function renameBodiesOverlap(currentBody: string, nextBody: string): boolean { +function renameBodiesOverlap(options: { + currentBody: string + nextBody: string +}): boolean { + const { currentBody, nextBody } = options const current = currentBody.trimEnd() const next = nextBody.trimEnd() return current === next @@ -305,10 +329,10 @@ function isUntitledRenameTransition( const currentHeading = getH1TextFromBlocks(editor.document) if (!currentHeading || slugifyPathStem(currentHeading) !== pathStem(nextPath)) return false - return renameBodiesOverlap( - serializeEditorBody(editor), - normalizeTabBody(activeTab.content), - ) + return renameBodiesOverlap({ + currentBody: serializeEditorBody(editor), + nextBody: normalizeTabBody({ content: activeTab.content }), + }) } function useLatestRef(value: T): MutableRefObject { @@ -382,21 +406,27 @@ function consumeRawModeTransition( function cachePreviousTabOnPathChange(options: { prevPath: string | null + previousTab: Tab | undefined pathChanged: boolean editorMountedRef: MutableRefObject cache: Map editor: ReturnType }) { - const { prevPath, pathChanged, editorMountedRef, cache, editor } = options - if (!prevPath || !pathChanged || !editorMountedRef.current) return - cacheEditorState(cache, prevPath, editor.document) + const { prevPath, previousTab, pathChanged, editorMountedRef, cache, editor } = options + if (!prevPath || !previousTab || !pathChanged || !editorMountedRef.current) return + cacheEditorState(cache, prevPath, { + blocks: editor.document, + scrollTop: readEditorScrollTop(), + sourceContent: previousTab.content, + }) } -function shouldWaitForActiveTab( - pathChanged: boolean, - activeTabPath: string | null, - activeTab: Tab | undefined, -) { +function shouldWaitForActiveTab(options: { + pathChanged: boolean + activeTabPath: string | null + activeTab: Tab | undefined +}) { + const { pathChanged, activeTabPath, activeTab } = options return pathChanged && !!activeTabPath && !activeTab } @@ -405,6 +435,7 @@ function syncActivePathTransition(options: { pathChanged: boolean activeTabPath: string | null activeTab: Tab | undefined + previousTab: Tab | undefined cache: Map editor: ReturnType editorMountedRef: MutableRefObject @@ -415,14 +446,22 @@ function syncActivePathTransition(options: { pathChanged, activeTabPath, activeTab, + previousTab, cache, editor, editorMountedRef, prevActivePathRef, } = options - cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor }) - if (shouldWaitForActiveTab(pathChanged, activeTabPath, activeTab)) return true + cachePreviousTabOnPathChange({ + prevPath, + previousTab, + pathChanged, + editorMountedRef, + cache, + editor, + }) + if (shouldWaitForActiveTab({ pathChanged, activeTabPath, activeTab })) return true if (!preserveUntitledRenameState({ prevPath, @@ -495,7 +534,11 @@ function cacheStableActivePath(options: { } = options if (!activeTabPath || !activeTab || !editorMountedRef.current) return - cacheEditorState(cache, activeTabPath, editor.document) + cacheEditorState(cache, activeTabPath, { + blocks: editor.document, + scrollTop: readEditorScrollTop(), + sourceContent: activeTab.content, + }) } function preserveUntitledRenameState(options: { @@ -530,15 +573,21 @@ function preserveUntitledRenameState(options: { return true } -function signalTabSwap(path: string) { +function signalTabSwap(options: { path: string }) { + const { path } = options requestAnimationFrame(() => signalEditorTabSwapped(path)) } -function clearStaleSwap( - targetPath: string, +function clearStaleSwap(options: { + targetPath: string prevActivePathRef: MutableRefObject, suppressChangeRef: MutableRefObject, -): boolean { +}): boolean { + const { + targetPath, + prevActivePathRef, + suppressChangeRef, + } = options if (prevActivePathRef.current === targetPath) return false suppressChangeRef.current = false return true @@ -547,19 +596,25 @@ function clearStaleSwap( function applyBlankTabState(options: { cache: Map targetPath: string + content: string editor: ReturnType suppressChangeRef: MutableRefObject }) { const { cache, targetPath, + content, editor, suppressChangeRef, } = options - cache.set(targetPath, { blocks: blankParagraphBlocks(), scrollTop: 0 }) + cacheEditorState(cache, targetPath, { + blocks: blankParagraphBlocks(), + scrollTop: 0, + sourceContent: content, + }) applyBlankStateToEditor(editor, suppressChangeRef) - signalTabSwap(targetPath) + signalTabSwap({ path: targetPath }) } function scheduleEmptyHeadingSwap(options: { @@ -577,13 +632,13 @@ function scheduleEmptyHeadingSwap(options: { suppressChangeRef, } = options - if (extractBodyRemainderAfterEmptyH1(content) === null) return false + if (extractBodyRemainderAfterEmptyH1({ content }) === null) return false void resolveEmptyHeadingHtml(editor, content) .then((html) => { if (prevActivePathRef.current !== targetPath || !html) return applyHtmlStateToEditor(editor, html, suppressChangeRef) - signalTabSwap(targetPath) + signalTabSwap({ path: targetPath }) }) .catch((err: unknown) => { suppressChangeRef.current = false @@ -614,7 +669,7 @@ function scheduleParsedBlockSwap(options: { .then(({ blocks, scrollTop }) => { if (prevActivePathRef.current !== targetPath) return applyBlocksToEditor(editor, blocks, scrollTop, suppressChangeRef) - signalTabSwap(targetPath) + signalTabSwap({ path: targetPath }) }) .catch((err: unknown) => { suppressChangeRef.current = false @@ -646,11 +701,17 @@ function scheduleTabSwap(options: { suppressChangeRef.current = true const doSwap = () => { - if (clearStaleSwap(targetPath, prevActivePathRef, suppressChangeRef)) return + if (clearStaleSwap({ targetPath, prevActivePathRef, suppressChangeRef })) return rawSwapPendingRef.current = false - if (isBlankBodyContent(activeTab.content)) { - applyBlankTabState({ cache, targetPath, editor, suppressChangeRef }) + if (isBlankBodyContent({ content: activeTab.content })) { + applyBlankTabState({ + cache, + targetPath, + content: activeTab.content, + editor, + suppressChangeRef, + }) return } @@ -681,6 +742,75 @@ function scheduleTabSwap(options: { pendingSwapRef.current = doSwap } +function resolveTabSwapState(options: { + tabs: Tab[] + activeTabPath: string | null + tabCacheRef: MutableRefObject> + prevActivePathRef: MutableRefObject + rawModeJustEnded: boolean +}): TabSwapState { + const { + tabs, + activeTabPath, + tabCacheRef, + prevActivePathRef, + rawModeJustEnded, + } = options + + const prevPath = prevActivePathRef.current + return { + cache: tabCacheRef.current, + prevPath, + pathChanged: prevPath !== activeTabPath, + activeTab: findActiveTab({ tabs, activeTabPath }), + previousTab: findActiveTab({ tabs, activeTabPath: prevPath }), + rawModeJustEnded, + } +} + +function shouldSkipScheduledTabSwap(options: { + state: TabSwapState + activeTabPath: string | null + editor: ReturnType + editorMountedRef: MutableRefObject + prevActivePathRef: MutableRefObject + rawSwapPendingRef: MutableRefObject +}) { + const { + state, + activeTabPath, + editor, + editorMountedRef, + prevActivePathRef, + rawSwapPendingRef, + } = options + + if (syncActivePathTransition({ + prevPath: state.prevPath, + pathChanged: state.pathChanged, + activeTabPath, + activeTab: state.activeTab, + previousTab: state.previousTab, + cache: state.cache, + editor, + editorMountedRef, + prevActivePathRef, + })) { + return true + } + + return handleStableActivePath({ + pathChanged: state.pathChanged, + rawModeJustEnded: state.rawModeJustEnded, + activeTabPath, + activeTab: state.activeTab, + cache: state.cache, + editor, + editorMountedRef, + rawSwapPendingRef, + }) +} + function runTabSwapEffect(options: { tabs: Tab[] activeTabPath: string | null @@ -708,46 +838,34 @@ function runTabSwapEffect(options: { suppressChangeRef, } = options - const cache = tabCacheRef.current - const prevPath = prevActivePathRef.current - const pathChanged = prevPath !== activeTabPath - const activeTab = findActiveTab(tabs, activeTabPath) const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode) - if (rawMode) return - if (syncActivePathTransition({ - prevPath, - pathChanged, + const state = resolveTabSwapState({ + tabs, + activeTabPath, + tabCacheRef, + prevActivePathRef, + rawModeJustEnded, + }) + + if (shouldSkipScheduledTabSwap({ + state, activeTabPath, - activeTab, - cache, editor, editorMountedRef, prevActivePathRef, - })) { - return - } - - if (handleStableActivePath({ - pathChanged, - rawModeJustEnded, - activeTabPath, - activeTab, - cache, - editor, - editorMountedRef, rawSwapPendingRef, })) { return } - if (!activeTabPath || !activeTab) return + if (!activeTabPath || !state.activeTab) return scheduleTabSwap({ editor, - cache, + cache: state.cache, targetPath: activeTabPath, - activeTab, + activeTab: state.activeTab, pendingSwapRef, prevActivePathRef, rawSwapPendingRef, @@ -811,23 +929,6 @@ function useTabSwapEffect(options: { ]) } -function useTabCacheCleanup( - tabs: Tab[], - tabCacheRef: MutableRefObject>, -) { - const tabPathsRef = useRef>(new Set()) - - useEffect(() => { - const currentPaths = new Set(tabs.map(t => t.entry.path)) - for (const path of tabPathsRef.current) { - if (!currentPaths.has(path)) { - tabCacheRef.current.delete(path) - } - } - tabPathsRef.current = currentPaths - }, [tabs, tabCacheRef]) -} - /** * Manages the tab content-swap machinery for the BlockNote editor. * @@ -871,7 +972,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, rawSwapPendingRef, suppressChangeRef, }) - useTabCacheCleanup(tabs, tabCacheRef) return { handleEditorChange, editorMountedRef } } diff --git a/src/hooks/useTabManagement.test.ts b/src/hooks/useTabManagement.test.ts index c0314dae..c9d11a0f 100644 --- a/src/hooks/useTabManagement.test.ts +++ b/src/hooks/useTabManagement.test.ts @@ -266,6 +266,20 @@ describe('useTabManagement (single-note model)', () => { expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1) }) + it('activates a warmed note immediately while reusing the cached content', async () => { + cacheNoteContent('/vault/note/warm.md', '# Warm content') + + const { result } = renderHook(() => useTabManagement()) + + act(() => { + void result.current.handleSelectNote(makeEntry({ path: '/vault/note/warm.md', title: 'Warm' })) + }) + + expect(result.current.activeTabPath).toBe('/vault/note/warm.md') + expect(result.current.tabs).toHaveLength(1) + expect(result.current.tabs[0].content).toBe('# Warm content') + }) + it('reuses cached content when reopening a recently loaded note', async () => { const { mockInvoke } = await import('../mock-tauri') vi.mocked(mockInvoke) diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index 604e63f5..fa6c05db 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -14,14 +14,15 @@ type NotePath = VaultEntry['path'] // Stores in-flight or recently loaded note content promises, keyed by path. // Cleared on vault reload to prevent stale content after external edits. // Latency profile: deduplicates rapid note switches and keeps revisits instant. -const prefetchCache = new Map>() -const NOTE_CONTENT_CACHE_LIMIT = 48 - interface NoteContentCacheEntry { path: NotePath promise: Promise + value: string | null } +const prefetchCache = new Map() +const NOTE_CONTENT_CACHE_LIMIT = 48 + function trimPrefetchCache(): void { while (prefetchCache.size > NOTE_CONTENT_CACHE_LIMIT) { const oldestPath = prefetchCache.keys().next().value @@ -30,23 +31,35 @@ function trimPrefetchCache(): void { } } -function rememberNoteContent({ path, promise }: NoteContentCacheEntry): Promise { +function rememberNoteContent(entry: NoteContentCacheEntry): NoteContentCacheEntry { + const { path } = entry if (prefetchCache.has(path)) prefetchCache.delete(path) - prefetchCache.set(path, promise) + prefetchCache.set(path, entry) trimPrefetchCache() - return promise + return entry } -function requestNoteContent({ path }: Pick): Promise { +function requestNoteContent({ path }: Pick): NoteContentCacheEntry { + const cacheEntry: NoteContentCacheEntry = { + path, + promise: Promise.resolve(''), + value: null, + } const promise = (isTauri() ? invoke('get_note_content', { path }) : mockInvoke('get_note_content', { path }) - ).catch((err) => { - prefetchCache.delete(path) - throw err - }) + ) + .then((content) => { + cacheEntry.value = content + return content + }) + .catch((err) => { + prefetchCache.delete(path) + throw err + }) - return rememberNoteContent({ path, promise }) + cacheEntry.promise = promise + return rememberNoteContent(cacheEntry) } /** Prefetch a note's content into the in-memory cache. @@ -58,7 +71,11 @@ export function prefetchNoteContent(path: string): void { } export function cacheNoteContent(path: string, content: string): void { - rememberNoteContent({ path, promise: Promise.resolve(content) }) + rememberNoteContent({ + path, + promise: Promise.resolve(content), + value: content, + }) } /** Clear the prefetch cache. Call on vault reload to prevent stale content. */ @@ -66,8 +83,12 @@ export function clearPrefetchCache(): void { prefetchCache.clear() } +function getCachedNoteContent(path: string): string | null { + return prefetchCache.get(path)?.value ?? null +} + async function loadNoteContent(path: string): Promise { - return prefetchCache.get(path) ?? requestNoteContent({ path }) + return prefetchCache.get(path)?.promise ?? requestNoteContent({ path }).promise } export type { Tab } @@ -102,6 +123,76 @@ function isAlreadyViewingPath( return activeTabPathRef.current === path || tabsRef.current.some((tab) => tab.entry.path === path) } +function startEntryNavigation(options: { + entry: VaultEntry + navSeqRef: React.MutableRefObject + tabsRef: React.MutableRefObject + activeTabPathRef: React.MutableRefObject + setTabs: React.Dispatch> + setActiveTabPath: React.Dispatch> +}) { + const { + entry, + navSeqRef, + tabsRef, + activeTabPathRef, + setTabs, + setActiveTabPath, + } = options + + const seq = ++navSeqRef.current + const cachedContent = getCachedNoteContent(entry.path) + syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path) + if (cachedContent !== null) { + setSingleTab(tabsRef, setTabs, { entry, content: cachedContent }) + } + + return { seq, cachedContent } +} + +function shouldApplyLoadedEntry(options: { + seq: number + navSeqRef: React.MutableRefObject + cachedContent: string | null + content: string + activeTabPathRef: React.MutableRefObject + path: string +}) { + const { + seq, + navSeqRef, + cachedContent, + content, + activeTabPathRef, + path, + } = options + + if (navSeqRef.current !== seq) return false + return cachedContent !== content || activeTabPathRef.current !== path +} + +function handleEntryLoadFailure(options: { + entry: VaultEntry + seq: number + navSeqRef: React.MutableRefObject + tabsRef: React.MutableRefObject + setTabs: React.Dispatch> + error: unknown +}) { + const { + entry, + seq, + navSeqRef, + tabsRef, + setTabs, + error, + } = options + + console.warn('Failed to load note content:', error) + if (navSeqRef.current !== seq) return + setSingleTab(tabsRef, setTabs, { entry, content: '' }) +} + async function navigateToEntry(options: { entry: VaultEntry navSeqRef: React.MutableRefObject @@ -125,17 +216,35 @@ async function navigateToEntry(options: { return } - const seq = ++navSeqRef.current - syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path) + const { seq, cachedContent } = startEntryNavigation({ + entry, + navSeqRef, + tabsRef, + activeTabPathRef, + setTabs, + setActiveTabPath, + }) try { const content = await loadNoteContent(entry.path) - if (navSeqRef.current !== seq) return + if (!shouldApplyLoadedEntry({ + seq, + navSeqRef, + cachedContent, + content, + activeTabPathRef, + path: entry.path, + })) return setSingleTab(tabsRef, setTabs, { entry, content }) } catch (err) { - console.warn('Failed to load note content:', err) - if (navSeqRef.current !== seq) return - setSingleTab(tabsRef, setTabs, { entry, content: '' }) + handleEntryLoadFailure({ + entry, + seq, + navSeqRef, + tabsRef, + setTabs, + error: err, + }) } }