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)