Custom node implementation cause incorrect node split?

I’m implementing an editor that supports math equations:

    equation: {
      group: "block",
      content: "text*",
      atom: true,
      marks: "",
      toDOM(node) {
        return ["equation", 0]
      },
      parseDOM: [{ tag: "equation" }]
    },

My implementation seems to work, but I ran into a strange issue, If I move the mouse cursor from an equation block to the above paragraph line, and if the cursor is at the beginning of that line and I hit “enter”.

I expect a new text line will be created above. Instead, a new equation block will be created mysteriously below. And the content of the equation block comes from the text line.

I captured the behavior in the following screen recoding.

notice that when I moved the cursor in front of the “sdf” line above the equation block and hit enter. The “sdf” line is disappeared and a new equation block is created with the content “sdf”.

The expected behavior would be a new empty line be created above “sdf”.

output

I debugged the prose mirror’s code a bit. It seems to be caused by a block-splitting logic. When the prose mirror determines that the text decorations are inconsistent, it will split the text block. But I don’t understand why it involves my equation block.

here is how I update my equation block

    deselectNode() {
        this.input.blur();
        let nn = textSchema.text(this.input.value);
        setTimeout(() => {
            let tr = this.outerView.state.tr.replaceWith(this.getPos() + 1, this.getPos()+1 + this.node.nodeSize - 2, [nn]);
            this.outerView.dispatch(tr);
        }, 100);
    }

When my equation editor is unfocused, I will update its textContent with

            let tr = this.outerView.state.tr.replaceWith(this.getPos() + 1, this.getPos()+1 + this.node.nodeSize - 2, [nn]);
            this.outerView.dispatch(tr);

I’m not confident with the above code. I tested that if I comment out the above code, the issue will be gone. But I can’t tell what’s wrong with my above code.

My intention is that given an equation block: xxx, I will select the entire text content xxx and replace it with the new content.

Anyway to solve this?

I debugged this issue further,

it seems to be this function in prosemirror-view which fails to find a match that caused the node split:

findNodeMatch(node, outerDeco, innerDeco, index) {
        let found = -1, targetDesc;
        if (index >= this.preMatch.index && ...){
// in the failed case, index = 7 and this.preMatch.index = 8; we skipped this branch. and returned "not found"
}
}

This is the key function that causes this behavior. it contains an if condition with 4 branches. for the correct behavior, we should hit the first branch. for the buggy behavior, since findMatchNode fails, we hit the last branch and create a new node.


// Syncs `this.children` to match `this.node.content` and the local
    // decorations, possibly introducing nesting for marks. Then, in a
    // separate step, syncs the DOM inside `this.contentDOM` to
    // `this.children`.
    updateChildren(view, pos) {
       ...
        iterDeco(this.node, this.innerDeco, (widget, i, insideNode) => {
           ...
        }, (child, outerDeco, innerDeco, i) => {
           ...
            if (updater.findNodeMatch(child, outerDeco, innerDeco, i)) {
               // for the correct behavior, we should go through this branch,
            }
            else if (compositionInChild && view.state.selection.from > off &&
                view.state.selection.to < off + child.nodeSize &&
                (compIndex = updater.findIndexWithChild(composition.node)) > -1 &&
                updater.updateNodeAt(child, outerDeco, innerDeco, compIndex, view)) {
     
            }
            else if (updater.updateNextNode(child, outerDeco, innerDeco, view, i)) {
            
            }
            else {
              // for the incorrect behavior, we go into this branch and create a new node.
                // Add it as a new view
                updater.addNode(child, outerDeco, innerDeco, view, off);
            }
          ...
        });

I have some new discovery, in this function, prosemirror does a diff algorithm to compare the new document and the old document:

function preMatch(frag, parentDesc) {
      if (node != frag.child(fI - 1))
            break;
}

the node != frag.child(fl-1) line is true for two nodes that look the same to me (i.e. two same equation nodes). I don’t know why.

I changed the above code to the following, and it seem to solve my issue

        if (JSON.stringify(node) !== JSON.stringify(frag.child(fI - 1)))
            break;

summary: in prosemirror-view code, there is a preMatch function. This function compares two arrays of nodes and finds out how many of the nodes are the same.

In my case, two custom nodes (equation nodes) should be the same, but the node != frag.child(fI-1) comparison of the two nodes returns true for some reason.

I worked around this issue by JSON.stringify both of them and then compare. I know this might not be right.

I couldn’t reproduce this in order to debug it. Could you provide a full (but minimal) example script that shows the issue?

Yes, please visit this link for the simplified reproducible version GitHub - shi-yan/limpid_bug

Run npm run dev

To reproduce, type some text into the editor (the paragraph area, not the title area) first.

then click on the Eq button to create an equation.

Upon creation, the equation box should be selected and in the editing mode (When the equation box is selected, it is in the editing mode, when the equation box is unselected, it is in the display mode, i.e. the latex code will be rendered into equations).

move the cursor up till it is outside the equation box and see the equation being rendered (by pressing the keyboard ArrowUp key, not using the mouse) i.e. the cursor is in the line of text above the equation box, and now hit enter.

if it doesn’t reproduce (the first time is usually fine, it usually reproduces on the second time), repeat this a few times (move the cursor into the equation box, move out of it, hit enter).

The buggy behavior: new equation boxes will be created by hitting enter on the line above an equation box (you will need to move the cursor in and out of the equation box before hitting enter).

I’m using the latest chrome on mac.

screen capture:

outputs

When i try to run that and follow the instructions I get an error about katex being undefined.

That being said, the problem is probably right next to that crash. Read the docs for NodeView.update a bit more closely.

Thank you so much for looking at my code. I feel bad if this is actually my bug and I wasted your time.

I checked the doc again and compared my code with your examples. I noticed that you check node type and only accept nodes of the same type.

I modified my code to do the same. Well, the above issue seems to be gone, but I still run into strange behavior occasionally. When I hit enter above my equation box, most of the time, it will result in a new line being inserted above the current line, but sometimes, the equation box will be selected by this action. While this won’t mess up the document, I think this is still a user experience issue. But at this time, I don’t know how to reliably reproduce it.

And as a library user, I sometimes find the document not sufficient. Use the update function’s doc as an example:

When given, this will be called when the view is updating itself. It will be given a node (possibly of a different type), an array of active decorations around the node (which are automatically drawn, and the node view may ignore them if it isn’t interested in them), and a decoration source that represents any decorations that apply to the content of the node (which again may be ignored). It should return true if it was able to update to that node, and false otherwise. If the node view has a contentDOM property (or no dom property), updating its child nodes will be handled by ProseMirror.

Why does it receive a node of a different type under what circumstances? is the update always triggered by the NodeView, or it can be triggered by other events? How to handle a node of a different type, should I always ignore it and return false? if true, then why do we call update when node.type’s mismatches?

A node view never triggers its own update. When the editor updates its document DOM, it will call update to sync the node view with a changed node.

Usually just return false. But it’s possible to have node views that can represent (and update themselves to) different types of nodes.