(Edited for brevity)
I’ve gone through how the work was done in great detail, because with respect, you seem to dislike AI code, and think it’s effortless. I want you to be able to see that I did work carefully, especially given how short the end result it.
If you want to skip to the code: Pull Request #84
By diffing .2 .. **.5**we get a patch over which the defect must live. You can constrain **.2**out of it with the repro, so it’s actually in .3 .. .5 .
The regression is 85948a6: the new if (!this.openWidget) guard suppresses the trailing line-start in a case where it’s still needed (our whole-document mark decoration), leaving the tile tree one line short.
After analysis to make sure we don’t regress what the original patch’s goal was,
The commit confirms it: 85948a6 fixed dev issue #1717 (“could corrupt the editor state”) by adding the if (!this.openWidget) guard — but that guard over-suppresses and created our corruption. And note the commit message wording matches the npm changelog’s 6.43.3 line exactly. The 6.43.4 follow-up (6db4710) added the markCount = active.length line trying to fix this very over-suppression — but our tests prove it’s still incomplete.
So we do location.
Regression history (all in buildtile.ts, TileUpdate.emit):
- 6.43.2 — clean.
- 6.43.3 / 85948a6 (dev #1717): added if (!this.openWidget) guard → introduces our corruption.
- 6.43.4 / 6db4710 (dev #1720, a different tag-highlighter crash): added markCount = active.length to the span callback to refine openWidget — helped that case,
but our e2e proves it’s still incomplete.
- 6.43.5 — unrelated (reuse-cache order, coords, iOS).
Did some bisection tied to the commit messages, and some random generated source contents from a distribution over 10 languages.
● Ground truth captured. This is the root cause, and it’s clean.
Look at every logged emit: openEnd: 1, markCount: 0 — and critically, every one is a zero-length emit (from == to: 171→171, 377→377, 692→692 …).
Here’s the mechanism:
- The lint diagnostic is a document-spanning mark decoration (because the machine is transition-free → no source_location → whole-doc range). So at every
internal boundary, 1 range is open → openEnd = 1.
- markCount is only assigned inside the span/point callbacks (lines 524, 560). On a zero-length emit, RangeSet.spans(from, from, …) iterates nothing, so neither
callback runs, and markCount stays at its initializer 0.
- Therefore openWidget = openEnd > markCount = (1 > 0) = true — spuriously. There is no widget at all; the excess is a mark that markCount failed to count.
- When covered: false (the block position genuinely needs a line start), the guard if (!this.openWidget) then skips the required addLineStartIfNotCovered → tile
tree one line short → TilePointer.advance overruns → parents.pop() undefined → the crash.
And this explains why the two prior fixes missed it: 6.43.4’s markCount = active.length was added inside the span callback — which never fires on a zero-length emit. So it fixed the tag-highlighter case (#1720) but not the empty-emit-under-whole-doc-mark case. Perfect fit with our evidence.
Now, the fix must correct openWidget itself (I can’t just gate on blockPosCovered — that’s false in both our case and the #1717 case it was added to fix, so it can’t discriminate). Let me read the exact RangeSet.spans contract to pick the correct, minimal lever rather than guess.
Install Chrome. Test against your Selenium/Mocha rig, Chrome MCP, vitest, fastcheck.
● While the harness probe runs, I’ve settled on the fix from the evidence. The defect is that an empty emit recomputes openWidget from a markCount that no callback ever set. An empty emit processes no content, so it can neither open nor close a widget — therefore it should simply leave openWidget unchanged instead of recomputing it:
if (from < to) this.openWidget = openEnd > markCount // only recompute when content was processed
if (!this.openWidget) b.addLineStartIfNotCovered(pendingLineAttrs)
this.openMarks = openEnd
This is provably safe against the two bugs Marijn was fixing: #1717 and #1720 both involve non-empty emits (from < to), where behavior is byte-identical to 6.43.5. It only changes the zero-length case — exactly where our evidence shows the corruption originates. Let me check the probe’s progress.
Time to attempt a patch.
563 - this.openWidget = openEnd > markCount
568 + if (from < to) this.openWidget = openEnd > markCount
trace the tile range
● Update(src\buildtile.ts)
⎿ Added 5 lines
565 // left at 0 even when marks are open (openEnd > 0), which would wrongly
566 // flag an open widget and suppress a needed line start. An empty emit
567 // can neither open nor close a widget, so leave the flag untouched.
568 + if (from == to && openEnd > markCount && !b.blockPosCovered() && !(window as any).__cap) {
569 + let decos: any =
570 + for (let set of this.decorations) (set as any).between(0, this.view.state.doc.length, (f: number, t: number, d: any) => decos.push({from: f
+, to: t, spec: d.spec}))
571 + ;(window as any).__armed = {from, to, openEnd, markCount, docLen: this.view.state.doc.length, decos}
572 + }
573 if (from < to) this.openWidget = openEnd > markCount
574 if (!this.openWidget) b.addLineStartIfNotCovered(pendingLineAttrs)
575 this.openMarks = openEnd
● Update(src\editorview.ts)
⎿ Added 4 lines
319 this.updatePlugins(update)
320 this.inputState.update(update)
321 }
322 + let __sd = update.startState.doc.toString(), __ch = (update.changes as any).toJSON()
323 + ;(window as any).__armed = null
324 redrawn = this.docView.update(update)
325 + if ((window as any).__armed && !(window as any).__cap)
326 + (window as any).__cap = {startDoc: __sd, changes: __ch, emit: (window as any).__armed}
327 if (this.state.facet(styleModule) != this.styleModules) this.mountStyles()
328 attrsChanged = this.updateAttrs()
329 this.showAnnouncements(transactions)
Selection requires TrustedInput under MCP. [Later: you found a way around this with your existing rig.]
● Captured — and it’s revealing. CAP_CHANGES: [562] means the triggering transaction made no document change at all (562 chars unchanged). The corruption is driven
by a decoration/selection update, not an edit. The decorations confirm the exact recipe:
- {from:0, to:562, cls:“cm-lintRange cm-lintRange-error”, keys:[…,“diagnostics”,…]} → the whole-document lint mark (transition-free machine, no source position).
- {from:171, to:171, cls:“cm-activeLine”} → highlightActiveLine.
- The corrupting emit is at from:378, to:378 (zero-length), openEnd:1, markCount:0.
So the trigger is exactly what the old bisect said — whole-doc mark + highlightActiveLine + a selection move — and it needs no edit. That’s a far cleaner
reproduction than I was guessing. My earlier tests failed because they used doc edits, not selection changes. Let me write the faithful test: the verbatim doc, a
doc-wide mark, an active-line decoration that follows the selection, and the drag-then-collapse selection change.
Trace the selection range.
● Passes on broken — my guessed selection isn’t the triggering one. The capture didn’t include the selection, and the trigger is a specific selection during the
drag. Let me capture the exact selection too.
● Update(src\editorview.ts)
⎿ Added 3 lines, removed 1 line
323 ;(window as any).__armed = null
324 redrawn = this.docView.update(update)
325 if ((window as any).__armed && !(window as any).__cap)
326 - (window as any).__cap = {startDoc: __sd, changes: __ch, emit: (window as any).__armed}
326 + (window as any).__cap = {startDoc: __sd, changes: __ch, emit: (window as any).__armed,
327 + selBefore: (update.startState as any).selection.toJSON(), selAfter: (update.state as any).selection.toJSON(),
328 + userEvent: (update as any).transactions.map((t: any) => t.annotation((EditorState as any).prototype ? null : null))}
329 if (this.state.facet(styleModule) != this.styleModules) this.mountStyles()
330 attrsChanged = this.updateAttrs()
331 this.showAnnouncements(transactions)
Modify how the text is read to cope with the actual output of the defect
● That userEvent line is garbled — let me simplify to just the selection before/after.
● Update(src\editorview.ts)
⎿ Added 1 line, removed 2 lines
324 redrawn = this.docView.update(update)
325 if ((window as any).__armed && !(window as any).__cap)
326 (window as any).__cap = {startDoc: __sd, changes: __ch, emit: (window as any).__armed,
327 - selBefore: (update.startState as any).selection.toJSON(), selAfter: (update.state as any).selection.toJSON(),
328 - userEvent: (update as any).transactions.map((t: any) => t.annotation((EditorState as any).prototype ? null : null))}
327 + selBefore: (update.startState as any).selection.toJSON(), selAfter: (update.state as any).selection.toJSON()}
328 if (this.state.facet(styleModule) != this.styleModules) this.mountStyles()
329 attrsChanged = this.updateAttrs()
330 this.showAnnouncements(transactions)
Discover that the trigger is sporadic.
An aside: it seems like proffering a diff viewer in this site would be helpful
● This capture caught a paste (a different round’s first-trigger); the trigger varies per run. The cleanest minimal case is the selection-only update (no doc
change) — that isolates the mechanism to just decorations. Let me restrict the capture to fire only on empty (no-doc-change) updates so I get that exact
selection.
● Update(~\projects\worktrees\codemirror_view_fix_26-07-05_tile-tree-corruption\src\editorview.ts)
⎿ Added 1 line, removed 1 line
322 let __sd = update.startState.doc.toString(), __ch = (update.changes as any).toJSON()
323 ;(window as any).__armed = null
324 redrawn = this.docView.update(update)
325 - if ((window as any).__armed && !(window as any).__cap)
325 + if ((window as any).__armed && !(window as any).__cap && (update.changes as any).empty)
326 (window as any).__cap = {startDoc: __sd, changes: __ch, emit: (window as any).__armed,
327 selBefore: (update.startState as any).selection.toJSON(), selAfter: (update.state as any).selection.toJSON()}
328 if (this.state.facet(styleModule) != this.styleModules) this.mountStyles()
Of the various triggers (eg typing, selection, cutting, copying) the only one that has a 100% trigger rate is pasting
● CAP_NONE on the empty-change filter — the reliable first-trigger is the paste, and capture #2 gave me that one completely: insert the Green_Arrow_North block at
pos 171 (cursor 171→379), under the whole-doc lint mark + active line + many syntax marks. My earlier minimal tests lacked the syntax marks, whose boundaries
shift on edit and create the zero-length changed-range. Let me recreate that fuller decoration environment and replay the exact captured paste.
● Write(~\projects\worktrees\codemirror_view_fix_26-07-05_tile-tree-corruption\test\webtest-tilesync.js)
⎿ Added 36 lines, removed 26 lines
Playing back the paste synthetically through the API does not trigger reliably.
● Still passes on broken. Even the exact captured paste doesn’t reproduce synthetically — which tells me the corruption depends on jssm’s real accumulated tile
state + real syntax-highlighting decorations, not just the change. One more different instrument before I decide: a sweep that moves the cursor across every line
(each move shifts the active-line decoration under the whole-doc mark) — if the zero-length-emit bug fires on any line, this catches it.
The four-way trigger is discovered
● Reproduced! 23 != 24 — the DOM is one line short of the document. That’s the corruption, caught by the active-line sweep (range-select + collapse under the
whole-doc mark). It’s a genuine fail-on-broken test. Now let me restore the clean fix (remove the capture probes, restore the guard) and confirm the test flips
to green. Let me read the current emit tail to restore precisely.
Exposition that TrustedInput is required. [Later: you seem to have defied this. Perhaps then a misdiagnosis.]
● DBG= again — emit isn’t called at all on the drop, yet .cm-line count changes. That’s the tell: the count is confounded by viewport virtualization (the test
workspace is a hidden 20em box, so CM only renders visible lines). My .cm-line-count proxy was measuring virtualization, not tile corruption — which is why it
“failed” identically on broken and fixed and why emit is uninvolved.
This connects to something my own memory recorded from the prior session: #891 requires trusted mouse input (real CDP drag) — “programmatic dispatch({selection})
never crashes.” His cm-runtests harness is programmatic (tempView + dispatch), with no trusted input — so it structurally cannot reproduce this crash. That’s
the real wall, and it’s not one I can push through with a cleverer synthetic test.