Invalid child in posBefore (CodeMirror6)

I have an issue where when I update the document and subsequently try to move my caret with the mouse I get the following error:

Uncaught RangeError: Invalid child in posBefore
at Proxy.posBefore (index.js:281)
at LineView.get posAtStart [as posAtStart] (index.js:269)
at findPositionSide (index.js:3289)
at queryPos (index.js:3310)
at basicMouseSelection (index.js:3324)
at handlers.mousedown (index.js:3258)
at HTMLDivElement.eval (index.js:2956)
posBefore @ index.js:281
get posAtStart @ index.js:269
findPositionSide @ index.js:3289
queryPos @ index.js:3310
basicMouseSelection @ index.js:3324
handlers.mousedown @ index.js:3258
eval @ index.js:2956

I can still move the caret with the keys with no problem (though when I go up or down lines it takes me back to the start of the row), but throws an error when trying to use the mouse.

I am updating the entirety of the document when updated externally like such:

const updateCode = (newCode) => {
      const transaction = CMEditor.value.state.update({
          changes: {
              from: 0, 
              to: CMEditor.value.state.doc.length, 
              insert:newCode
          },
          selection: EditorSelection.cursor(0)
      });
      CMEditor.value.update([transaction]);
}

I thought maybe the selection was getting invalidated when updating so I tried to add a selection update to position 0 on update but it still throws the error.

Any suggestions or thoughts?

Might well be a bug in the library. Can you boil it down to a simple script / instructions that would allow me to reproduce this?

**(at the bottom are what I suspect are the simplest steps to repeat, but including additional info in case you find it useful)

I’m using it with VueJS. Below I removed most of the interactions with props and external components, etc… since it seems to be throwing the same error just changing the code with a test button I inserted. I’m using a debounce with the updateListener.of but I’m pretty sure it has nothing to do with that.

Also note:

  • the doc I set to props.code, but it happens just the same if I use standard text
  • interestingly when I try to select anything or move the caret in the rows that were changed, it throws the error. If I press enter and type new text and try to select it, then it correctly moves the caret with no errors as I would expect.
  • After playing with it a bit I’m suspecting there is an issue when code gets inserted there may be cases where internal positioning states are not correctly updating to accommodate the newly inserted code

I’m using the following versions:

@codemirror/autocomplete”: “^0.18.7”,
@codemirror/basic-setup”: “^0.18.2”,
@codemirror/closebrackets”: “^0.18.0”,
@codemirror/commands”: “^0.18.3”,
@codemirror/comment”: “^0.18.1”,
@codemirror/fold”: “^0.18.1”,
@codemirror/gutter”: “^0.18.4”,
@codemirror/highlight”: “^0.18.4”,
@codemirror/history”: “^0.18.1”,
@codemirror/lang-css”: “^0.18.0”,
@codemirror/lang-html”: “^0.18.1”,
@codemirror/lang-javascript”: “^0.18.0”,
@codemirror/language”: “^0.18.2”,
@codemirror/lint”: “^0.18.4”,
@codemirror/matchbrackets”: “^0.18.0”,
@codemirror/rectangular-selection”: “^0.18.0”,
@codemirror/search”: “^0.18.4”,
@codemirror/state”: “^0.18.7”,
@codemirror/view”: “^0.18.17”,
“vue”: “^3.1.1”,

<template>
    <button @click="testcodechange">Change Content</button>
    <div :id="my-editor" class="cm-editor-wrapper" style="width:100%;height:100%"></div>
</template>

