From 51a1d3bf504c828bab1c16e84224a256f72614f0 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 10 May 2026 11:09:57 -0400 Subject: [PATCH] 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 --- shared/editor/plugins/AnchorPlugin.ts | 54 ++++++++++++++++--- .../plugins/CodeWordDecorationsPlugin.ts | 44 +++++++++++++-- shared/editor/plugins/CommentedImagePlugin.ts | 34 ++++++++++-- 3 files changed, 118 insertions(+), 14 deletions(-) diff --git a/shared/editor/plugins/AnchorPlugin.ts b/shared/editor/plugins/AnchorPlugin.ts index dbd8b00aaf..cf16bfc207 100644 --- a/shared/editor/plugins/AnchorPlugin.ts +++ b/shared/editor/plugins/AnchorPlugin.ts @@ -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); diff --git a/shared/editor/plugins/CodeWordDecorationsPlugin.ts b/shared/editor/plugins/CodeWordDecorationsPlugin.ts index 7d5112b9e8..a398e2c78b 100644 --- a/shared/editor/plugins/CodeWordDecorationsPlugin.ts +++ b/shared/editor/plugins/CodeWordDecorationsPlugin.ts @@ -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 diff --git a/shared/editor/plugins/CommentedImagePlugin.ts b/shared/editor/plugins/CommentedImagePlugin.ts index 4bc93bfcf9..8351309481 100644 --- a/shared/editor/plugins/CommentedImagePlugin.ts +++ b/shared/editor/plugins/CommentedImagePlugin.ts @@ -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[] = [];