fix(editor): sync TitleEditor when defaultValue changes externally (MUL-2565) (#3080)

* 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 <github@multica.ai>

* 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 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-05-22 18:50:14 +08:00
committed by GitHub
parent 0339599ff6
commit c280fc0879
2 changed files with 176 additions and 0 deletions

View File

@@ -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: () => <div data-testid="editor-content" />,
}));
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(<TitleEditor defaultValue="old title" />);
expect(mockSetContent).not.toHaveBeenCalled();
rerender(<TitleEditor defaultValue="new title from server" />);
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(<TitleEditor defaultValue="old title" />);
editorState.isFocused = true;
editorState.text = "user typed but not yet blurred";
rerender(<TitleEditor defaultValue="external update" />);
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(<TitleEditor defaultValue="old title" />);
// 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(<TitleEditor defaultValue="new title from server" />);
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(<TitleEditor defaultValue="same title" />);
// 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(<TitleEditor defaultValue="same title" />);
expect(mockSetContent).not.toHaveBeenCalled();
});
it("clears the editor when defaultValue transitions to empty", () => {
editorState.text = "old title";
const { rerender } = render(<TitleEditor defaultValue="old title" />);
rerender(<TitleEditor defaultValue="" />);
expect(mockSetContent).toHaveBeenCalledTimes(1);
expect(mockSetContent).toHaveBeenCalledWith("", { emitUpdate: false });
});
});

View File

@@ -132,6 +132,44 @@ const TitleEditor = forwardRef<TitleEditorRef, TitleEditorProps>(
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: () => {