I implemented a math block node view a while back. This is the node spec:
get schema(): NodeSpec {
return {
attrs: { lang: { default: "stex" }},
content: "text*",
marks: "",
group: "block",
code: true,
defining: true,
selectable: true,
parseDOM: [{ tag: "div.mathblock"}],
toDOM: (node: PMNode) => ["div", {class: "mathblock"}, 0]
};
}
And this is the custom node view:
import Node, { CustomNodeViewProps } from '../Node'
import type { Node as PMNode } from "prosemirror-model";
import type { NodeView } from 'prosemirror-view';
import { copyToClipboard } from "../../utils"
const katexOptions = {
displayMode: true,
throwOnError: false,
errorColor: "#FF6666",
}
export default class MathBlockNodeView implements NodeView {
dom: HTMLDivElement;
contentDOM: HTMLElement;
node: PMNode;
render: HTMLDivElement;
katex: typeof import('katex');
constructor (props: CustomNodeViewProps) {
this.node = props.node;
this.dom = document.createElement("div")
this.dom.classList.add("mathblock")
let render = this.dom.appendChild(document.createElement("div"));
render.classList.add("katex-render")
render.contentEditable = 'false';
render.onclick = () => copyToClipboard(this.node.textContent);
render.setAttribute("aria-label", 'click to copy')
this.render = render;
this.renderLaTeX();
let editor = this.dom.appendChild(document.createElement("pre"));
editor.classList.add("katex-editor")
this.contentDOM = editor.appendChild(document.createElement("code"));
this.contentDOM.spellcheck = false;
}
update (node: PMNode) {
if (node.type !== this.node.type) return false;
if (node.textContent !== this.node.textContent) this.renderLaTeX(node.textContent);
this.node = node;
return true;
}
renderLaTeX (text?: string) {
text = text || this.node.textContent;
if (this.katex) {
this.render.innerHTML = this.katex.renderToString(text, katexOptions);
} else {
import("katex").then(module => { this.katex = module; this.renderLaTeX(); });
}
}
// Needed only after prosemirror-view@1.17.5
ignoreMutation (mutation: MutationRecord) {
return mutation.type == "childList" && mutation.target == this.render;
}
}
To avoid unnecessarily shipping the katex
module to the browser, I dynamically import only when a math block node view is created.
Previously, before updating to prosemirror-view@1.17.5
, this node view worked without the custom ignoreMutation
method.
After updating and without the ignoreMutation
method, the node view was encountering an infinite DOM observer flushing loop. The stack trace is as follows:
DOMObserver.observer (index.js?703f:3057)
childList (async)
renderLaTeX (mathblock-nodeview.ts?d612:38)
eval (mathblock-nodeview.ts?d612:43)
Promise.then (async)
renderLaTeX (mathblock-nodeview.ts?d612:41)
MathBlockNodeView (mathblock-nodeview.ts?d612:20)
customNodeView (index.ts?1211:61)
mathblock (editor.ts?a4a4:161)
create (index.js?703f:1221)
addNode (index.js?703f:1821)
eval (index.js?703f:1304)
iterDeco (index.js?703f:1938)
updateChildren (index.js?703f:1287)
updateInner (index.js?703f:1379)
update (index.js?703f:1371)
updateStateInner (index.js?703f:4778)
updateState (index.js?703f:4730)
flush (index.js?703f:3201)
DOMObserver.observer (index.js?703f:3067)
childList (async)
// repeat...
I narrowed the issue down to this line which asynchronously modifies the dom on the first time katex
is imported:
this.render.innerHTML = this.katex.renderToString(text, katexOptions);
Removing this line avoided the flushing loop but also disabled LaTeX rendering.
Parsing through the changelogs, I stumbled upon the bug fix for 1.17.5
which seemed to fit the problem specifically.
Fix an issue where the view could go into an endless DOM flush loop in specific circumstances involving asynchronous DOM mutation.
which was created in response to Browser crash from infinite selection loop · Issue #1126 · ProseMirror/prosemirror · GitHub. Opening up Chrome Dev Tools, I saw that after 1.17.5
that asynchronous rendering of LaTeX resulted in a mutation in DOMObserver.observer
that was detected before.
new window.MutationObserver(function (mutations) {
// MutationRecord {type: "childList", target: div.katex-render, addedNodes: NodeList(1), removedNodes: NodeList(0), previousSibling: null, …}
for (var i = 0; i < mutations.length; i++) { this$1.queue.push(mutations[i]); }
if (result.ie && result.ie_version <= 11 && mutations.some(
function (m) { return m.type == "childList" && m.removedNodes.length ||
m.type == "characterData" && m.oldValue.length > m.target.nodeValue.length; }))
{ this$1.flushSoon(); }
else
{ this$1.flush(); }
});
As a result, ignoreMutation
for mutation.type == "childList" && mutation.target == this.render;
was not needed before 1.17.5
and is needed afterwards.
The question is, was ignoreMutation
required all along with the custom node view since we’re having an external library katex
modify part of the node view’s dom, or is this a regression within prosemirror-view
?
This infinite DOM observer flushing loop does not exist if we statically import katex
, and if the first render and mutation is done synchronously.
I should note that the mutation.type == "childList" && mutation.target == this.render;
only occurs once after katex
is imported since we cache the module in the node view to avoid further dymamically importing and having to asynchronously modify the dom (although asynchronous LaTeX rendering might be better for performance so keystrokes aren’t blocked).
If that’s the case of always asynchronously rendering, should ignoreMutation
always be used when a custom node view asynchronously modifies the dom with prosemirror?