perf: Cache decorations in editor plugins (#12030)

Avoid full document traversal on every keystroke by mapping decorations
through the transaction when no relevant nodes changed. Uses
changedDescendants to detect when a heading, image, or code_inline-marked
text actually changes; otherwise the existing DecorationSet is mapped to
new positions cheaply.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-05-10 11:09:57 -04:00
committed by GitHub
parent ff3b3ce552
commit 51a1d3bf50
3 changed files with 118 additions and 14 deletions
+47 -7
View File
@@ -1,8 +1,11 @@
import type { NodeAnchor } from "@shared/utils/ProsemirrorHelper";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import type { EditorState } from "prosemirror-state";
import type { Node } from "prosemirror-model";
import type { EditorState, Transaction } from "prosemirror-state";
import { Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import { changedDescendants } from "../lib/changedDescendants";
import { isRemoteTransaction } from "../lib/multiplayer";
export class AnchorPlugin extends Plugin {
constructor() {
@@ -11,12 +14,18 @@ export class AnchorPlugin extends Plugin {
init: (_, state: EditorState) => ({
decorations: this.createDecorations(state),
}),
apply: (tr, pluginState, oldState, newState) => {
// Only recompute if doc changed
if (tr.docChanged) {
apply: (tr, pluginState, _oldState, newState) => {
if (!tr.docChanged) {
return pluginState;
}
if (isRemoteTransaction(tr) || this.hasAnchorableChange(tr)) {
return { decorations: this.createDecorations(newState) };
}
return pluginState;
return {
decorations: pluginState.decorations.map(tr.mapping, tr.doc),
};
},
},
props: {
@@ -28,8 +37,38 @@ export class AnchorPlugin extends Plugin {
});
}
private createAnchorDecoration = (anchor: NodeAnchor) =>
Decoration.widget(
private isAnchorable(node: Node): boolean {
return (
node.type.name === "heading" ||
(Array.isArray(node.attrs.marks) &&
node.attrs.marks.some(
(mark: { type: string; attrs?: { id?: string } }) =>
mark.type === "comment" && mark.attrs?.id
))
);
}
/**
* Check if the transaction changed any heading or image-with-comment-mark
* nodes by comparing changed descendants in both directions.
*/
private hasAnchorableChange(tr: Transaction): boolean {
let found = false;
const check = (node: Node) => {
if (!found && this.isAnchorable(node)) {
found = true;
}
};
changedDescendants(tr.before, tr.doc, 0, check);
if (!found) {
changedDescendants(tr.doc, tr.before, 0, check);
}
return found;
}
private createAnchorDecoration(anchor: NodeAnchor) {
return Decoration.widget(
anchor.pos,
() => {
const anchorElement = document.createElement("a");
@@ -39,6 +78,7 @@ export class AnchorPlugin extends Plugin {
},
{ side: -1, key: anchor.id }
);
}
private createDecorations(state: EditorState) {
const anchors = ProsemirrorHelper.getAnchors(state.doc);
@@ -1,6 +1,9 @@
import type { EditorState } from "prosemirror-state";
import type { Node } from "prosemirror-model";
import type { EditorState, Transaction } from "prosemirror-state";
import { Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import { changedDescendants } from "../lib/changedDescendants";
import { isRemoteTransaction } from "../lib/multiplayer";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
interface CodeWordDecorationsConfig {
@@ -22,13 +25,19 @@ class CodeWordDecorationsPlugin extends Plugin {
decorations: this.createDecorations(state, finalConfig),
}),
apply: (tr, pluginState, _oldState, newState) => {
// Only recompute if doc changed
if (tr.docChanged) {
if (!tr.docChanged) {
return pluginState;
}
if (isRemoteTransaction(tr) || this.hasCodeInlineChange(tr)) {
return {
decorations: this.createDecorations(newState, finalConfig),
};
}
return pluginState;
return {
decorations: pluginState.decorations.map(tr.mapping, tr.doc),
};
},
},
props: {
@@ -40,6 +49,33 @@ class CodeWordDecorationsPlugin extends Plugin {
});
}
/**
* Check if the transaction changed any text nodes with code_inline marks.
*/
private hasCodeInlineChange(tr: Transaction): boolean {
const codeMarkType = tr.doc.type.schema.marks.code_inline;
if (!codeMarkType) {
return false;
}
let found = false;
const check = (node: Node) => {
if (
!found &&
node.isText &&
node.marks.some((m) => m.type === codeMarkType)
) {
found = true;
}
};
changedDescendants(tr.before, tr.doc, 0, check);
if (!found) {
changedDescendants(tr.doc, tr.before, 0, check);
}
return found;
}
private createDecorations(
state: EditorState,
config: Required<CodeWordDecorationsConfig>
+31 -3
View File
@@ -1,6 +1,9 @@
import type { EditorState } from "prosemirror-state";
import type { Node } from "prosemirror-model";
import type { EditorState, Transaction } from "prosemirror-state";
import { Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import { changedDescendants } from "../lib/changedDescendants";
import { isRemoteTransaction } from "../lib/multiplayer";
/**
* Plugin that applies a light outline decoration to image nodes that have
@@ -15,10 +18,17 @@ export class CommentedImagePlugin extends Plugin {
decorations: this.createDecorations(state),
}),
apply: (tr, pluginState, _oldState, newState) => {
if (tr.docChanged) {
if (!tr.docChanged) {
return pluginState;
}
if (isRemoteTransaction(tr) || this.hasImageChange(tr)) {
return { decorations: this.createDecorations(newState) };
}
return pluginState;
return {
decorations: pluginState.decorations.map(tr.mapping, tr.doc),
};
},
},
props: {
@@ -30,6 +40,24 @@ export class CommentedImagePlugin extends Plugin {
});
}
/**
* Check if the transaction added, removed, or modified any image nodes.
*/
private hasImageChange(tr: Transaction): boolean {
let found = false;
const check = (node: Node) => {
if (!found && node.type.name === "image") {
found = true;
}
};
changedDescendants(tr.before, tr.doc, 0, check);
if (!found) {
changedDescendants(tr.doc, tr.before, 0, check);
}
return found;
}
private createDecorations(state: EditorState) {
const decorations: Decoration[] = [];