Custom Node View Unexpectedly Destroyed and Recreated during Reconciliation

Summary

I’m experiencing an issue where a custom node view is destroyed and recreated when certain edits occur in preceding nodes. I don’t have a self-contained sample reproduction yet, but I have identified the underlying cause and wanted to first see if anyone has experienced something similar, has any workaround, or whether my thoughts below are worth opening a PR for (which I’m happy to do).

Related

Recent post about a similar issue: Node decorations before custom node view causes rerender

Recent changes to relevant part of prosemirror-view: Improve pre-matching in DOM updates · ProseMirror/prosemirror-view@31e7cdf · GitHub, Increase bounds check for children in reconcilliation back to 5 by seanchambo · Pull Request #113 · ProseMirror/prosemirror-view · GitHub

I don’t think either of these account for what I’m experiencing.

Details

I have a custom node view that renders a block element, which is a div containing an iframe. I don’t want this node to be destroyed and recreated on the DOM when other parts of the document change, since that would cause the iframe to reload.

If I have several blank paragraphs before the div/iframe, and at least one blank paragraph after, then if I add a new blank paragraph before the div/iframe and within a few lines of it, the div/iframe node is destroyed and recreated unexpectedly.

Diagnosis

I’ve traced this down to the behavior of prosemirror-view’s ViewDescription reconciliation logic, and in particular to ViewTreeUpdater.prototype.findNodeMatch (prosemirror-view/viewdesc.js at 49ecad1ad1e0e7cf750a1d22322010a007be1479 · ProseMirror/prosemirror-view · GitHub).

What’s happening is this: When the new blank paragraph is inserted shortly before the div/iframe, the reconciliation looks ahead up to 5 nodes to find a match. Since there’s a blank paragraph after the div/iframe, it treats that as a match, destroying everything in between.

To confirm that’s the case, I locally patched prosemirror-view/viewdesc.js at 49ecad1ad1e0e7cf750a1d22322010a007be1479 · ProseMirror/prosemirror-view · GitHub and replaced the lines I highlighted with the following:

      for (let i = this.index, e = Math.min(children.length, i + 5); i < e; i++) {
        let child = children[i]
        if (child.matchesNode(node, outerDeco, innerDeco) && !this.preMatch.matched.has(child)) {
          found = i
          break
        }

        if (child instanceof CustomNodeViewDesc) {
            break;
        }
      }

This treats custom node views as “expensive” and prevents them from being skipped over (and possibly destroyed) during the matching. Of course, I don’t think this is an appropriate patch as is, it’s just meant to illustrate my current understanding of the issue.

Solutions

What’s the appropriate way to address this?

One idea is making the above patch a bit more generic and opt-in. Block nodes could have a property indicating whether they are “expensive” (default false), and the reconciliation logic would try not to destroy expensive block nodes unnecessarily, as per the patch. (Of course, we’d need to think about whether “expensiveness” needs to cascade up the hierarchy.) I’d be happy to open a PR for this as long as the approach is agreed upon.

Other Observations

The related discussion I mentioned above (Node decorations before custom node view causes rerender) is for a cool looking open source project (Curvenote · GitHub). That project doesn’t seem to be experiencing this issue out-of-the-box. I’ve identified the reason why. It has a plugin to decorate the current paragraph with a placeholder class. When a new paragraph is inserted, it’s given that decoration, therefore during reconciliation it cannot match against the empty paragraphs after the iframe, which don’t have the decoration. Running that project and disabling this plugin results in the same behavior I experience above.

Versions

  • prosemirror-model: 1.15.0
  • prosemirror-state: 1.3.4
  • prosemirror-view: 1.23.1

Full disclosure: I’m using ProseMirror through tiptap (https://tiptap.dev/), so it’s certainly possible that something in my custom node implementation via tiptap, or in tiptap itself, is fully or partially to blame.

3 Likes

When the change is in front of the node view only, the preMatch.matched.has check should prevent the node view from being discarded here – unless pre-matching is somehow failing, I just patched a problem like that in 609880b. If that patch doesn’t help for you, is it possible to set up a minimal script that shows the problem occurring, using raw ProseMirror? That’d allow me to debug what’s going on, and why the intended mechanism to prevent this is failing.

Thanks for the reply.

I ended up going down a different path which obviated my immediate need to have iframe nodes in the document. I may still see if I can create a minimal test case, but unfortunately it’s no longer a priority for me at the moment because I’m not blocked on it anymore.

That said, I did have some observations about prematching when I was first debugging this which may be helpful. The issue I described occurs when the document is first rendered and after the first few edits, but after a while (exact steps unclear), the issue would stop. I was able to see in debugging logs that it did have to do with prematching. When the document was first rendered, the prematch path didn’t kick in, but later, it did. Perhaps this has to do with the prematch data structure not being populated initially (either by a bug in ProseMirror or something the wrapper library I’m using, tiptap, has failed to do – I haven’t looked closely).

For example, I remember a case with a document more or less like this:

<p>Begin</p>
<p></p>
<p></p>
<iframe />
<p></p>
<p></p>
<p>End</p>

If I loaded this document, then inserted a new paragraph between the first and second blank ones, the iframe node would be destroyed and recreated, and I could see in the debugging log it was because my newly inserted empty paragraph was being matched against the empty paragraph after the the iframe. Repeating this several times, and each time it would be destroyed and recreated. But surprisingly, after a few more new empty paragraphs (6 or 7, IIRC), the iframe would no longer be destroyed and recreated. And again, I could see in the debugging that it had to do with the prematch logic.

Which version of prosemirror-view are you using? If I set up a scenario like this with the current 1.23.1, the node between the empty paragraphs gets reused.

Interesting. I’m using prosemirror-view 1.23.1 as well.

Perhaps this weekend I’ll see if I can create a minimal test case.