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.

I’m having exactly the same situation. Custom node gets destroyed and immediatly created, after new line is passed in above node, whith emplty lines above and below of custom node. It is a bit random as not every new lines causes this issue.

Version: prosemirror-view: 1.33.8

I get the destroy/recreate behavior specifically when I drag a NodeView down, regardless of which or how many nodes surround it above or below. It doesn’t happen when dragging up :thinking:

"prosemirror-commands": "1.6.0",
"prosemirror-dropcursor": "1.8.1",
"prosemirror-gapcursor": "1.3.2",
"prosemirror-history": "1.4.1",
"prosemirror-inputrules": "1.4.0",
"prosemirror-keymap": "1.2.2",
"prosemirror-model": "1.22.3",
"prosemirror-state": "1.4.3",
"prosemirror-transform": "1.10.0",
"prosemirror-view": "1.34.2",

That’s expected—there no guarantee that a node won’t be redrawn when it changes its position in the document. The update code happens to preserve it in the dragging up situation here, but even that isn’t something you can count on.

Ah okay. If I have a custom NodeView that is rendering something expensive and I can’t make guarantees of when it’ll be re-rendered, do you suggest I implement some cache layer then? I’m specifically building a view of panels a user can interact with that render graphs. Though IDK how frequently someone would be moving this nodeview around.

I thought that manually managing the dom state of a NodeView would allow me to avoid this constraint. Is there a possible improvement that can be made so that NodeViews can opt out of being destroy and re-created when only their position changes?

For reference, it looks like this at the moment. I’m using the nodeview such that I can allow GridStack to handle the dom updates within the nodeview

Not really. As far as ProseMirror is concerned nodes do not have identity, and what’s happening is one node being deleted and another one being created. You could work around this by caching DOM elements, if you’re careful (to not, say, try and return the same element twice when a given node occurs multiple times in the document). But for the panels in the screenshot, I’m not sure why rendering those would be all that expensive.

Hmm okay that makes sense but I’m still confused as to why the drag up doesn’t trigger it. Can you point me in the direction of where I can read about this behavior or the code paths I could review?

I don’t have the graphs rendering yet as it’s a WIP. But you could imagine a potential tens of thousands of points per graph. Those would be created after a potentially expensive network request, then some data manipulation to shape the network data into something usable by the graphing package, then rendering. So destroying that work isn’t ideal.

I have a few potential optimizations outside of prosemirror such as caching the network request, which would likely suffice for most cases, but was really hoping to optimize at the source.