<script>
    import { ref, onMounted, watch} from 'vue';

    import { debounce } from '@/embedded_assets/js/debounce';

    import { EditorView, ViewUpdate, keymap, highlightSpecialChars, drawSelection, highlightActiveLine } from "@codemirror/view";
    import { EditorState, Extension, Compartment, EditorSelection } from "@codemirror/state";
    import { history, historyKeymap } from "@codemirror/history"
    import { foldGutter, foldKeymap } from "@codemirror/fold"
    import { indentOnInput } from "@codemirror/language"
    import { lineNumbers, highlightActiveLineGutter } from "@codemirror/gutter"
    import { defaultKeymap, defaultTabBinding } from "@codemirror/commands"
    import { bracketMatching } from "@codemirror/matchbrackets"
    import { closeBrackets, closeBracketsKeymap } from "@codemirror/closebrackets"
    import { searchKeymap, highlightSelectionMatches } from "@codemirror/search"
    import { autocompletion, completionKeymap } from "@codemirror/autocomplete"
    import { commentKeymap } from "@codemirror/comment"
    import { rectangularSelection } from "@codemirror/rectangular-selection"
    import { defaultHighlightStyle } from "@codemirror/highlight"
    import { lintKeymap } from "@codemirror/lint"

    import { html } from "@codemirror/lang-html";
    import { defaultLight } from "./themes/default-light";

    export default {
        props:{
            code:{
                default: "<p>Hello World</p>"
            },
            debounceTime:{
                default: 0
            }
        },
        setup(props, ctx){
            //Create the debounce function for emitting updates out of the editor
            const debouncedEmitter = debounce((v)=>{
                if(props.code != v.state.doc.toString()){
                    ctx.emit("update:code", v.state.doc.toString());
                }
            }, props.debounceTime);

            //Generate the editor
            const CMEditor = ref(null);

            //Must be on mount because it needs to get attached to the parent which is not 
            //generated prior to setup running
            onMounted(()=>{
                CMEditor.value = new EditorView({
                    parent: document.getElementById("my-editor"),
                    state: EditorState.create({
                        doc: props.code,
                        extensions:[
                            lineNumbers(),
                            EditorView.updateListener.of(debouncedEmitter),
                            highlightActiveLineGutter(),
                            highlightSpecialChars(),
                            history(),
                            foldGutter(),
                            drawSelection(),
                            EditorState.allowMultipleSelections.of(false),
                            indentOnInput(),
                            defaultHighlightStyle.fallback,
                            bracketMatching(),
                            closeBrackets(),
                            autocompletion(),
                            rectangularSelection(),
                            highlightActiveLine(),
                            highlightSelectionMatches(),
                            keymap.of([
                                ...closeBracketsKeymap,
                                ...defaultKeymap,
                                ...searchKeymap,
                                ...historyKeymap,
                                ...foldKeymap,
                                ...commentKeymap,
                                ...completionKeymap,
                                ...lintKeymap,
                                defaultTabBinding
                            ]),
                            html(),
                            //theme
                            defaultLight,
                        ]
                    })
                });
            })
            
            //needs to reset cursor position on update because it will throw an error
            const updateCode = (newCode) => {
                const codeTransaction = CMEditor.value.state.update({
                    changes: {
                        from: 0, 
                        to: CMEditor.value.state.doc.length, 
                        insert:newCode,
                    }, 
                });
                CMEditor.value.update([codeTransaction]);
                
            }

           //code to run for testing purposes
            const testcodechange = () => {
                console.log("changing");
                updateCode("test");
            }

            return { CMEditor, updateCode, testcodechange};
        },
    }
</script>

<style>
    .cm-editor-wrapper > .cm-editor{
        width: 100%;
        height: 100%;
        
    }
    .cm-editor-wrapper .cm-scroller { 
        scrollbar-width: 15px;
    }   
</style>

Boiling it down:
I think all that’s required is:

  • Create an editor
  • Set the initial state doc using a string or variable
  • Create a code transaction (I did it using the entire size of the editor but I suspect it will work regardless of size) using from, to, insert
  • Update the editor using the code transaction
  • Try either selecting text or moving the caret anywhere in the rows with the inserted code

Including where in the codemirror code the error was thrown in case that helps:

