From 4634949f71c1bb2d746bb7fb07f41afa21945fe7 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 9 Apr 2026 12:36:09 +0200 Subject: [PATCH] fix: swap note content after pending navigation --- src/hooks/useEditorTabSwap.test.ts | 35 ++ src/hooks/useEditorTabSwap.ts | 661 +++++++++++++++++++---------- 2 files changed, 461 insertions(+), 235 deletions(-) diff --git a/src/hooks/useEditorTabSwap.test.ts b/src/hooks/useEditorTabSwap.test.ts index 83d19384..0ca766c1 100644 --- a/src/hooks/useEditorTabSwap.test.ts +++ b/src/hooks/useEditorTabSwap.test.ts @@ -185,6 +185,41 @@ function makeMockEditor(docRef: { current: unknown[] }) { 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))) + mockEditor.tryParseMarkdownToBlocks.mockClear() + mockEditor.replaceBlocks.mockClear() + + rerender({ tabs: [tabA], activeTabPath: 'b.md', rawMode: false }) + await act(() => new Promise(r => setTimeout(r, 0))) + expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled() + + rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false }) + await act(() => new Promise(r => setTimeout(r, 0))) + + expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith( + expect.stringContaining('March 2024'), + ) + expect(mockEditor.replaceBlocks).toHaveBeenCalled() + }) + 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 }) diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index 998c758f..84b77761 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' import type { useCreateBlockNote } from '@blocknote/react' import type { VaultEntry } from '../types' import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks } from '../utils/wikilinks' @@ -9,6 +9,10 @@ interface Tab { content: string } +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays +type EditorBlocks = any[] +type CachedTabState = { blocks: EditorBlocks; scrollTop: number } + interface UseEditorTabSwapOptions { tabs: Tab[] activeTabPath: string | null @@ -27,15 +31,17 @@ export function extractEditorBody(rawFileContent: string): string { /** Extract H1 text from the editor's first block, or null if not an H1. */ export function getH1TextFromBlocks(blocks: unknown[]): string | null { - if (!blocks?.length) return null - const first = blocks[0] as { + const first = blocks?.[0] as { type?: string props?: { level?: number } content?: Array<{ type?: string; text?: string }> - } - if (first.type !== 'heading' || first.props?.level !== 1) return null - if (!Array.isArray(first.content)) return null - const text = first.content + } | undefined + const content = first?.type === 'heading' && first.props?.level === 1 && Array.isArray(first.content) + ? first.content + : null + if (!content) return null + + const text = content .filter(item => item.type === 'text') .map(item => item.text || '') .join('') @@ -47,6 +53,392 @@ export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string) return frontmatter.replace(/^(title:\s*).+$/m, `$1${newTitle}`) } +function readEditorScrollTop(): number { + const scrollEl = document.querySelector('.editor__blocknote-container') + return scrollEl?.scrollTop ?? 0 +} + +function cacheEditorState( + cache: Map, + path: string, + blocks: EditorBlocks, +) { + cache.set(path, { + blocks, + scrollTop: readEditorScrollTop(), + }) +} + +function buildFastPathBlocks(preprocessed: string): EditorBlocks | null { + if (!preprocessed.trim()) { + return [{ type: 'paragraph', content: [] }] + } + + const h1OnlyMatch = preprocessed.trim().match(/^# (.+)$/) + if (!h1OnlyMatch) return null + + return [ + { type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [{ type: 'text', text: h1OnlyMatch[1], styles: {} }], children: [] }, + { type: 'paragraph', content: [], children: [] }, + ] +} + +async function parseMarkdownBlocks( + editor: ReturnType, + preprocessed: string, +): Promise { + const result = editor.tryParseMarkdownToBlocks(preprocessed) + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tryParseMarkdownToBlocks returns sync or async BlockNote blocks + if (result && typeof (result as any).then === 'function') { + return (result as unknown as Promise) + } + return result as EditorBlocks +} + +async function resolveBlocksForTarget( + editor: ReturnType, + cache: Map, + targetPath: string, + content: string, +): Promise { + const cached = cache.get(targetPath) + if (cached) return cached + + const body = extractEditorBody(content) + const preprocessed = preProcessWikilinks(body) + const fastPathBlocks = buildFastPathBlocks(preprocessed) + if (fastPathBlocks) { + const nextState = { blocks: fastPathBlocks, scrollTop: 0 } + cache.set(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 } +} + +function applyBlocksToEditor( + editor: ReturnType, + blocks: EditorBlocks, + scrollTop: number, + suppressChangeRef: MutableRefObject, +) { + suppressChangeRef.current = true + try { + const current = editor.document + if (current.length > 0 && blocks.length > 0) { + editor.replaceBlocks(current, blocks) + } else if (blocks.length > 0) { + editor.insertBlocks(blocks, current[0], 'before') + } + } catch (err) { + console.error('applyBlocks failed, trying fallback:', err) + try { + const html = editor.blocksToHTMLLossy(blocks) + editor._tiptapEditor.commands.setContent(html) + } catch (err2) { + console.error('Fallback also failed:', err2) + } + } finally { + queueMicrotask(() => { suppressChangeRef.current = false }) + } + + requestAnimationFrame(() => { + const scrollEl = document.querySelector('.editor__blocknote-container') + if (scrollEl) scrollEl.scrollTop = scrollTop + }) +} + +function findActiveTab(tabs: Tab[], activeTabPath: string | null): Tab | undefined { + return activeTabPath + ? tabs.find(tab => tab.entry.path === activeTabPath) + : undefined +} + +function useLatestRef(value: T): MutableRefObject { + const ref = useRef(value) + useEffect(() => { + ref.current = value + }, [value]) + return ref +} + +function useEditorMountState( + editor: ReturnType, + editorMountedRef: MutableRefObject, + pendingSwapRef: MutableRefObject<(() => void) | null>, +) { + useEffect(() => { + if (editor.prosemirrorView) { + editorMountedRef.current = true + } + const cleanup = editor.onMount(() => { + editorMountedRef.current = true + if (pendingSwapRef.current) { + const swap = pendingSwapRef.current + pendingSwapRef.current = null + queueMicrotask(swap) + } + }) + return cleanup + }, [editor, editorMountedRef, pendingSwapRef]) +} + +function useEditorChangeHandler(options: { + editor: ReturnType + tabsRef: MutableRefObject + onContentChangeRef: MutableRefObject<((path: string, content: string) => void) | undefined> + prevActivePathRef: MutableRefObject + suppressChangeRef: MutableRefObject +}) { + const { + editor, + tabsRef, + onContentChangeRef, + prevActivePathRef, + suppressChangeRef, + } = options + + return useCallback(() => { + if (suppressChangeRef.current) return + const path = prevActivePathRef.current + if (!path) return + + const tab = tabsRef.current.find(t => t.entry.path === path) + if (!tab) return + + const blocks = editor.document + const restored = restoreWikilinksInBlocks(blocks) + const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks)) + const [frontmatter] = splitFrontmatter(tab.content) + onContentChangeRef.current?.(path, `${frontmatter}${bodyMarkdown}`) + }, [editor, onContentChangeRef, prevActivePathRef, suppressChangeRef, tabsRef]) +} + +function consumeRawModeTransition( + prevRawModeRef: MutableRefObject, + rawMode: boolean | undefined, +) { + const rawModeJustEnded = prevRawModeRef.current && !rawMode + prevRawModeRef.current = !!rawMode + return rawModeJustEnded +} + +function cachePreviousTabOnPathChange(options: { + prevPath: string | null + 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) +} + +function rememberPendingTabArrival( + activeTabPath: string | null, + activeTab: Tab | undefined, + pendingTabArrivalPathRef: MutableRefObject, +) { + if (!activeTabPath) { + pendingTabArrivalPathRef.current = null + return false + } + if (activeTab) { + pendingTabArrivalPathRef.current = null + return true + } + pendingTabArrivalPathRef.current = activeTabPath + return false +} + +function handleStableActivePath(options: { + pathChanged: boolean + rawModeJustEnded: boolean + activeTabPath: string | null + activeTab: Tab | undefined + pendingTabArrival: boolean + cache: Map + editor: ReturnType + editorMountedRef: MutableRefObject + rawSwapPendingRef: MutableRefObject +}) { + const { + pathChanged, + rawModeJustEnded, + activeTabPath, + activeTab, + pendingTabArrival, + cache, + editor, + editorMountedRef, + rawSwapPendingRef, + } = options + + if (pathChanged) return false + if (rawModeJustEnded && activeTabPath) { + cache.delete(activeTabPath) + rawSwapPendingRef.current = true + return false + } + if (pendingTabArrival) return false + if (rawSwapPendingRef.current) return true + + if (activeTabPath && activeTab && editorMountedRef.current) { + cacheEditorState(cache, activeTabPath, editor.document) + } + return true +} + +function scheduleTabSwap(options: { + editor: ReturnType + cache: Map + targetPath: string + activeTab: Tab + pendingSwapRef: MutableRefObject<(() => void) | null> + prevActivePathRef: MutableRefObject + rawSwapPendingRef: MutableRefObject + suppressChangeRef: MutableRefObject +}) { + const { + editor, + cache, + targetPath, + activeTab, + pendingSwapRef, + prevActivePathRef, + rawSwapPendingRef, + suppressChangeRef, + } = options + + const doSwap = () => { + if (prevActivePathRef.current !== targetPath) return + rawSwapPendingRef.current = false + void resolveBlocksForTarget(editor, cache, targetPath, activeTab.content) + .then(({ blocks, scrollTop }) => { + if (prevActivePathRef.current !== targetPath) return + applyBlocksToEditor(editor, blocks, scrollTop, suppressChangeRef) + }) + .catch((err: unknown) => { + console.error('Failed to parse/swap editor content:', err) + }) + } + + if (editor.prosemirrorView) { + queueMicrotask(doSwap) + return + } + pendingSwapRef.current = doSwap +} + +function useTabSwapEffect(options: { + tabs: Tab[] + activeTabPath: string | null + editor: ReturnType + rawMode?: boolean + tabCacheRef: MutableRefObject> + prevActivePathRef: MutableRefObject + editorMountedRef: MutableRefObject + pendingSwapRef: MutableRefObject<(() => void) | null> + pendingTabArrivalPathRef: MutableRefObject + prevRawModeRef: MutableRefObject + rawSwapPendingRef: MutableRefObject + suppressChangeRef: MutableRefObject +}) { + const { + tabs, + activeTabPath, + editor, + rawMode, + tabCacheRef, + prevActivePathRef, + editorMountedRef, + pendingSwapRef, + pendingTabArrivalPathRef, + prevRawModeRef, + rawSwapPendingRef, + suppressChangeRef, + } = options + + useEffect(() => { + const cache = tabCacheRef.current + const prevPath = prevActivePathRef.current + const pathChanged = prevPath !== activeTabPath + const activeTab = findActiveTab(tabs, activeTabPath) + const pendingTabArrival = activeTabPath !== null + && pendingTabArrivalPathRef.current === activeTabPath + const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode) + + if (rawMode) return + cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor }) + prevActivePathRef.current = activeTabPath + + if (handleStableActivePath({ + pathChanged, + rawModeJustEnded, + activeTabPath, + activeTab, + pendingTabArrival, + cache, + editor, + editorMountedRef, + rawSwapPendingRef, + })) { + return + } + + if (!rememberPendingTabArrival(activeTabPath, activeTab, pendingTabArrivalPathRef)) { + return + } + + scheduleTabSwap({ + editor, + cache, + targetPath: activeTabPath, + activeTab: activeTab!, + pendingSwapRef, + prevActivePathRef, + rawSwapPendingRef, + suppressChangeRef, + }) + }, [ + activeTabPath, + editor, + editorMountedRef, + pendingSwapRef, + pendingTabArrivalPathRef, + prevActivePathRef, + prevRawModeRef, + rawMode, + rawSwapPendingRef, + suppressChangeRef, + tabCacheRef, + tabs, + ]) +} + +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. * @@ -59,241 +451,40 @@ export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string) * Returns `handleEditorChange`, the onChange callback for SingleEditorView. */ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, rawMode }: UseEditorTabSwapOptions) { - // Cache parsed blocks + scroll position per tab path for instant switching - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays - const tabCacheRef = useRef>(new Map()) + const tabCacheRef = useRef>(new Map()) const prevActivePathRef = useRef(null) const editorMountedRef = useRef(false) const pendingSwapRef = useRef<(() => void) | null>(null) + const pendingTabArrivalPathRef = useRef(null) const prevRawModeRef = useRef(!!rawMode) - // Guard: prevents a subsequent effect run from re-caching stale blocks - // while a raw-mode swap is still pending in a microtask/pendingSwap. const rawSwapPendingRef = useRef(false) - - // Suppress onChange during programmatic content swaps (tab switching / initial load) const suppressChangeRef = useRef(false) + const onContentChangeRef = useLatestRef(onContentChange) + const tabsRef = useLatestRef(tabs) + const handleEditorChange = useEditorChangeHandler({ + editor, + tabsRef, + onContentChangeRef, + prevActivePathRef, + suppressChangeRef, + }) - // Keep refs to callbacks for the onChange handler - const onContentChangeRef = useRef(onContentChange) - onContentChangeRef.current = onContentChange - const tabsRef = useRef(tabs) - tabsRef.current = tabs - - // Track editor mount state - useEffect(() => { - // Check if already mounted (prosemirrorView exists) - if (editor.prosemirrorView) { - editorMountedRef.current = true - } - const cleanup = editor.onMount(() => { - editorMountedRef.current = true - // Execute any pending content swap that was queued before mount. - // Defer via queueMicrotask so BlockNote's internal flushSync calls - // don't collide with React's commit phase. - if (pendingSwapRef.current) { - const swap = pendingSwapRef.current - pendingSwapRef.current = null - queueMicrotask(swap) - } - }) - return cleanup - }, [editor]) - - // onChange handler: serialize editor blocks → markdown, reconstruct full file, call save - const handleEditorChange = useCallback(() => { - if (suppressChangeRef.current) return - const path = prevActivePathRef.current - if (!path) return - - const tab = tabsRef.current.find(t => t.entry.path === path) - if (!tab) return - - // Convert blocks → markdown, restoring wikilinks first - const blocks = editor.document - const restored = restoreWikilinksInBlocks(blocks) - const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks)) - - // Reconstruct full file: frontmatter + body (which now includes H1 if present) - const [frontmatter] = splitFrontmatter(tab.content) - const fullContent = `${frontmatter}${bodyMarkdown}` - - onContentChangeRef.current?.(path, fullContent) - }, [editor]) - - // Swap document content when active tab changes. - // Uses queueMicrotask to defer BlockNote mutations outside React's commit phase, - // avoiding flushSync-inside-lifecycle errors that silently prevent content from rendering. - useEffect(() => { - const cache = tabCacheRef.current - const prevPath = prevActivePathRef.current - const pathChanged = prevPath !== activeTabPath - - // Detect raw mode transition: true → false means we need to re-parse - // from tab.content since the cached blocks are stale. - const rawModeJustEnded = prevRawModeRef.current && !rawMode - prevRawModeRef.current = !!rawMode - - // While raw mode is active the BlockNote editor is hidden — skip all - // swap logic to avoid touching the invisible editor. - if (rawMode) return - - // Save current editor state + scroll position for the tab we're leaving - if (prevPath && pathChanged && editorMountedRef.current) { - const scrollEl = document.querySelector('.editor__blocknote-container') - cache.set(prevPath, { - blocks: editor.document, - scrollTop: scrollEl?.scrollTop ?? 0, - }) - } - prevActivePathRef.current = activeTabPath - - if (!pathChanged) { - if (rawModeJustEnded && activeTabPath) { - // Raw mode just ended — invalidate stale cached blocks so we - // re-parse from the latest tab.content below. - cache.delete(activeTabPath) - rawSwapPendingRef.current = true - } else { - // While a raw-mode swap is pending (scheduled via microtask), a second - // effect run can fire due to the tabs prop updating. Skip re-caching - // stale editor.document to avoid poisoning the cache before doSwap runs. - if (rawSwapPendingRef.current) return - - // When tab content updates but the active tab stays the same (e.g. after - // Cmd+S save), refresh the cache with the current editor blocks so a later - // tab switch doesn't revert to stale content. Do NOT re-apply blocks — - // the editor already shows the user's edits. - if (activeTabPath && editorMountedRef.current) { - const scrollEl = document.querySelector('.editor__blocknote-container') - cache.set(activeTabPath, { - blocks: editor.document, - scrollTop: scrollEl?.scrollTop ?? 0, - }) - } - return - } - } - - if (!activeTabPath) return - - const tab = tabs.find(t => t.entry.path === activeTabPath) - if (!tab) return - - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote's PartialBlock generic is extremely complex - const applyBlocks = (blocks: any[], scrollTop = 0) => { - suppressChangeRef.current = true - try { - const current = editor.document - if (current.length > 0 && blocks.length > 0) { - editor.replaceBlocks(current, blocks) - } else if (blocks.length > 0) { - editor.insertBlocks(blocks, current[0], 'before') - } - } catch (err) { - console.error('applyBlocks failed, trying fallback:', err) - try { - const html = editor.blocksToHTMLLossy(blocks) - editor._tiptapEditor.commands.setContent(html) - } catch (err2) { - console.error('Fallback also failed:', err2) - } - } finally { - // Re-enable change detection on next microtask, after BlockNote - // finishes its internal state updates from the content swap - queueMicrotask(() => { suppressChangeRef.current = false }) - } - // Restore scroll position after layout updates from the content swap - requestAnimationFrame(() => { - const scrollEl = document.querySelector('.editor__blocknote-container') - if (scrollEl) scrollEl.scrollTop = scrollTop - }) - } - - const targetPath = activeTabPath - - const doSwap = () => { - // Guard: bail if user switched tabs since this swap was scheduled - if (prevActivePathRef.current !== targetPath) return - rawSwapPendingRef.current = false - - if (cache.has(targetPath)) { - const cached = cache.get(targetPath)! - applyBlocks(cached.blocks, cached.scrollTop) - return - } - - const body = extractEditorBody(tab.content) - const preprocessed = preProcessWikilinks(body) - - // Fast path: empty body (e.g. newly created notes). Skip the - // potentially-async markdown parser and set a single empty paragraph - // so the editor is immediately interactive. - if (!preprocessed.trim()) { - const emptyDoc = [{ type: 'paragraph', content: [] }] - cache.set(targetPath, { blocks: emptyDoc, scrollTop: 0 }) - applyBlocks(emptyDoc) - return - } - - // Fast path: H1-only content (e.g. newly created notes that just have - // the title heading). Build blocks directly to stay instant. - const h1OnlyMatch = preprocessed.trim().match(/^# (.+)$/) - if (h1OnlyMatch) { - const h1Doc = [ - { type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [{ type: 'text', text: h1OnlyMatch[1], styles: {} }], children: [] }, - { type: 'paragraph', content: [], children: [] }, - ] - cache.set(targetPath, { blocks: h1Doc, scrollTop: 0 }) - applyBlocks(h1Doc) - return - } - - try { - const result = editor.tryParseMarkdownToBlocks(preprocessed) - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays - const handleBlocks = (blocks: any[]) => { - if (prevActivePathRef.current !== targetPath) return - const withWikilinks = injectWikilinks(blocks) - // Only cache non-empty results to avoid poisoning the cache - if (withWikilinks.length > 0) { - cache.set(targetPath, { blocks: withWikilinks, scrollTop: 0 }) - } - applyBlocks(withWikilinks) - } - /* eslint-disable @typescript-eslint/no-explicit-any -- tryParseMarkdownToBlocks returns sync or async BlockNote blocks */ - if (result && typeof (result as any).then === 'function') { - (result as unknown as Promise).then(handleBlocks).catch((err: unknown) => { - console.error('Async markdown parse failed:', err) - }) - } else { - handleBlocks(result as any[]) - } - /* eslint-enable @typescript-eslint/no-explicit-any */ - } catch (err) { - console.error('Failed to parse/swap editor content:', err) - } - } - - if (editor.prosemirrorView) { - // Defer the swap outside React's commit phase so BlockNote's internal - // flushSync calls don't collide with React's rendering lifecycle. - queueMicrotask(doSwap) - } else { - pendingSwapRef.current = doSwap - } - }, [activeTabPath, tabs, editor, rawMode]) - - // Clean up cache entries when tabs are closed - 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]) + useEditorMountState(editor, editorMountedRef, pendingSwapRef) + useTabSwapEffect({ + tabs, + activeTabPath, + editor, + rawMode, + tabCacheRef, + prevActivePathRef, + editorMountedRef, + pendingSwapRef, + pendingTabArrivalPathRef, + prevRawModeRef, + rawSwapPendingRef, + suppressChangeRef, + }) + useTabCacheCleanup(tabs, tabCacheRef) return { handleEditorChange, editorMountedRef } }