fix: defer H1 selection to second rAF so content swap completes first
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user