Files
multica/packages/views/editor/content-editor.test.tsx
Naiyuan Qing d42fbcb794 fix(editor): sync ContentEditor when defaultValue changes externally (#2765)
* fix(editor): sync ContentEditor when defaultValue changes externally

Tiptap v3 `useEditor` reads `content` only at mount (ueberdosis/tiptap#5831
— by design), so when an issue description is updated remotely (WS event,
another agent, another client), the editor kept showing stale content
until the issue was closed and reopened. `key={id}` in issue-detail only
force-remounts on issue switch, not on same-issue updates.

Add a useEffect in ContentEditor that watches `defaultValue` and applies
it via `editor.commands.setContent()` with four guards:

  1. Focused AND dirty — protect bytes the user is actively typing.
     Focused-but-clean intentionally falls through: onBlur has no replay
     path, so an unconditional `if (isFocused) return` would drop the
     sync forever for users who click into the editor without typing.
  2. Unfocused AND dirty — covers the blur → debounce (1500ms) window
     where the editor holds unsaved content but isFocused is already
     false. The pending onUpdate flush reconciles via the cache;
     overwriting here would be silent data loss.
  3. Normalized-equal short-circuit — avoids a no-op transaction when
     the cache reflects a write this editor just emitted.
  4. `emitUpdate: false` — Tiptap v3 flipped setContent's emitUpdate
     default to true; without this the sync would re-trigger onUpdate
     → server save → self-write loop.

After setContent, clamp the prior selection to the new doc size so the
caret doesn't snap to position 0.

Tests cover five cases: unfocused+dirty-content (sync fires),
focused+dirty (skip), focused+clean (must sync — regression guard for
the focused-but-clean hole), unfocused+dirty (blur-before-debounce
window, skip), and normalized-equal short-circuit (skip).

Closes #2409

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(editor): cover normalized-equal sync path with a distinct defaultValue

The previous rerender passed the same `defaultValue` string, so React's
dep-array equality short-circuited the sync effect entirely — the test
only exercised the first-mount equality check, not the actual
normalized-equal guard.

Pass a different-but-trimEnd-equivalent value so the effect re-runs and
the normalized-equal short-circuit is what keeps setContent uncalled.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 12:39:14 +08:00

188 lines
6.3 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
const mockFocus = vi.hoisted(() => vi.fn());
const mockSetContent = vi.hoisted(() => vi.fn());
const mockSetTextSelection = vi.hoisted(() => vi.fn());
const editorState = vi.hoisted(() => ({
isFocused: false,
isDestroyed: false,
markdown: "",
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({}),
}));
vi.mock("./extensions", () => ({
createEditorExtensions: () => [],
}));
vi.mock("./extensions/file-upload", () => ({
uploadAndInsertFile: vi.fn(),
}));
vi.mock("./utils/preprocess", () => ({
preprocessMarkdown: (value: string) => value,
}));
vi.mock("./bubble-menu", () => ({
EditorBubbleMenu: () => null,
}));
const editorRef = vi.hoisted<{ current: unknown }>(() => ({ current: null }));
const onCreateFired = vi.hoisted(() => ({ value: false }));
vi.mock("@tiptap/react", () => ({
useEditor: (options: { onCreate?: (args: { editor: unknown }) => void }) => {
if (!editorRef.current) {
editorRef.current = {
get isFocused() {
return editorState.isFocused;
},
get isDestroyed() {
return editorState.isDestroyed;
},
commands: {
focus: mockFocus,
clearContent: vi.fn(),
setContent: mockSetContent,
setTextSelection: mockSetTextSelection,
},
getMarkdown: () => editorState.markdown,
state: {
doc: { content: { size: 0 } },
selection: { empty: true, from: 0, to: 0 },
},
};
}
if (!onCreateFired.value) {
onCreateFired.value = true;
options?.onCreate?.({ editor: editorRef.current });
}
return editorRef.current;
},
EditorContent: ({ className }: { className?: string }) => (
<div className={className} data-testid="editor-content">
<div className="ProseMirror rich-text-editor" data-testid="prosemirror" />
</div>
),
}));
import { ContentEditor } from "./content-editor";
describe("ContentEditor", () => {
beforeEach(() => {
vi.clearAllMocks();
editorState.isFocused = false;
editorState.isDestroyed = false;
editorState.markdown = "";
editorRef.current = null;
onCreateFired.value = false;
});
it("focuses the editor when clicking the empty container area", () => {
render(<ContentEditor placeholder="Add description..." />);
const shell = screen.getByTestId("editor-content").parentElement;
expect(shell).not.toBeNull();
fireEvent.mouseDown(shell!);
expect(mockFocus).toHaveBeenCalledWith("end");
});
it("does not hijack clicks that land inside the ProseMirror node", () => {
render(<ContentEditor placeholder="Add description..." />);
fireEvent.mouseDown(screen.getByTestId("prosemirror"));
expect(mockFocus).not.toHaveBeenCalled();
});
it("syncs editor content when defaultValue changes externally and editor is unfocused", () => {
editorState.markdown = "old content";
const { rerender } = render(<ContentEditor defaultValue="old content" />);
expect(mockSetContent).not.toHaveBeenCalled();
// Editor still holds the old, in-sync content; external value changes.
editorState.markdown = "old content";
rerender(<ContentEditor defaultValue="new content from server" />);
expect(mockSetContent).toHaveBeenCalledTimes(1);
expect(mockSetContent).toHaveBeenCalledWith(
"new content from server",
expect.objectContaining({ emitUpdate: false, contentType: "markdown" }),
);
});
it("does not sync when editor is focused and has unsaved local edits", () => {
editorState.markdown = "old content";
const { rerender } = render(<ContentEditor defaultValue="old content" />);
// User is typing — focused AND dirty (markdown diverges from
// lastEmittedRef, which was seeded with "old content" by onCreate).
editorState.isFocused = true;
editorState.markdown = "user-typed-content";
rerender(<ContentEditor defaultValue="incoming external change" />);
expect(mockSetContent).not.toHaveBeenCalled();
});
it("syncs even when editor is focused, as long as it is clean (focused-but-clean must not be permanently dropped)", () => {
// This case is the regression test for the focused-but-clean hole:
// user clicks into the editor (focused = true) but types nothing
// (markdown still equals lastEmittedRef). An external update arrives.
// With an unconditional `if (isFocused) return`, this sync would be lost
// forever because onBlur has no replay path.
editorState.markdown = "old content";
const { rerender } = render(<ContentEditor defaultValue="old content" />);
editorState.isFocused = true;
editorState.markdown = "old content"; // clean — no typing happened
rerender(<ContentEditor defaultValue="new content from server" />);
expect(mockSetContent).toHaveBeenCalledTimes(1);
expect(mockSetContent).toHaveBeenCalledWith(
"new content from server",
expect.objectContaining({ emitUpdate: false, contentType: "markdown" }),
);
});
it("does not sync when editor is unfocused but has unsaved local edits (blur-before-debounce window)", () => {
editorState.markdown = "old content";
const { rerender } = render(
<ContentEditor defaultValue="old content" onUpdate={() => {}} />,
);
// User typed locally, then blurred. Debounce hasn't flushed yet so
// lastEmittedRef inside the component still reflects "old content".
editorState.isFocused = false;
editorState.markdown = "user typed but unsaved";
rerender(
<ContentEditor
defaultValue="external update from another agent"
onUpdate={() => {}}
/>,
);
expect(mockSetContent).not.toHaveBeenCalled();
});
it("does not sync when defaultValue normalizes to the current editor markdown", () => {
editorState.markdown = "same content";
const { rerender } = render(<ContentEditor defaultValue="same content" />);
// Different `defaultValue` string forces the effect to re-run (the dep
// array sees a new value), but the trailing whitespace normalises away
// via `trimEnd()`, so `setContent` must still short-circuit.
rerender(<ContentEditor defaultValue={"same content\n"} />);
expect(mockSetContent).not.toHaveBeenCalled();
});
});