class ContentView {
    constructor() {
        this.parent = null;
        this.dom = null;
        this.dirty = 2 /* Node */;
    }
    get editorView() {
        if (!this.parent)
            throw new Error("Accessing view in orphan content view");
        return this.parent.editorView;
    }
    get overrideDOMText() { return null; }
    get posAtStart() {
        return this.parent ? this.parent.posBefore(this) : 0;
    }
    get posAtEnd() {
        return this.posAtStart + this.length;
    }
    posBefore(view) {
        let pos = this.posAtStart;
        for (let child of this.children) {
            if (child == view)
                return pos;
            pos += child.length + child.breakAfter;
        }
        throw new RangeError("Invalid child in posBefore");
    }
    posAfter(view) {
        return this.posBefore(view) + view.length;
    }
    // Will return a rectangle directly before (when side < 0), after
    // (side > 0) or directly on (when the browser supports it) the
    // given position.
    coordsAt(_pos, _side) { return null; }
    sync(track) {
        var _a;
        if (this.dirty & 2 /* Node */) {
            let parent = this.dom, pos = null;
            for (let child of this.children) {
                if (child.dirty) {
                    let next = pos ? pos.nextSibling : parent.firstChild;
                    if (!child.dom && next && !((_a = ContentView.get(next)) === null || _a === void 0 ? void 0 : _a.parent))
                        child.reuseDOM(next);
                    child.sync(track);
                    child.dirty = 0 /* Not */;
                }
                if (track && track.node == parent && pos != child.dom)
                    track.written = true;
                syncNodeInto(parent, pos, child.dom);
                pos = child.dom;
            }
            let next = pos ? pos.nextSibling : parent.firstChild;
            if (next && track && track.node == parent)
                track.written = true;
            while (next)
                next = rm(next);
        }
        else if (this.dirty & 1 /* Child */) {
            for (let child of this.children)
                if (child.dirty) {
                    child.sync(track);
                    child.dirty = 0 /* Not */;
                }
        }
    }
    reuseDOM(_dom) { return false; }
    localPosFromDOM(node, offset) {
        let after;
        if (node == this.dom) {
            after = this.dom.childNodes[offset];
        }
        else {
            let bias = maxOffset(node) == 0 ? 0 : offset == 0 ? -1 : 1;
            for (;;) {
                let parent = node.parentNode;
                if (parent == this.dom)
                    break;
                if (bias == 0 && parent.firstChild != parent.lastChild) {
                    if (node == parent.firstChild)
                        bias = -1;
                    else
                        bias = 1;
                }
                node = parent;
            }
            if (bias < 0)
                after = node;
            else
                after = node.nextSibling;
        }
        if (after == this.dom.firstChild)
            return 0;
        while (after && !ContentView.get(after))
            after = after.nextSibling;
        if (!after)
            return this.length;
        for (let i = 0, pos = 0;; i++) {
            let child = this.children[i];
            if (child.dom == after)
                return pos;
            pos += child.length + child.breakAfter;
        }
    }
    domBoundsAround(from, to, offset = 0) {
        let fromI = -1, fromStart = -1, toI = -1, toEnd = -1;
        for (let i = 0, pos = offset, prevEnd = offset; i < this.children.length; i++) {
            let child = this.children[i], end = pos + child.length;
            if (pos < from && end > to)
                return child.domBoundsAround(from, to, pos);
            if (end >= from && fromI == -1) {
                fromI = i;
                fromStart = pos;
            }
            if (pos > to && child.dom.parentNode == this.dom) {
                toI = i;
                toEnd = prevEnd;
                break;
            }
            prevEnd = end;
            pos = end + child.breakAfter;
        }
        return { from: fromStart, to: toEnd < 0 ? offset + this.length : toEnd,
            startDOM: (fromI ? this.children[fromI - 1].dom.nextSibling : null) || this.dom.firstChild,
            endDOM: toI < this.children.length && toI >= 0 ? this.children[toI].dom : null };
    }
    markDirty(andParent = false) {
        if (this.dirty & 2 /* Node */)
            return;
        this.dirty |= 2 /* Node */;
        this.markParentsDirty(andParent);
    }
    markParentsDirty(childList) {
        for (let parent = this.parent; parent; parent = parent.parent) {
            if (childList)
                parent.dirty |= 2 /* Node */;
            if (parent.dirty & 1 /* Child */)
                return;
            parent.dirty |= 1 /* Child */;
            childList = false;
        }
    }
    setParent(parent) {
        if (this.parent != parent) {
            this.parent = parent;
            if (this.dirty)
                this.markParentsDirty(true);
        }
    }
    setDOM(dom) {
        this.dom = dom;
        dom.cmView = this;
    }
    get rootView() {
        for (let v = this;;) {
            let parent = v.parent;
            if (!parent)
                return v;
            v = parent;
        }
    }
    replaceChildren(from, to, children = none$3) {
        this.markDirty();
        for (let i = from; i < to; i++) {
            let child = this.children[i];
            if (child.parent == this)
                child.parent = null;
        }
        this.children.splice(from, to - from, ...children);
        for (let i = 0; i < children.length; i++)
            children[i].setParent(this);
    }
    ignoreMutation(_rec) { return false; }
    ignoreEvent(_event) { return false; }
    childCursor(pos = this.length) {
        return new ChildCursor(this.children, pos, this.children.length);
    }
    childPos(pos, bias = 1) {
        return this.childCursor().findPos(pos, bias);
    }
    toString() {
        let name = this.constructor.name.replace("View", "");
        return name + (this.children.length ? "(" + this.children.join() + ")" :
            this.length ? "[" + (name == "Text" ? this.text : this.length) + "]" : "") +
            (this.breakAfter ? "#" : "");
    }
    static get(node) { return node.cmView; }
}
// Try to determine, for the given coordinates, associated with the
// given position, whether they are related to the element before or
// the element after the position.
function findPositionSide(view, pos, x, y) {
    let line = LineView.find(view.docView, pos);
    if (!line)
        return 1;
    let off = pos - line.posAtStart;
    // Line boundaries point into the line
    if (off == 0)
        return 1;
    if (off == line.length)
        return -1;
    // Positions on top of an element point at that element
    let before = line.coordsAt(off, -1);
    if (before && inside(x, y, before))
        return -1;
    let after = line.coordsAt(off, 1);
    if (after && inside(x, y, after))
        return 1;
    // This is probably a line wrap point. Pick before if the point is
    // beside it.
    return before && insideY(y, before) ? -1 : 1;
}
function queryPos(view, event) {
    let pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
    if (pos == null)
        return null;
    return { pos, bias: findPositionSide(view, pos, event.clientX, event.clientY) };
}

