From fe82569b97f4f7bfbefe259b3610c16263f6d7a7 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:00:05 -0700 Subject: [PATCH] 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 --- ...arkdownEditorInstance.integration.test.tsx | 69 ++++++++++++ .../useRichMarkdownEditorInstance.test.tsx | 103 ++++++++++++++++++ .../editor/useRichMarkdownEditorInstance.ts | 6 +- 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/editor/useRichMarkdownEditorInstance.integration.test.tsx create mode 100644 src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx diff --git a/src/renderer/src/components/editor/useRichMarkdownEditorInstance.integration.test.tsx b/src/renderer/src/components/editor/useRichMarkdownEditorInstance.integration.test.tsx new file mode 100644 index 00000000000..b04549aacf1 --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownEditorInstance.integration.test.tsx @@ -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) => ({ + extensions: [StarterKit], + immediatelyRender: false, + content: `

${params.content}

` + }) + } +}) + +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() + }) +}) diff --git a/src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx b/src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx new file mode 100644 index 00000000000..967facaedbb --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx @@ -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) + }) +}) diff --git a/src/renderer/src/components/editor/useRichMarkdownEditorInstance.ts b/src/renderer/src/components/editor/useRichMarkdownEditorInstance.ts index 211793f5a56..4d05828f4a2 100644 --- a/src/renderer/src/components/editor/useRichMarkdownEditorInstance.ts +++ b/src/renderer/src/components/editor/useRichMarkdownEditorInstance.ts @@ -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