Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/core/src/schema/blocks/createSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/schema/inlineContent/createSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -208,12 +209,19 @@ export function createInlineContentSpec<
editor,
);

return addInlineContentAttributes(
const nodeView = addInlineContentAttributes(
output,
inlineContentConfig.type,
node.attrs as Props<T["propSchema"]>,
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;
};
},
});
Expand Down
121 changes: 121 additions & 0 deletions packages/core/src/schema/nodeViewMutations.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
46 changes: 46 additions & 0 deletions packages/core/src/schema/nodeViewMutations.ts
Original file line number Diff line number Diff line change
@@ -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;
};
}
Loading