Compare commits

...

2 Commits

Author SHA1 Message Date
Naiyuan Qing
91594966ec 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>
2026-05-18 11:10:01 +08:00
Naiyuan Qing
9b2be8570e 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>
2026-05-18 11:00:21 +08:00
2 changed files with 180 additions and 16 deletions

View File

@@ -2,6 +2,13 @@ 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: () => ({}),
@@ -23,24 +30,38 @@ 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: () => ({
commands: {
focus: mockFocus,
clearContent: vi.fn(),
},
getMarkdown: () => "",
state: {
doc: {
content: {
size: 0,
useEditor: (options: { onCreate?: (args: { editor: unknown }) => void }) => {
if (!editorRef.current) {
editorRef.current = {
get isFocused() {
return editorState.isFocused;
},
},
selection: {
empty: true,
},
},
}),
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" />
@@ -53,6 +74,11 @@ 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", () => {
@@ -73,4 +99,89 @@ describe("ContentEditor", () => {
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();
});
});

View File

@@ -225,6 +225,59 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
};
}, []);
// Sync external `defaultValue` changes into the editor.
// Tiptap v3 `useEditor` reads `content` only at mount (ueberdosis/tiptap#5831);
// without this effect, a WS-driven description update keeps the editor
// showing stale content until the issue is closed and reopened.
useEffect(() => {
if (!editor || editor.isDestroyed) return;
const current = stripBlobUrls(editor.getMarkdown()).trimEnd();
// "Dirty" = user has local edits not yet flushed through the debounced
// `onUpdate`. `lastEmittedRef` is advanced only after a debounce fire,
// so a divergence means the editor holds unsaved bytes.
const isDirty =
lastEmittedRef.current !== null && current !== lastEmittedRef.current;
// Guard 1: focused AND dirty — protect bytes the user is actively
// typing. Focused-but-clean falls through: applying setContent is safe
// (no user input to lose) and necessary, because onBlur has no replay
// mechanism and a focused clean editor would otherwise drop this sync
// permanently.
if (editor.isFocused && isDirty) return;
// Guard 2: unfocused-but-dirty — blur happened but the debounce window
// (debounceMs, 1500ms for description) hasn't flushed yet. The pending
// onUpdate will reach the server and the cache will reconcile; skipping
// here avoids overwriting unsaved local edits.
if (isDirty) return;
const incoming = defaultValue ? preprocessMarkdown(defaultValue) : "";
const incomingNormalized = stripBlobUrls(incoming).trimEnd();
// Guard 3: normalized-equal short-circuit. Avoids a no-op transaction
// when the cache reflects a write this same editor just emitted.
if (incomingNormalized === current) return;
// Guard 4: `emitUpdate: false`. Tiptap v3's setContent defaults to
// `emitUpdate: true`; without this we would re-trigger onUpdate →
// server save → self-write loop.
const { from, to } = editor.state.selection;
editor.commands.setContent(incoming, {
emitUpdate: false,
contentType: "markdown",
});
// Clamp prior selection to the new doc size so the caret doesn't snap
// to position 0 after ProseMirror replaces the document.
const docSize = editor.state.doc.content.size;
editor.commands.setTextSelection({
from: Math.min(from, docSize),
to: Math.min(to, docSize),
});
lastEmittedRef.current = stripBlobUrls(editor.getMarkdown()).trimEnd();
}, [defaultValue, editor]);
useImperativeHandle(ref, () => ({
getMarkdown: () => stripBlobUrls(editor?.getMarkdown() ?? ""),
clearContent: () => {