mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user