Stabilize Tiptap editor instance across prop changes (#17526)

- Change useEditor dependency array to empty, preventing recreation
- Tiptap now updates live options instead of reparsing initial content
- Preserves selection, undo history, and document across rerenders
- Add tests verifying editor stability and option handling
This commit is contained in:
Jinjing
2026-08-30 19:00:05 -07:00
committed by GitHub
parent df48337d72
commit fe82569b97
3 changed files with 177 additions and 1 deletions
@@ -0,0 +1,69 @@
// @vitest-environment happy-dom
import { renderHook, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import type { EditorConfigParams } from './rich-markdown-editor-config'
import { useRichMarkdownEditorInstance } from './useRichMarkdownEditorInstance'
vi.mock('./rich-markdown-extensions', () => ({
createRichMarkdownExtensions: vi.fn(() => [])
}))
vi.mock('./rich-markdown-editor-config', async () => {
return {
createRichMarkdownEditorConfig: (params: Pick<EditorConfigParams, 'content'>) => ({
extensions: [StarterKit],
immediatelyRender: false,
content: `<p>${params.content}</p>`
})
}
})
function createParams(content: string): EditorConfigParams {
return {
codec: {} as EditorConfigParams['codec'],
htmlSuperscriptLinkContext: {} as EditorConfigParams['htmlSuperscriptLinkContext'],
content,
editorRef: { current: null } as EditorConfigParams['editorRef']
} as EditorConfigParams
}
describe('useRichMarkdownEditorInstance with Tiptap', () => {
afterEach(() => {
vi.clearAllMocks()
})
it('preserves the document, selection, and undo history across ordinary rerenders', async () => {
const initialParams = createParams('initial')
const { rerender, result, unmount } = renderHook(
({ params }) => useRichMarkdownEditorInstance(params),
{ initialProps: { params: initialParams } }
)
await waitFor(() => expect(result.current).toBeTruthy())
const editor = result.current as Editor
editor.commands.setTextSelection(editor.state.doc.content.size - 1)
editor.commands.insertContent(' edited')
const selection = {
from: editor.state.selection.from,
to: editor.state.selection.to
}
const documentBeforeRerender = editor.getHTML()
expect(editor.can().undo()).toBe(true)
rerender({ params: { ...initialParams, content: 'updated from props' } })
expect(result.current).toBe(editor)
expect(editor.getHTML()).toBe(documentBeforeRerender)
expect(editor.state.selection.from).toBe(selection.from)
expect(editor.state.selection.to).toBe(selection.to)
expect(editor.can().undo()).toBe(true)
editor.commands.undo()
expect(editor.getText()).toBe('initial')
unmount()
})
})
@@ -0,0 +1,103 @@
// @vitest-environment happy-dom
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Editor } from '@tiptap/react'
import type { EditorConfigParams } from './rich-markdown-editor-config'
import { useRichMarkdownEditorInstance } from './useRichMarkdownEditorInstance'
const { createExtensionsMock, createConfigMock, useEditorMock } = vi.hoisted(() => {
const editorMock = { id: 'editor' }
return {
createExtensionsMock: vi.fn(() => ['extension']),
createConfigMock: vi.fn(() => ({})),
useEditorMock: vi.fn(() => editorMock as unknown as Editor)
}
})
vi.mock('@tiptap/react', () => ({ useEditor: useEditorMock }))
vi.mock('./rich-markdown-extensions', () => ({
createRichMarkdownExtensions: createExtensionsMock
}))
vi.mock('./rich-markdown-editor-config', () => ({
createRichMarkdownEditorConfig: createConfigMock
}))
function createParams(content = ''): EditorConfigParams {
return {
codec: {} as EditorConfigParams['codec'],
htmlSuperscriptLinkContext: {} as EditorConfigParams['htmlSuperscriptLinkContext'],
content,
filePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
isMac: false,
richMarkdownSpellcheckEnabled: true,
settings: {} as EditorConfigParams['settings'],
activateMarkdownLink: vi.fn(),
rootRef: { current: null },
editorRef: { current: null },
lastCommittedMarkdownRef: { current: '' },
originalSourceRef: { current: '' },
baseCanonicalRef: { current: '' },
reconcileRoundTripRef: { current: () => null },
onContentChangeRef: { current: vi.fn() },
onDirtyStateHintRef: { current: vi.fn() },
onSaveRef: { current: vi.fn() },
onOpenDocLinkRef: { current: undefined },
isEditingLinkRef: { current: false },
slashMenuRef: { current: null },
filteredSlashCommandsRef: { current: [] },
selectedCommandIndexRef: { current: 0 },
docLinkMenuRef: { current: null },
filteredDocLinkRowsRef: { current: [] },
selectedDocLinkIndexRef: { current: 0 },
handleLocalImagePickRef: { current: vi.fn() },
handleEmojiPickRef: { current: vi.fn() },
typedEmptyOrderedListMarkerRef: { current: false },
cancelAutoFocusRef: { current: null },
serializeTimerRef: { current: null },
isInitializingRef: { current: false },
isApplyingProgrammaticUpdateRef: { current: false },
markdownCommentsRef: { current: [] },
markdownSourceLineOffsetRef: { current: 0 },
flushPendingSerialization: vi.fn(),
openSearchRef: { current: vi.fn() },
openAnnotationPopoverRef: { current: vi.fn() },
syncAnnotationTarget: vi.fn(),
clearAnnotationTarget: vi.fn(),
scrollRichMarkdownReviewNoteCardIntoView: vi.fn(),
setIsEditingLink: vi.fn(),
setLinkBubble: vi.fn(),
setSelectedCommandIndex: vi.fn(),
setSelectedDocLinkIndex: vi.fn(),
setSlashMenu: vi.fn(),
setDocLinkMenu: vi.fn()
}
}
describe('useRichMarkdownEditorInstance', () => {
beforeEach(() => {
createExtensionsMock.mockClear()
createConfigMock.mockClear()
useEditorMock.mockClear()
})
it('does not rebuild Tiptap when ordinary editor options change', () => {
const initialParams = createParams('initial')
const { rerender, result } = renderHook(({ params }) => useRichMarkdownEditorInstance(params), {
initialProps: { params: initialParams }
})
const nextParams = { ...initialParams, content: 'updated' }
rerender({ params: nextParams })
expect(result.current).toBe(useEditorMock.mock.results[0]?.value)
expect(createExtensionsMock).toHaveBeenCalledOnce()
expect(useEditorMock).toHaveBeenCalledTimes(2)
const editorCalls = useEditorMock.mock.calls as unknown[][]
expect(editorCalls[0]?.[1]).toEqual([])
expect(editorCalls[1]?.[1]).toEqual([])
expect(createConfigMock).toHaveBeenCalledTimes(2)
})
})
@@ -26,7 +26,11 @@ export function useRichMarkdownEditorInstance(params: EditorConfigParams): Edito
// Dependencies are the same as the params object keys
// eslint-disable-next-line react-hooks/exhaustive-deps
Object.values(params)
)
),
// Keep the editor instance stable while its options change. Tiptap updates
// the live editor options when this list is empty, preserving selection and
// history instead of reparsing the initial content.
[]
)
params.editorRef.current = editor ?? null
return editor