From c280fc0879c201f29b28e0410e8571b82f08505a Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 22 May 2026 18:50:14 +0800 Subject: [PATCH] fix(editor): sync TitleEditor when defaultValue changes externally (MUL-2565) (#3080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(editor): sync TitleEditor when defaultValue changes externally (MUL-2565) Tiptap's useEditor consumes `content` only at mount, so a WS-driven title update left the editor showing the old text. Worse, the next blur ran onBlur's value-vs-issue.title compare with stale editor bytes and silently mutated the title back, rolling the external change. Add a useEffect that calls editor.commands.setContent when defaultValue diverges and the editor is unfocused (preserve in-flight user typing). Pass emitUpdate:false to avoid an onUpdate echo loop. Co-authored-by: multica-agent * fix(editor): refine TitleEditor focus guard to focused+dirty only (MUL-2565) Reviewer flagged that the previous "focused → skip" guard was too coarse: a user who clicked into the title field but had not yet typed would leave the editor doc stale when an external title update arrived, and the next blur would compare the stale text to the new server value and silently roll the external update back. Track the previous defaultValue in a ref and only skip when the editor is both focused AND its current text diverges from that previous value (meaning the user has actually typed). Focused-but-clean updates fall through and accept the new external value. Adds a regression test covering the focused-but-clean external update case. Co-Authored-By: Claude Opus 4.7 Co-authored-by: multica-agent --------- Co-authored-by: multica-agent Co-authored-by: Claude Opus 4.7 --- packages/views/editor/title-editor.test.tsx | 138 ++++++++++++++++++++ packages/views/editor/title-editor.tsx | 38 ++++++ 2 files changed, 176 insertions(+) create mode 100644 packages/views/editor/title-editor.test.tsx diff --git a/packages/views/editor/title-editor.test.tsx b/packages/views/editor/title-editor.test.tsx new file mode 100644 index 000000000..ce3232ba3 --- /dev/null +++ b/packages/views/editor/title-editor.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render } from "@testing-library/react"; + +const mockFocus = vi.hoisted(() => vi.fn()); +const mockSetContent = vi.hoisted(() => vi.fn()); +const mockBlur = vi.hoisted(() => vi.fn()); +const editorState = vi.hoisted(() => ({ + isFocused: false, + isDestroyed: false, + text: "", +})); + +vi.mock("../i18n", () => ({ + useT: () => ({ t: (fn: unknown) => (typeof fn === "function" ? "" : "") }), +})); + +const editorRef = vi.hoisted<{ current: unknown }>(() => ({ current: null })); + +vi.mock("@tiptap/react", () => ({ + useEditor: () => { + if (!editorRef.current) { + editorRef.current = { + get isFocused() { + return editorState.isFocused; + }, + get isDestroyed() { + return editorState.isDestroyed; + }, + commands: { + focus: mockFocus, + blur: mockBlur, + setContent: mockSetContent, + }, + getText: () => editorState.text, + }; + } + return editorRef.current; + }, + EditorContent: () =>
, +})); + +import { TitleEditor } from "./title-editor"; + +describe("TitleEditor", () => { + beforeEach(() => { + vi.clearAllMocks(); + editorState.isFocused = false; + editorState.isDestroyed = false; + editorState.text = ""; + editorRef.current = null; + }); + + it("syncs editor content when defaultValue changes externally and editor is unfocused", () => { + editorState.text = "old title"; + const { rerender } = render(); + + expect(mockSetContent).not.toHaveBeenCalled(); + + rerender(); + + expect(mockSetContent).toHaveBeenCalledTimes(1); + expect(mockSetContent).toHaveBeenCalledWith( + { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "new title from server" }], + }, + ], + }, + { emitUpdate: false }, + ); + }); + + it("does not overwrite the user's in-flight edits when the editor is focused and dirty", () => { + editorState.text = "old title"; + const { rerender } = render(); + + editorState.isFocused = true; + editorState.text = "user typed but not yet blurred"; + + rerender(); + + expect(mockSetContent).not.toHaveBeenCalled(); + }); + + // Regression: a focused but clean editor (user clicked in but never typed) + // must still accept external updates, otherwise the subsequent blur would + // compare stale editor text to the new server value and silently roll the + // external update back. + it("syncs to new defaultValue when editor is focused but clean", () => { + editorState.text = "old title"; + const { rerender } = render(); + + // User clicked into the title field but has not typed anything yet: + // editor text still equals the previous defaultValue. + editorState.isFocused = true; + editorState.text = "old title"; + + rerender(); + + expect(mockSetContent).toHaveBeenCalledTimes(1); + expect(mockSetContent).toHaveBeenCalledWith( + { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "new title from server" }], + }, + ], + }, + { emitUpdate: false }, + ); + }); + + it("short-circuits when editor text already equals incoming defaultValue", () => { + editorState.text = "same title"; + const { rerender } = render(); + + // Force the effect to re-run by rendering with a different prop, then + // back to the same value. Even an identity-equal prop should be skipped. + rerender(); + + expect(mockSetContent).not.toHaveBeenCalled(); + }); + + it("clears the editor when defaultValue transitions to empty", () => { + editorState.text = "old title"; + const { rerender } = render(); + + rerender(); + + expect(mockSetContent).toHaveBeenCalledTimes(1); + expect(mockSetContent).toHaveBeenCalledWith("", { emitUpdate: false }); + }); +}); diff --git a/packages/views/editor/title-editor.tsx b/packages/views/editor/title-editor.tsx index 0dfad35e4..2ba4a7eb0 100644 --- a/packages/views/editor/title-editor.tsx +++ b/packages/views/editor/title-editor.tsx @@ -132,6 +132,44 @@ const TitleEditor = forwardRef( return undefined; }, [autoFocus, editor]); + // Track the last `defaultValue` we've reconciled against, so we can tell + // "focused + dirty" (user typed something that diverges from external) + // apart from "focused + clean" (user just clicked in without typing). + const lastDefaultValueRef = useRef(defaultValue); + + // Sync external `defaultValue` changes into the editor. + // Tiptap `useEditor` consumes `content` only at mount, so a WS-driven + // title update would otherwise leave the editor showing stale text — and + // the next blur would silently roll the external change back via onBlur's + // value-vs-issue.title compare. + useEffect(() => { + if (!editor || editor.isDestroyed) return; + const prevDefaultValue = lastDefaultValueRef.current; + lastDefaultValueRef.current = defaultValue; + + // Already in sync — nothing to do. + if (editor.getText() === defaultValue) return; + + // Focused + dirty: editor text diverges from the previous external + // value, meaning the user has typed in this session. Preserve input. + // Focused + clean (text still equals prev defaultValue) falls through + // so we accept the new external value instead of letting the next blur + // roll it back. + if (editor.isFocused && editor.getText() !== prevDefaultValue) return; + + editor.commands.setContent( + defaultValue + ? { + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: defaultValue }] }, + ], + } + : "", + { emitUpdate: false }, + ); + }, [defaultValue, editor]); + useImperativeHandle(ref, () => ({ getText: () => editor?.getText() ?? "", focus: () => {