Asynchronous DOM mutation and CustomNodeView ignoreMutation

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?

It has always been a requirement to define an ignoreMutation method if you’re going to asynchronously change the DOM in your view. Are you saying patch b78f5534d (created in response to the issue you linked) is where this problem started happening for you? I’m having a hard time imagining how that patch could cause such an issue.

It has always been a requirement to define an ignoreMutation method if you’re going to asynchronously change the DOM in your view.

This works; previously when I was statically importing katex, I was synchronously modifying the DOM within the update method so I didn’t run into problems with asynchronously modifying the DOM. Not having an ignoreMutation method was just an oversight on my end.

I’m having a hard time imagining how that patch could cause such an issue.

I may have over-focused on that patch because the commit message fitted my problem almost perfectly, but setting up a reproducible demo, the “problem”* actually starts at prosemirror-view@1.17.8 instead of 1.17.5 as I incorrectly stated earlier.

If you toggle between the dependency versions of 1.17.7 and 1.17.8 on the left hand side, and open the console in chrome dev tool for the html, the former renders only once while the latter enters an infinite loop.

* “problem” because having a ignoreMutation method fixes the asynchronous DOM mutation infinite loop regardless of which versions

Okay, 1.17.8 fixed a problem where some DOM mutations were ignored entirely, so that makes a lot more sense. But I can confirm that this wasn’t a regression, but a bugfix, and the effect, however unfortunate in this case, is expected.

1 Like