From 60dbfad1bf3cf3ec4f7244aefdcdd0e92d9459f3 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 2 Mar 2026 21:24:35 +0100 Subject: [PATCH] fix: defer H1 selection to second rAF so content swap completes first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation called selectFirstHeading() in the same rAF as editor.focus(), but the new note's content (applied via queueMicrotask inside a React effect) hadn't been swapped in yet. React's MessageChannel scheduler runs the re-render between animation frames, so the heading block didn't exist in the TipTap document when the selection ran. Fix: move selectFirstHeading() into a nested requestAnimationFrame inside doFocus(). Between rAF 1 (focus) and rAF 2 (select), all pending macrotasks — React's re-render + the queueMicrotask content swap — run to completion, guaranteeing the H1 block is in the document before we try to select it. Updated tests: add rAF mock to the timeout-path select test (which now needs it since selection is deferred via rAF); add a regression test that explicitly verifies focus in rAF1 and selection in rAF2. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/useEditorFocus.test.ts | 35 ++++++++++++++++++++++++++++++++ src/hooks/useEditorFocus.ts | 16 +++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/hooks/useEditorFocus.test.ts b/src/hooks/useEditorFocus.test.ts index 9461805f..e594394b 100644 --- a/src/hooks/useEditorFocus.test.ts +++ b/src/hooks/useEditorFocus.test.ts @@ -110,6 +110,9 @@ describe('useEditorFocus', () => { it('selects H1 text after timeout when editor not yet mounted', () => { vi.useFakeTimers() + // Mock rAF synchronously so the deferred selectFirstHeading call inside doFocus + // runs immediately when requestAnimationFrame is invoked, keeping the test simple. + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) const tiptap = makeTiptapMock(true) const { editor } = setup(false, tiptap) @@ -122,5 +125,37 @@ describe('useEditorFocus', () => { expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 16 }) vi.useRealTimers() }) + + it('selection happens in second rAF (not first), allowing content swap to complete', () => { + // Verify the double-rAF contract: focus in rAF1, selection deferred to rAF2. + // This ensures the new note's blocks are applied (via queueMicrotask between frames) + // before selectFirstHeading runs. + const callbacks: FrameRequestCallback[] = [] + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + callbacks.push(cb) + return callbacks.length + }) + const tiptap = makeTiptapMock(true) + const { editor } = setup(true, tiptap) + + window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } })) + + // rAF 1 is scheduled (doFocus) + expect(callbacks.length).toBe(1) + callbacks[0](0) + + // After rAF 1: editor focused, but selection NOT yet triggered + expect(editor.focus).toHaveBeenCalled() + expect(tiptap.chain).not.toHaveBeenCalled() + + // rAF 2 is now scheduled (selectFirstHeading) + expect(callbacks.length).toBe(2) + callbacks[1](0) + + // After rAF 2: heading is selected + expect(tiptap.chain).toHaveBeenCalled() + expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 16 }) + expect(tiptap._chainResult.run).toHaveBeenCalled() + }) }) }) diff --git a/src/hooks/useEditorFocus.ts b/src/hooks/useEditorFocus.ts index 73536e15..a2afd564 100644 --- a/src/hooks/useEditorFocus.ts +++ b/src/hooks/useEditorFocus.ts @@ -48,8 +48,20 @@ export function useEditorFocus( const selectTitle = detail?.selectTitle ?? false const doFocus = () => { editor.focus() - if (selectTitle) selectFirstHeading(editor) - if (t0) console.debug(`[perf] createNote → focus: ${(performance.now() - t0).toFixed(1)}ms`) + if (!selectTitle) { + if (t0) console.debug(`[perf] createNote → focus: ${(performance.now() - t0).toFixed(1)}ms`) + return + } + // Defer selection to the next animation frame so the new note's content + // (applied via queueMicrotask inside a React effect triggered by the tab + // change) is in the document before we try to select the heading. + // Between two rAF callbacks, all pending macrotasks — including React's + // MessageChannel re-render and the subsequent queueMicrotask content swap + // — complete, so the heading block is guaranteed to exist by rAF 2. + requestAnimationFrame(() => { + selectFirstHeading(editor) + if (t0) console.debug(`[perf] createNote → focus+select: ${(performance.now() - t0).toFixed(1)}ms`) + }) } if (editorMountedRef.current) { requestAnimationFrame(doFocus)