Is it possible to reproduce this without Vue? The Proxy.posBefore part in the stack trace is very suspicious (there’s nothing named Proxy in the CodeMirror codebase). If Vue is adding magic to CodeMirror library objects, that’s just not something that’s supported.

I’ll check it out and play with it outside of Vue and let you know.

It appears there may be something going on with it playing with Vue. I did a more simple test and it does not appear to be having the issue

import { debounce } from '@/debounce'
import { EditorView, ViewUpdate, keymap, highlightSpecialChars, drawSelection, highlightActiveLine } from "@codemirror/view";
import { EditorState, Extension, Compartment, EditorSelection } from "@codemirror/state";
import { history, historyKeymap } from "@codemirror/history"
import { foldGutter, foldKeymap } from "@codemirror/fold"
import { indentOnInput } from "@codemirror/language"
import { lineNumbers, highlightActiveLineGutter } from "@codemirror/gutter"
import { defaultKeymap, defaultTabBinding } from "@codemirror/commands"
import { bracketMatching } from "@codemirror/matchbrackets"
import { closeBrackets, closeBracketsKeymap } from "@codemirror/closebrackets"
import { searchKeymap, highlightSelectionMatches } from "@codemirror/search"
import { autocompletion, completionKeymap } from "@codemirror/autocomplete"
import { commentKeymap } from "@codemirror/comment"
import { rectangularSelection } from "@codemirror/rectangular-selection"
import { defaultHighlightStyle } from "@codemirror/highlight"
import { lintKeymap } from "@codemirror/lint"
import { html } from "@codemirror/lang-html";
import { defaultLight } from "./theme/default-light";

console.log("Loading CM6");

const CMEditor = new EditorView({
    parent: document.getElementById("cm6"),
    state: EditorState.create({
        doc: "<p>Hello World</p>",
        extensions:[
            lineNumbers(),
            highlightActiveLineGutter(),
            highlightSpecialChars(),
            history(),
            foldGutter(),
            drawSelection(),
            EditorState.allowMultipleSelections.of(true),
            indentOnInput(),
            defaultHighlightStyle.fallback,
            bracketMatching(),
            closeBrackets(),
            autocompletion(),
            rectangularSelection(),
            highlightActiveLine(),
            highlightSelectionMatches(),
            keymap.of(
                ...closeBracketsKeymap,
                ...defaultKeymap,
                ...searchKeymap,
                ...historyKeymap,
                ...foldKeymap,
                ...commentKeymap,
                ...completionKeymap,
                ...lintKeymap,
                defaultTabBinding
            ),
            html(),
            defaultLight
        ]
    })
})

const updateCode = (newCode)=>{
    const codeTransaction = CMEditor.state.update({
        changes: {
            from: 0, 
            to: CMEditor.state.doc.length, 
            insert:newCode,
        }, 
    });
    CMEditor.update([codeTransaction]);
}

updateCode("This is a test");

Thanks for taking a look anyway. Understood it’s not a directly supported use case, though I do suspect using Vue with this would be a pretty common use case, so I’ll play around and see if there’s something obvious I can find that’s going on.

At the very least if Vue is doing something funny and I can identify what I can place it here for others to find

After you pointed me to the fact that it’s probably issues with VueJS, I started doing more testing and it turns out that the editor does not play well with the Vue refs.

Vue refs are used to help keep reactivity while using the composition API.

Solution: Do not wrap the editor instance with ref! As soon as I removed this it started working again.

You can assign the doc and other values within the editor to a ref, which should be fine, but the editor itself cannot be a ref.

Good to know. That kind of invasive magic is likely to break other things as well (and is going to be rather inefficient even in the best case).