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.