From e18491bcd33f68434103e56ec3920113eddbc0e5 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 18 Apr 2026 12:17:24 +0200 Subject: [PATCH] fix: preserve untitled rename session continuity --- src/hooks/useEditorTabSwap.rename.test.ts | 43 ++++ src/hooks/useEditorTabSwap.ts | 231 ++++++++++++++-------- 2 files changed, 194 insertions(+), 80 deletions(-) diff --git a/src/hooks/useEditorTabSwap.rename.test.ts b/src/hooks/useEditorTabSwap.rename.test.ts index 04e131e7..3974b04f 100644 --- a/src/hooks/useEditorTabSwap.rename.test.ts +++ b/src/hooks/useEditorTabSwap.rename.test.ts @@ -67,6 +67,7 @@ describe('useEditorTabSwap untitled rename continuity', () => { await act(() => new Promise(r => setTimeout(r, 0))) expect(editor.replaceBlocks).not.toHaveBeenCalled() + expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled() act(() => { result.current.handleEditorChange() @@ -104,4 +105,46 @@ describe('useEditorTabSwap untitled rename continuity', () => { expect(editor.tryParseMarkdownToBlocks).toHaveBeenCalled() }) + + it('keeps the live editor session when the renamed tab arrives one render after the path switch', async () => { + vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) + + const editor = makeMockEditor('# Fresh Title\n\nBody typed live') + const onContentChange = vi.fn() + const untitledTab = makeTab('untitled-note-123.md', 'Untitled Note 123', 'Body') + const renamedTab = makeTab('fresh-title.md', 'Fresh Title', 'Body') + + const { result, rerender } = renderHook( + ({ tabs, activeTabPath }) => useEditorTabSwap({ + tabs, + activeTabPath, + editor: editor as never, + onContentChange, + }), + { initialProps: { tabs: [untitledTab], activeTabPath: untitledTab.entry.path } }, + ) + + await act(() => new Promise(r => setTimeout(r, 0))) + editor.replaceBlocks.mockClear() + editor.tryParseMarkdownToBlocks.mockClear() + + rerender({ tabs: [untitledTab], activeTabPath: renamedTab.entry.path }) + await act(() => new Promise(r => setTimeout(r, 0))) + + rerender({ tabs: [renamedTab], activeTabPath: renamedTab.entry.path }) + await act(() => new Promise(r => setTimeout(r, 0))) + + expect(editor.replaceBlocks).not.toHaveBeenCalled() + expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled() + + act(() => { + result.current.handleEditorChange() + }) + + expect(onContentChange).toHaveBeenCalledWith( + 'fresh-title.md', + expect.stringContaining('Body typed live'), + ) + }) }) diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index a1e047ce..7dac35c7 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -35,23 +35,36 @@ export function extractEditorBody(rawFileContent: string): string { return rawBody.trimStart() } -/** Extract H1 text from the editor's first block, or null if not an H1. */ -export function getH1TextFromBlocks(blocks: unknown[]): string | null { +type HeadingTextInline = { type?: string; text?: string } + +function extractH1Content(blocks: unknown[]): HeadingTextInline[] | null { const first = blocks?.[0] as { type?: string props?: { level?: number } - content?: Array<{ type?: string; text?: string }> + content?: HeadingTextInline[] } | undefined - const content = first?.type === 'heading' && first.props?.level === 1 && Array.isArray(first.content) - ? first.content - : null + + if (!first) return null + if (first.type !== 'heading') return null + if (first.props?.level !== 1) return null + if (!Array.isArray(first.content)) return null + return first.content +} + +/** Extract H1 text from the editor's first block, or null if not an H1. */ +export function getH1TextFromBlocks(blocks: unknown[]): string | null { + const content = extractH1Content(blocks) if (!content) return null - const text = content - .filter(item => item.type === 'text') - .map(item => item.text || '') - .join('') - return text.trim() || null + let text = '' + for (const item of content) { + if (item.type === 'text') { + text += item.text || '' + } + } + + const trimmed = text.trim() + return trimmed || null } /** Replace the title: line in YAML frontmatter with a new title value. */ @@ -274,9 +287,11 @@ function normalizeTabBody(content: string): string { } function renameBodiesOverlap(currentBody: string, nextBody: string): boolean { - return currentBody === nextBody - || currentBody.startsWith(nextBody) - || nextBody.startsWith(currentBody) + const current = currentBody.trimEnd() + const next = nextBody.trimEnd() + return current === next + || current.startsWith(next) + || next.startsWith(current) } function isUntitledRenameTransition( @@ -377,21 +392,52 @@ function cachePreviousTabOnPathChange(options: { cacheEditorState(cache, prevPath, editor.document) } -function rememberPendingTabArrival( +function shouldWaitForActiveTab( + pathChanged: boolean, activeTabPath: string | null, activeTab: Tab | undefined, - pendingTabArrivalPathRef: MutableRefObject, ) { - if (!activeTabPath) { - pendingTabArrivalPathRef.current = null + return pathChanged && !!activeTabPath && !activeTab +} + +function syncActivePathTransition(options: { + prevPath: string | null + pathChanged: boolean + activeTabPath: string | null + activeTab: Tab | undefined + cache: Map + editor: ReturnType + editorMountedRef: MutableRefObject + prevActivePathRef: MutableRefObject +}) { + const { + prevPath, + pathChanged, + activeTabPath, + activeTab, + cache, + editor, + editorMountedRef, + prevActivePathRef, + } = options + + cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor }) + if (shouldWaitForActiveTab(pathChanged, activeTabPath, activeTab)) return true + + if (!preserveUntitledRenameState({ + prevPath, + activeTabPath, + activeTab, + cache, + editor, + editorMountedRef, + })) { + prevActivePathRef.current = activeTabPath return false } - if (activeTab) { - pendingTabArrivalPathRef.current = null - return true - } - pendingTabArrivalPathRef.current = activeTabPath - return false + + prevActivePathRef.current = activeTabPath + return true } function handleStableActivePath(options: { @@ -399,7 +445,6 @@ function handleStableActivePath(options: { rawModeJustEnded: boolean activeTabPath: string | null activeTab: Tab | undefined - pendingTabArrival: boolean cache: Map editor: ReturnType editorMountedRef: MutableRefObject @@ -410,7 +455,6 @@ function handleStableActivePath(options: { rawModeJustEnded, activeTabPath, activeTab, - pendingTabArrival, cache, editor, editorMountedRef, @@ -423,7 +467,6 @@ function handleStableActivePath(options: { rawSwapPendingRef.current = true return false } - if (pendingTabArrival) return false if (rawSwapPendingRef.current) return true cacheStableActivePath({ @@ -638,7 +681,81 @@ function scheduleTabSwap(options: { pendingSwapRef.current = doSwap } -function useTabSwapEffect(options: { +function runTabSwapEffect(options: { + tabs: Tab[] + activeTabPath: string | null + editor: ReturnType + rawMode?: boolean + tabCacheRef: MutableRefObject> + prevActivePathRef: MutableRefObject + editorMountedRef: MutableRefObject + pendingSwapRef: MutableRefObject<(() => void) | null> + prevRawModeRef: MutableRefObject + rawSwapPendingRef: MutableRefObject + suppressChangeRef: MutableRefObject +}) { + const { + tabs, + activeTabPath, + editor, + rawMode, + tabCacheRef, + prevActivePathRef, + editorMountedRef, + pendingSwapRef, + prevRawModeRef, + rawSwapPendingRef, + 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, + activeTabPath, + activeTab, + cache, + editor, + editorMountedRef, + prevActivePathRef, + })) { + return + } + + if (handleStableActivePath({ + pathChanged, + rawModeJustEnded, + activeTabPath, + activeTab, + cache, + editor, + editorMountedRef, + rawSwapPendingRef, + })) { + return + } + + if (!activeTabPath || !activeTab) return + + scheduleTabSwap({ + editor, + cache, + targetPath: activeTabPath, + activeTab, + pendingSwapRef, + prevActivePathRef, + rawSwapPendingRef, + suppressChangeRef, + }) +} + +function useTabSwapEffect(options: { tabs: Tab[] activeTabPath: string | null editor: ReturnType @@ -647,7 +764,6 @@ function useTabSwapEffect(options: { prevActivePathRef: MutableRefObject editorMountedRef: MutableRefObject pendingSwapRef: MutableRefObject<(() => void) | null> - pendingTabArrivalPathRef: MutableRefObject prevRawModeRef: MutableRefObject rawSwapPendingRef: MutableRefObject suppressChangeRef: MutableRefObject @@ -661,64 +777,22 @@ function useTabSwapEffect(options: { 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 (preserveUntitledRenameState({ - prevPath, + runTabSwapEffect({ + tabs, activeTabPath, - activeTab, - cache, editor, + rawMode, + tabCacheRef, editorMountedRef, - })) { - return - } - - if (handleStableActivePath({ - pathChanged, - rawModeJustEnded, - activeTabPath, - activeTab, - pendingTabArrival, - cache, - editor, - editorMountedRef, - rawSwapPendingRef, - })) { - return - } - - if (!rememberPendingTabArrival(activeTabPath, activeTab, pendingTabArrivalPathRef)) { - return - } - const targetPath = activeTabPath - const readyActiveTab = activeTab - if (!targetPath || !readyActiveTab) return - - scheduleTabSwap({ - editor, - cache, - targetPath, - activeTab: readyActiveTab, - pendingSwapRef, prevActivePathRef, + pendingSwapRef, + prevRawModeRef, rawSwapPendingRef, suppressChangeRef, }) @@ -727,7 +801,6 @@ function useTabSwapEffect(options: { editor, editorMountedRef, pendingSwapRef, - pendingTabArrivalPathRef, prevActivePathRef, prevRawModeRef, rawMode, @@ -771,7 +844,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, const prevActivePathRef = useRef(null) const editorMountedRef = useRef(false) const pendingSwapRef = useRef<(() => void) | null>(null) - const pendingTabArrivalPathRef = useRef(null) const prevRawModeRef = useRef(!!rawMode) const rawSwapPendingRef = useRef(false) const suppressChangeRef = useRef(false) @@ -795,7 +867,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, prevActivePathRef, editorMountedRef, pendingSwapRef, - pendingTabArrivalPathRef, prevRawModeRef, rawSwapPendingRef, suppressChangeRef,