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>
162 lines
6.5 KiB
TypeScript
162 lines
6.5 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
import { renderHook } from '@testing-library/react'
|
|
import { useEditorFocus } from './useEditorFocus'
|
|
|
|
function makeTiptapMock(hasHeading = true) {
|
|
const chainResult = { setTextSelection: vi.fn().mockReturnThis(), run: vi.fn() }
|
|
const descendantsMock = vi.fn().mockImplementation((cb: (node: { type: { name: string }; nodeSize: number }, pos: number) => boolean | void) => {
|
|
if (hasHeading) cb({ type: { name: 'heading' }, nodeSize: 15 }, 2)
|
|
})
|
|
return {
|
|
state: { doc: { descendants: descendantsMock } },
|
|
chain: vi.fn(() => chainResult),
|
|
_chainResult: chainResult,
|
|
_descendantsMock: descendantsMock,
|
|
}
|
|
}
|
|
|
|
describe('useEditorFocus', () => {
|
|
afterEach(() => { vi.restoreAllMocks() })
|
|
|
|
function setup(isMounted: boolean, tiptap?: ReturnType<typeof makeTiptapMock>) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
|
|
const editor = { focus: vi.fn(), _tiptapEditor: tiptap } as any
|
|
const mountedRef = { current: isMounted }
|
|
renderHook(() => useEditorFocus(editor, mountedRef))
|
|
return { editor, tiptap }
|
|
}
|
|
|
|
it('focuses editor via rAF when already mounted', async () => {
|
|
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
|
const { editor } = setup(true)
|
|
|
|
window.dispatchEvent(new CustomEvent('laputa:focus-editor'))
|
|
|
|
expect(rAF).toHaveBeenCalled()
|
|
expect(editor.focus).toHaveBeenCalled()
|
|
})
|
|
|
|
it('focuses editor via setTimeout when not yet mounted', () => {
|
|
vi.useFakeTimers()
|
|
const { editor } = setup(false)
|
|
|
|
window.dispatchEvent(new CustomEvent('laputa:focus-editor'))
|
|
|
|
expect(editor.focus).not.toHaveBeenCalled()
|
|
vi.advanceTimersByTime(80)
|
|
expect(editor.focus).toHaveBeenCalled()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('cleans up event listener on unmount', () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
|
|
const editor = { focus: vi.fn() } as any
|
|
const mountedRef = { current: true }
|
|
const { unmount } = renderHook(() => useEditorFocus(editor, mountedRef))
|
|
|
|
unmount()
|
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
|
window.dispatchEvent(new CustomEvent('laputa:focus-editor'))
|
|
|
|
expect(editor.focus).not.toHaveBeenCalled()
|
|
})
|
|
|
|
describe('selectTitle behavior', () => {
|
|
it('selects H1 text when selectTitle is true and editor is mounted', () => {
|
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
|
const tiptap = makeTiptapMock(true)
|
|
const { editor } = setup(true, tiptap)
|
|
|
|
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
|
|
|
expect(editor.focus).toHaveBeenCalled()
|
|
expect(tiptap.chain).toHaveBeenCalled()
|
|
expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 16 })
|
|
expect(tiptap._chainResult.run).toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not select title when selectTitle is false (default)', () => {
|
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
|
const tiptap = makeTiptapMock(true)
|
|
const { editor } = setup(true, tiptap)
|
|
|
|
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: false } }))
|
|
|
|
expect(editor.focus).toHaveBeenCalled()
|
|
expect(tiptap.chain).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not select title when selectTitle is absent from event detail', () => {
|
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
|
const tiptap = makeTiptapMock(true)
|
|
const { editor } = setup(true, tiptap)
|
|
|
|
window.dispatchEvent(new CustomEvent('laputa:focus-editor'))
|
|
|
|
expect(editor.focus).toHaveBeenCalled()
|
|
expect(tiptap.chain).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('skips selection when no heading found in document', () => {
|
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
|
const tiptap = makeTiptapMock(false)
|
|
const { editor } = setup(true, tiptap)
|
|
|
|
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
|
|
|
expect(editor.focus).toHaveBeenCalled()
|
|
expect(tiptap.chain).not.toHaveBeenCalled()
|
|
})
|
|
|
|
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)
|
|
|
|
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
|
|
|
expect(editor.focus).not.toHaveBeenCalled()
|
|
vi.advanceTimersByTime(80)
|
|
expect(editor.focus).toHaveBeenCalled()
|
|
expect(tiptap.chain).toHaveBeenCalled()
|
|
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()
|
|
})
|
|
})
|
|
})
|