Skip to content

Commit b7d2a16

Browse files
Avoid double editor transaction application (#5909)
* Avoid double editor transaction application Move editor change notifications into a ProseMirror plugin view update and remove custom dispatch handlers that re-applied transactions in uncontrolled React ProseMirror editors. * Capture editor content before debounce
1 parent 29d2a1b commit b7d2a16

5 files changed

Lines changed: 121 additions & 37 deletions

File tree

packages/editor/src/chat/index.tsx

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,11 @@ import {
3434
MentionNodeView,
3535
withNodeViewErrorBoundary,
3636
} from "../node-views";
37-
import { type PlaceholderFunction, placeholderPlugin } from "../plugins";
38-
import { dispatchEditorTransaction } from "../transaction-guard";
37+
import {
38+
docChangeListenerPlugin,
39+
type PlaceholderFunction,
40+
placeholderPlugin,
41+
} from "../plugins";
3942
import {
4043
type MentionConfig,
4144
MentionSuggestion,
@@ -227,6 +230,9 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
227230

228231
return [
229232
reactKeys(),
233+
docChangeListenerPlugin((view) => {
234+
onUpdateRef.current?.(view.state.doc.toJSON() as JSONContent);
235+
}),
230236
keymap({
231237
"Mod-z": undo,
232238
"Mod-Shift-z": redo,
@@ -273,15 +279,6 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
273279
<ProseMirror
274280
defaultState={defaultState}
275281
nodeViewComponents={nodeViews}
276-
dispatchTransaction={function (this: EditorView, tr) {
277-
dispatchEditorTransaction({
278-
view: this,
279-
transaction: tr,
280-
onDocChanged: (view) => {
281-
onUpdateRef.current?.(view.state.doc.toJSON() as JSONContent);
282-
},
283-
});
284-
}}
285282
attributes={{
286283
spellCheck: "false",
287284
autoComplete: "off",

packages/editor/src/note/index.tsx

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import {
1818
PluginKey,
1919
Selection,
2020
TextSelection,
21-
type Transaction,
2221
} from "prosemirror-state";
2322
import type { EditorView } from "prosemirror-view";
2423
import {
@@ -52,6 +51,7 @@ import {
5251
clearMarksOnEnterPlugin,
5352
clipboardPlugin,
5453
clipPastePlugin,
54+
docChangeListenerPlugin,
5555
ensureImageTrailingParagraphs,
5656
fileHandlerPlugin,
5757
getSearchState,
@@ -75,7 +75,6 @@ import {
7575
normalizeTaskContent,
7676
type TaskSource,
7777
} from "../tasks";
78-
import { dispatchEditorTransaction } from "../transaction-guard";
7978
import {
8079
FormatToolbar,
8180
type MentionConfig,
@@ -537,26 +536,28 @@ export const NoteEditor = forwardRef<NoteEditorRef, NoteEditorProps>(
537536
[taskSource, taskStorage],
538537
);
539538

540-
const flushChange = useCallback(() => {
541-
const view = viewRef.current;
542-
if (!view) {
543-
return;
544-
}
545-
546-
const content = view.state.doc.toJSON() as JSONContent;
547-
syncTasks(content);
548-
if (!handleChange) {
549-
return;
550-
}
539+
const flushChange = useCallback(
540+
(content: JSONContent) => {
541+
syncTasks(content);
542+
if (!handleChange) {
543+
return;
544+
}
551545

552-
handleChange(content);
553-
}, [handleChange, syncTasks]);
546+
handleChange(content);
547+
},
548+
[handleChange, syncTasks],
549+
);
554550

555551
const onUpdate = useDebounceCallback(flushChange, 500);
552+
const onUpdateRef = useRef(onUpdate);
553+
onUpdateRef.current = onUpdate;
556554

557555
const plugins = useMemo(
558556
() => [
559557
reactKeys(),
558+
docChangeListenerPlugin((view) =>
559+
onUpdateRef.current(view.state.doc.toJSON() as JSONContent),
560+
),
560561
buildInputRules(),
561562
...(enforceTitleHeading ? [titleHeadingPlugin()] : []),
562563
taskIdentityPlugin(),
@@ -649,12 +650,18 @@ export const NoteEditor = forwardRef<NoteEditorRef, NoteEditorProps>(
649650
doc,
650651
plugins: view.state.plugins,
651652
});
653+
onUpdate.cancel();
652654
view.updateState(state);
653655
previousContentRef.current = reconciledInitialContent;
654656
} catch {
655657
// invalid content
656658
}
657-
}, [reconciledInitialContent, syncContentWhenFocused, enforceTitleHeading]);
659+
}, [
660+
reconciledInitialContent,
661+
syncContentWhenFocused,
662+
enforceTitleHeading,
663+
onUpdate,
664+
]);
658665

659666
const onViewReady = useCallback(
660667
(view: EditorView) => {
@@ -675,16 +682,6 @@ export const NoteEditor = forwardRef<NoteEditorRef, NoteEditorProps>(
675682
<ProseMirror
676683
defaultState={defaultState}
677684
nodeViewComponents={nodeViews}
678-
dispatchTransaction={function (
679-
this: EditorView,
680-
tr: Transaction,
681-
) {
682-
dispatchEditorTransaction({
683-
view: this,
684-
transaction: tr,
685-
onDocChanged: () => onUpdate(),
686-
});
687-
}}
688685
attributes={{
689686
spellCheck: "false",
690687
autoComplete: "off",
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { EditorState, type Plugin } from "prosemirror-state";
2+
import type { EditorView } from "prosemirror-view";
3+
import { describe, expect, it, vi } from "vitest";
4+
5+
import { schema } from "../note/schema";
6+
import { docChangeListenerPlugin } from "./doc-change-listener";
7+
8+
function createState(text = "", plugins: Plugin[] = []) {
9+
return EditorState.create({
10+
schema,
11+
doc: schema.node("doc", null, [
12+
schema.node("paragraph", null, text ? [schema.text(text)] : undefined),
13+
]),
14+
plugins,
15+
});
16+
}
17+
18+
describe("docChangeListenerPlugin", () => {
19+
it("reports only document changes", () => {
20+
const onDocChanged = vi.fn();
21+
const plugin = docChangeListenerPlugin(onDocChanged);
22+
const previousState = createState("", [plugin]);
23+
const pluginView = plugin.spec.view?.({
24+
state: previousState,
25+
} as EditorView);
26+
27+
const selectionOnlyState = previousState.apply(
28+
previousState.tr.setMeta("source", "test"),
29+
);
30+
const selectionOnlyView = { state: selectionOnlyState } as EditorView;
31+
pluginView?.update(selectionOnlyView, previousState);
32+
33+
expect(onDocChanged).not.toHaveBeenCalled();
34+
35+
const changedState = previousState.apply(previousState.tr.insertText("x"));
36+
const changedView = { state: changedState } as EditorView;
37+
pluginView?.update(changedView, previousState);
38+
39+
expect(onDocChanged).toHaveBeenCalledOnce();
40+
expect(onDocChanged).toHaveBeenCalledWith(changedView);
41+
});
42+
43+
it("ignores document replacement from controlled prop sync", () => {
44+
const onDocChanged = vi.fn();
45+
const plugin = docChangeListenerPlugin(onDocChanged);
46+
const previousState = createState("old", [plugin]);
47+
const pluginView = plugin.spec.view?.({
48+
state: previousState,
49+
} as EditorView);
50+
const syncedState = createState("new", [plugin]);
51+
52+
pluginView?.update({ state: syncedState } as EditorView, previousState);
53+
54+
expect(onDocChanged).not.toHaveBeenCalled();
55+
});
56+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Plugin, PluginKey } from "prosemirror-state";
2+
import type { EditorView } from "prosemirror-view";
3+
4+
const docChangedByTransactionKey = new PluginKey<boolean>(
5+
"docChangedByTransaction",
6+
);
7+
8+
export function docChangeListenerPlugin(
9+
onDocChanged: (view: EditorView) => void,
10+
) {
11+
return new Plugin({
12+
key: docChangedByTransactionKey,
13+
state: {
14+
init: () => false,
15+
apply: (transaction, previous) => transaction.docChanged || previous,
16+
},
17+
view() {
18+
return {
19+
update(view, prevState) {
20+
if (prevState.doc === view.state.doc) {
21+
return;
22+
}
23+
24+
if (!docChangedByTransactionKey.getState(view.state)) {
25+
return;
26+
}
27+
28+
onDocChanged(view);
29+
},
30+
};
31+
},
32+
});
33+
}

packages/editor/src/plugins/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export { appLinkPastePlugin } from "./app-link-paste";
22
export { autolinkPlugin } from "./autolink";
33
export { clipboardPlugin, serializeClipboardText } from "./clipboard";
44
export { clearMarksOnEnterPlugin } from "./clear-marks-on-enter";
5+
export { docChangeListenerPlugin } from "./doc-change-listener";
56
export {
67
clipNodeSpec,
78
clipPastePlugin,

0 commit comments

Comments
 (0)