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: () => {