diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 730fa46d47..faf0d35292 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -7,6 +7,7 @@ import { } from "@tiptap/pm/model"; import { NodeView } from "@tiptap/pm/view"; import { mergeParagraphs } from "../../blocks/defaultBlockHelpers.js"; +import { ignoreNonContentMutations } from "../nodeViewMutations.js"; import { Extension, ExtensionFactoryInstance, @@ -271,6 +272,11 @@ export function addNodeAndExtensionsToSpec< applyNonSelectableBlockFix(typedNodeView, this.editor); } + // Ignores DOM mutations that don't affect the block's content, so + // that browser extensions which rewrite the DOM (e.g. Dark Reader) + // can't trigger an infinite re-render loop that freezes the tab. + ignoreNonContentMutations(typedNodeView); + // See explanation for why `update` is not implemented for NodeViews // https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464 // TODO: in a future version, we might want to implement updates so that diff --git a/packages/core/src/schema/inlineContent/createSpec.ts b/packages/core/src/schema/inlineContent/createSpec.ts index f4522936a4..7b6505d245 100644 --- a/packages/core/src/schema/inlineContent/createSpec.ts +++ b/packages/core/src/schema/inlineContent/createSpec.ts @@ -4,6 +4,7 @@ import { TagParseRule } from "@tiptap/pm/model"; import { inlineContentToNodes } from "../../api/nodeConversions/blockToNode.js"; import { nodeToCustomInlineContent } from "../../api/nodeConversions/nodeToBlock.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { ignoreNonContentMutations } from "../nodeViewMutations.js"; import { propsToAttributes } from "../blocks/internal.js"; import { Props } from "../propTypes.js"; import { StyleSchema } from "../styles/types.js"; @@ -208,12 +209,19 @@ export function createInlineContentSpec< editor, ); - return addInlineContentAttributes( + const nodeView = addInlineContentAttributes( output, inlineContentConfig.type, node.attrs as Props, inlineContentConfig.propSchema, ); + + // Ignores DOM mutations that don't affect the inline content, so that + // browser extensions which rewrite the DOM (e.g. Dark Reader) can't + // trigger an infinite re-render loop that freezes the tab. + ignoreNonContentMutations(nodeView); + + return nodeView; }; }, }); diff --git a/packages/core/src/schema/nodeViewMutations.test.ts b/packages/core/src/schema/nodeViewMutations.test.ts new file mode 100644 index 0000000000..b8c0f663bd --- /dev/null +++ b/packages/core/src/schema/nodeViewMutations.test.ts @@ -0,0 +1,121 @@ +import { NodeView, ViewMutationRecord } from "@tiptap/pm/view"; +import { describe, expect, it } from "vite-plus/test"; + +import { + ignoreNonContentMutations, + isNonContentMutation, +} from "./nodeViewMutations.js"; + +function attributeMutation(target: Node): ViewMutationRecord { + return { + type: "attributes", + target, + attributeName: "style", + } as unknown as ViewMutationRecord; +} + +function childListMutation(target: Node): ViewMutationRecord { + return { + type: "childList", + target, + addedNodes: [] as any, + removedNodes: [] as any, + } as unknown as ViewMutationRecord; +} + +const selectionMutation = { type: "selection" } as ViewMutationRecord; + +describe("isNonContentMutation", () => { + it("never ignores selection mutations", () => { + expect(isNonContentMutation(selectionMutation, null)).toBe(false); + expect( + isNonContentMutation(selectionMutation, document.createElement("div")), + ).toBe(false); + }); + + it("ignores everything for a node view without content", () => { + const target = document.createElement("span"); + expect(isNonContentMutation(attributeMutation(target), null)).toBe(true); + expect(isNonContentMutation(childListMutation(target), null)).toBe(true); + }); + + it("ignores attribute mutations even inside the content DOM", () => { + const contentDOM = document.createElement("div"); + const span = document.createElement("span"); + contentDOM.appendChild(span); + + // e.g. Dark Reader rewriting the inline style of a highlight span. + expect(isNonContentMutation(attributeMutation(span), contentDOM)).toBe( + true, + ); + expect( + isNonContentMutation(attributeMutation(contentDOM), contentDOM), + ).toBe(true); + }); + + it("reads content mutations inside the content DOM", () => { + const contentDOM = document.createElement("div"); + const textNode = document.createTextNode("hello"); + contentDOM.appendChild(textNode); + + // childList directly on the content DOM (e.g. a node inserted while typing). + expect( + isNonContentMutation(childListMutation(contentDOM), contentDOM), + ).toBe(false); + // characterData-style mutation on a text node inside the content DOM. + expect(isNonContentMutation(childListMutation(textNode), contentDOM)).toBe( + false, + ); + }); + + it("ignores content mutations outside the content DOM (node view chrome)", () => { + const dom = document.createElement("div"); + const contentDOM = document.createElement("div"); + const chrome = document.createElement("button"); // e.g. toggle button + dom.append(chrome, contentDOM); + + expect(isNonContentMutation(childListMutation(dom), contentDOM)).toBe(true); + expect(isNonContentMutation(childListMutation(chrome), contentDOM)).toBe( + true, + ); + }); +}); + +describe("ignoreNonContentMutations", () => { + it("ignores non-content mutations while reading content ones", () => { + const contentDOM = document.createElement("div"); + const span = document.createElement("span"); + contentDOM.appendChild(span); + const nodeView: NodeView = { + dom: document.createElement("div"), + contentDOM, + }; + + ignoreNonContentMutations(nodeView); + + // Non-content (attribute) mutation is ignored... + expect(nodeView.ignoreMutation!(attributeMutation(span))).toBe(true); + // ...content mutation is read... + expect(nodeView.ignoreMutation!(childListMutation(contentDOM))).toBe(false); + // ...and selection is read. + expect(nodeView.ignoreMutation!(selectionMutation)).toBe(false); + }); + + it("still defers to an existing ignoreMutation for content mutations", () => { + const contentDOM = document.createElement("div"); + const nodeView: NodeView = { + dom: document.createElement("div"), + contentDOM, + // Pretend this node view wants to ignore all of its content mutations. + ignoreMutation: () => true, + }; + + ignoreNonContentMutations(nodeView); + + // A content mutation the filter would read is still ignored by the + // original `ignoreMutation`. + expect(nodeView.ignoreMutation!(childListMutation(contentDOM))).toBe(true); + // Non-content mutations are ignored by the filter regardless. + expect(nodeView.ignoreMutation!(attributeMutation(contentDOM))).toBe(true); + }); +}); diff --git a/packages/core/src/schema/nodeViewMutations.ts b/packages/core/src/schema/nodeViewMutations.ts new file mode 100644 index 0000000000..73305807ab --- /dev/null +++ b/packages/core/src/schema/nodeViewMutations.ts @@ -0,0 +1,46 @@ +import { NodeView, ViewMutationRecord } from "@tiptap/pm/view"; + +// Ignores all mutations, except those which modify content. ProseMirror by default allows for +// bidirectional updates between state & view i.e., mutating the view can cause a state update. +// This means that basically any DOM mutation in a node view will trigger a re-render, which can +// cause issues with certain browser extensions and interactive elements which aren't linked to +// the node or editor state. +export function isNonContentMutation( + mutation: ViewMutationRecord, + contentDOM: HTMLElement | null | undefined, +): boolean { + // Let ProseMirror handle selection changes. + if (mutation.type === "selection") { + return false; + } + + // Ignore all mutations for nodes without content. + if (!contentDOM) { + return true; + } + + // Ignore all changes to DOM attributes. If a DOM attribute value depends on the value of a + // ProseMirror node's attribute, the change should be made to the ProseMirror node directly, + // which will trigger a re-render. We don't propagate changes to the DOM back to the node. + if (mutation.type === "attributes") { + return true; + } + + // Everything left is a `childList` or `characterData` mutation (i.e., a content mutation). Only + // those inside the content DOM are actual content edits that ProseMirror needs to read. + return !contentDOM.contains(mutation.target); +} + +export function ignoreNonContentMutations(nodeView: NodeView): void { + const originalIgnoreMutation = nodeView.ignoreMutation?.bind(nodeView); + const contentDOM = nodeView.contentDOM; + + nodeView.ignoreMutation = (mutation: ViewMutationRecord) => { + if (isNonContentMutation(mutation, contentDOM)) { + return true; + } + + // Defer to the node view's own `ignoreMutation` for additional filtering. + return originalIgnoreMutation ? originalIgnoreMutation(mutation) : false; + }; +}