Wire CodeMirror 6 into an Angular form without collapsing the caret

One document with an Angular form on the left and a CodeMirror EditorState on the right; a single arrow runs from the editor's state out to the form, and the return arrow is crossed out with the measured cost of taking it — the caret collapsing from offset 10 to 0
COMPANION REPO · LIVE FROM GITHUB

CodeMirror 6 is the editor you reach for when a textarea stops being enough and Monaco is too much to ship. The @codemirror/* packages carry no Angular binding of their own; the project's community page lists a third-party Angular component, and plenty of people mount it by hand instead. Mounting it by hand is where the trouble starts, because the obvious way to do it is the way you would wire an <input>: bind the value in, listen for changes, write the value back.

That does not work, and the reason is not a bug in either library. CodeMirror holds the document itself, in an EditorState, and the guide is direct about how that state moves:

Changes to that state happen in functional code, by creating a transaction that describes the changes to document, selection, or other state fields. Such a transaction can then be dispatched, which tells the view to update its state, at which point it'll synchronize its DOM representation with the new state.

A transaction carries more than the new text. It carries the change set, and every position in the old document maps through that change set into the new one, which is how the caret, the selection, the undo history and any decoration you have added all end up in the right place afterwards. Hand the editor a whole new string and you have thrown that mapping away. A replacement of the entire document sends every position inside it to one end or the other of the replacement, depending on which way the mapping leans, so the change set records nothing about where any of that text went. A caret lands on one end of the replacement. A selection has two boundaries, and CodeMirror maps the lower one forward and the upper one back, so across a replacement of everything the two swap and what comes back runs from the end of the new text to its start.

ONE KEYSTROKE · TWO WIRINGS · WHERE THE CARET ENDS UP
one-way · the editor keeps the document
EDITORSTATE
2
caret at 11
TRANSACTION changes: { from: 11, insert: "2" }
FORM VALUE
write it back · the form replaces the document
EDITORSTATE
2
caret at 11
TRANSACTION, THEN THE ONE COMING BACK changes: { from: 0, to: 13, insert: the whole value }
FORM VALUE
value written back
same keystroke, same text in the form either way — what differs is the range the change covers, and every position maps through it

What the controlled version actually costs

I built both versions side by side to measure rather than argue. Both host the same editor. One writes incoming values over the whole document, the way a controlled <input> would; the other turns an incoming value into the smallest change that would produce it, and dispatches nothing at all when the incoming value already matches what the editor holds.

Put the caret ten characters into the first line, then have something outside the editor change the document:

caret at offset 10, then...            one-way    replace the whole document
a line appended at the end                  10                             0
a header inserted at the top                20                             0

Those are read straight off view.state.selection.main.head before and after, in external-write-and-the-cursor.spec.ts. The 20 is the interesting number: ten characters went in front of the caret, so the offset moved by ten and the caret is still sitting on the same character it was on. That is the change set doing its job. In the right-hand column the caret is at the start of the document, every time, whatever the change was and wherever it landed.

The other failure is louder. If the value comes back into the editor synchronously from inside the update listener, each write is itself a change, so each write runs the listener again:

RangeError: Maximum call stack size exceeded

That is V8's wording and the exact string is not the point; the shape is. The test that produces it counts the recursion depth on the way down and asserts it passed a hundred before the stack gave out. In a real app the write-back is usually asynchronous instead, which turns the stack overflow into a quieter version of the same thing: the editor and the form take turns overwriting each other, and the caret sits at the start of the document the whole time.

The flow that works

The document flows out of the editor and the editor is never told what it already knows.

ngAfterViewInit(): void {
  this.view = new EditorView({
    parent: this.host().nativeElement,
    state: EditorState.create({
      doc: this.value(),
      extensions: [
        lineNumbers(),
        history(),
        keymap.of([...defaultKeymap, ...historyKeymap]),
        javascript(),
        EditorView.updateListener.of((update) => {
          if (!update.docChanged) return;
          this.valueChange.emit(update.state.doc.toString());
        }),
      ],
    }),
  });
}

The value input seeds the document once, at creation. After that it is only consulted for writes that genuinely come from somewhere else — a template loaded from a dropdown, a reset button, a document arriving over a socket:

constructor() {
  effect(() => {
    const next = this.value();
    const view = this.view;
    if (!view) return;
    const change = minimalChange(view.state.doc.toString(), next);
    if (!change) return;
    view.dispatch({ changes: change });
  });
}

minimalChange returns null when the two strings are equal, and that null is what keeps the whole thing still. The effect re-runs every time the form's value changes, including the moment the form echoes back the document the editor just emitted, and on that run there is nothing to dispatch. No dispatch, no update listener, no second emission. The test for it just counts: two edits, two emissions, in order.

When the strings do differ, the helper walks in from both ends and replaces only the middle. Two short functions, thirty-five lines between them, and most of the second one exists because the scan has to step over whole code points rather than UTF-16 units. A prefix scan that counts units stops between the two halves of an emoji and hands CodeMirror a range whose ends fall inside a character: changing one emoji into another gives {from: 6, to: 7} on a seven-unit string, which says "replace the second half of this character with the second half of that one."

I originally wrote that this leaves a lone surrogate in the document. It does not, and I should have checked before writing it. I have now: across 1.7 million random string pairs the naive scan produced a cut range 84,502 times and a broken document zero times. Both inputs are valid strings, so whenever the kept prefix ends on a high surrogate the inserted text begins with its low surrogate and the halves re-pair.

The reason to walk code points is narrower, and it is about the change rather than the result. Everything that consumes the change instead of the final string is handed boundaries that are not character boundaries: position mapping, undo granularity, decoration ranges, anything collaborative. And the re-pairing that saves the document is a property of this exact two-sided scan, not something the next person to touch the helper will preserve.

You can do better than a prefix-suffix diff if your writes are structured, and if you already know what changed you should dispatch that instead. The helper is for the case where all you are handed is a string.

The line nobody writes

The rest of this survives review and then leaks in production, because an EditorView is not only the element it renders into. From the reference manual, on destroy:

Clean up this editor view, removing its element from the document, unregistering event handlers, and notifying plugins. The view instance can no longer be used after calling this.

"Unregistering event handlers" is doing a lot of work in that sentence, so I counted them. The demo wraps addEventListener and removeEventListener on document, on window, on MediaQueryList and on EventTarget.prototype, plus observe and disconnect on the observer prototypes, then mounts one editor and reads the numbers. Mounted four levels deep in a plain page:

document                selectionchange   1
window                  resize            1
window                  scroll            1
matchMedia("print")     change            1
each ancestor element   scroll            1   <- host, wrapper, body, html
MutationObserver on the content element   1

There is no single number, and that is the part worth knowing. The view walks from its content element up through every parent and puts a scroll handler on each one, so the count is a property of your page rather than of the library. Measured on the same editor at three depths: mounted straight into body it puts two of them above itself, on body and html; inside one wrapper, three; inside two, the four in the table.

Three more land on the editor's own elements — the content, the scroller and the editor itself — and those leave with the element Angular removes. The table counts what stays behind on the page.

I got this wrong the first time I wrote it. I counted five, because the census I had written watched only document, window and the print query, and the handlers on body and html are on neither. A census cannot find what it is not looking at, which is a lesson about instrumentation rather than about CodeMirror.

The print handler moves around too. @codemirror/view 6.43.12 puts it on window.matchMedia("print") where matchMedia exists and falls back to a beforeprint handler on the window where it does not, so a census that skips the media query sees one fewer in a browser than under jsdom for the same editor. And a browser carries more still: the same constructor creates a ResizeObserver on the scroller and two IntersectionObservers. jsdom implements neither, so the number a test can assert is smaller than the number a real page actually holds.

Exactly one row above is inside the component's own element, the MutationObserver watching content the editor created. Everything else is attached to something that outlives the component.

Now remove the component without calling destroy. Angular takes the element out of the document, dom.isConnected goes false, and every one of them stays. Nothing about Angular's teardown reaches inside a third-party object it did not create. The selectionchange handler still runs on every selection change in that document, does a couple of checks, and returns, on behalf of an editor nobody can see.

It is worse than idle. Change the text in that detached tree and the view's MutationObserver still fires, still reads the DOM, still builds a transaction, and still moves to a new state:

detached, never destroyed  ->  doc becomes "const zz = 9;\n", listener ran once
detached and destroyed     ->  doc unchanged, listener never ran

Angular is not completely passive, which surprised me. The update listener in that test calls this.valueChange.emit(...), and output() refuses to emit once its component is destroyed:

NG0953: Unexpected emit for destroyed `OutputRef`. The owning directive/component is destroyed.

So the parent's handler is not called. That code has no page in Angular's error encyclopedia as of 22.1.7; I read the text out of @angular/core/fesm2022/_resource-chunk.mjs, where emit warns and returns early. The message body sits behind ngDevMode, so a production build prints the code with no sentence after it. Useful, and it protects exactly one line. Everything before the emit still ran: the view read its DOM, made the transaction, and the listener's closure wrote into a field on a component Angular had already destroyed.

The fix is the line the docs ask for:

ngOnDestroy(): void {
  this.view?.destroy();
  this.view = null;
}

With it, every one of them comes back and the same DOM mutation does nothing at all. The demo asserts that as a number, so the build breaks when it stops being true.

Two things I would check in an existing app

Grep for new EditorView and check that every one of them has a destroy in reach of a teardown hook. Editors tend to live inside dialogs, tabs and routed panels, which are exactly the components that get created and thrown away over and over in one session, so the count does not stay put for long.

Then look at how the value gets in. If there is an effect, a valueChanges subscription or a writeValue that replaces the document unconditionally, the caret jump is already there and somebody has probably reported it as "the editor sometimes jumps to the top" and been told it could not reproduce. It reproduces reliably if you put the caret in the middle of the document first.

The demo is at github.com/gdhami-net/codemirror-angular-demo. npm run check runs nineteen tests: the caret offsets above, where a selection ends up, the census across mount and destroy, the recursion, and a version pin so that an upgrade of CodeMirror or Angular fails the suite instead of quietly changing a number in this post. npm start puts both versions on one page with the live counts underneath, and pressing "append a line from outside" with the caret in the middle of each editor takes about five seconds and is more convincing than the table.