site appears to be down, can't post bugfix

so, there’s a bug in the most recent two minors. a patch went up but it’s incomplete.

i wanted to post a pr, but … oh, you’re off of github.

the forgejo website is down

anyway, i think i have a pr to fix a tree corruption bug that is partially repaired in recent .4

i don’t really know where to put it

It’s up for me. I (and many others) have been using it actively in the past days. What error are you seeing?

You can describe the issue and link to a repository with a patch here if you prefer.

i apologize. it’s a false alarm based on an extremely bizarre chain of events.

  1. apparently my ISP cox imposed some nanny state filter by accident because they inherited it on purchased modems from comcast, called “safebrowse.io”, at some point. i don’t really know when? this is the first i’ve seen it. (they appear to also be charging me for this, without my permission.)
  2. it’s a holiday weekend in the united states, and i’m taking advantage of the fable 5 day window as hard as i can. my laptop is ancient and i am a browser tab hoarder, so i had closed chrome to give claude room to breathe. i was using (shudder) edge.
  3. apparently safebrowse.io is so broken that its redirect page doesn’t actually work in ie. it wasn’t until i brought chrome back up that i actually saw it. prior to that i was getting what looked like a service outage, but was actually my router choosing to be the info police.
  4. i have confirmed this by loading your webpage on my phone when on the wifi (broken) and when on the cellular network (working.)

after two days, my isp has finally turned this off, and your site runs fine. i apologize for my numbskulled error.

Let’s get to my other numbskulled error, then.

Here are some words I’m certain you’re sick of: “I tried to make a highlighter for your stuff a couple years ago and failed. Now I’m having Claude do it, and it’s doing a bangup job, but there’s a bizarre fault and Fable thinks it’s in the library. And, yes, I am doing all sorts of stuff I’m not supposed to be doing, and no, I’m not aware of that.”

It’s a StreamTokenizer. I will probably do the full thing soon, but first I need to spin out the other highlighters. However, limitations of StreamTokenizers mean that I’ve tooled the real parser to give augmentation data along the way.

There was a point at which I was using the resulting editor, cutting and pasting blocks so that I could go back and tweak their contents, to make a chain of things. The parse tree corrupted horribly, and full select and paste didn’t repair it, to the point that the page needed to be reloaded.

Fable says that in .4, if you remove !this.openWidgetfrom the .4 codebase, the bug is entirely suppressed. that’s obviously not a fix, but at least a clear indication of what it thinks it’s found.

There is a replication with none of my software involved, which is relatively small, attached.

Here is Fable’s opinion.

6.43.3 regression: blank-line click drops a tile when a document-spanning mark and highlightActiveLine are active

In @codemirror/view 6.43.3 and 6.43.4, clicking on a blank line can silently drop that line’s tile from the tile tree. Four conditions are needed at once: a mark decoration spanning the whole document (for example a linter returning one file-wide diagnostic with no specific location), highlightActiveLine, a preceding mouse drag-selection across a few lines, and a real click on the blank line. Roughly half of qualifying clicks reproduce, usually within two attempts. Each was verified necessary in isolation (~100-200 trials per cell): a subrange mark does not reproduce, nor does an empty or absent linter, nor the whole-document mark without highlightActiveLine. 6.43.2 does not reproduce (0 in 1000+ trials of the same page and gesture).

After the drop, docView.tile.length is one less than state.doc.length and stays that way. In larger configurations a later document update walks off the end of the corrupted tree and throws “TypeError: Cannot destructure property ‘tile’ of ‘parents.pop(…)’” in TilePointer.advance, after which every dispatch rethrows (“Error: No tile at position N”) until reload.

Mechanism, as far as I can read it from the shipped dist (I don’t have the source tree): the only change to dist/index.js between 6.43.2 and 6.43.3 is at the end of TileUpdate.emit(), where addLineStartIfNotCovered became guarded:

      span: (from, to, active, openStart) => {
          for (let pos = from; pos < to;) {
              let chars = this.text.next(Math.min(512, to - pos));
              if (chars == null) { // Line break
                  b.addLineStartIfNotCovered(pendingLineAttrs);
                  b.addBreak();
                  pos++;
              }
              else { ... }
              pendingLineAttrs = null;
          }
          markCount = active.length;      // <- added in 6.43.4
      }
  });
  this.openWidget = openEnd > markCount;
  if (!this.openWidget)                   // <- added in 6.43.3
      b.addLineStartIfNotCovered(pendingLineAttrs);
  this.openMarks = openEnd;

markCount is only assigned inside the span() callback, but a blank line inside a mark arrives through the line-break path, not as a trailing span. So when an emitted region ends at a blank line (which is what the drag-then-click redraw boundaries produce), markCount holds a stale value from an earlier span. With a document-spanning mark, openEnd >= 1, so openEnd > markCount spuriously reports an open widget and the guard skips the blank line’s line start; the tree comes out one tile short. highlightActiveLine matters because it is what makes pendingLineAttrs non-null on that line; with nothing pending the skip costs nothing. This also seems to explain why 6.43.4’s markCount fix helped the fenced-code report but not this path: a region ending with a span reaches the assignment, a region ending at a break never does.

Removing the guard eliminates the corruption in my testing (0 in 240 trials versus ~50% per qualifying click unpatched, and my editor’s full regression suite goes green on the patched build). But since 6.43.3’s changelog says the guard was itself fixing a content-DOM corruption, a plain revert presumably regresses that; it looks like the real problem is inferring widget-openness from a mark-count heuristic that the break path never updates, rather than the guard as such.

Attached: a self-contained reproduction page (pure @codemirror/view 6.43.4 + @codemirror/lint via esm.sh, ~40 lines) with a live doc-length-vs-tile-length readout, so the corruption is visible at the moment of the click, before anything crashes. Steps: open the file, drag-select a few lines, click the blank line; repeat once if the first attempt survives. Verified triggering from a plain file:// open in Chromium; other engines untested.

Attached, a repro and a replay

repro_esm.html (3.6 KB)

verify_minimal.rename_to_mjs.js (4.7 KB)

Thanks for clearing up the Forgejo thing. I was a little worried.

Is the issue you’re running into still present in @codemirror/view 6.43.5? I don’t see any mention of that version in the slop.

It’s a little hard to tell, but it looks like .5 is around 36 hours old, and if so, the slop is a day older. No, this slop does not know about .5 , as didn’t I.

The reproduction fails in the same way on .5 . I believe .5 does not resolve this.

(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.

Discovered in writing that comment:

  1. When bold wraps fixed text in successive pairs, the second pair’s bold status degenerates to surrounding double asterisks
  2. It is very hard to get out of a quote block by keyboard when you are not at the end
  3. If I write “foo \n \n \n \n bar” I get “foo \n bar.” I suspect this is intentional, but, the ability to voluntarily add vertical whitespace seems to me like an important formatting tool for long text
  4. If you allow a line block text to be interpreted, its newlines are lost, rather than maintained and re-interpreted, meaning fixing it means manual string repair
  5. A quote block which maintained single line breaks instead of collapsing them would make pasting code and console output more convenient

Hi John, I appreciate the report—once I got down to the essence that was really useful. I did not read this whole 4.5k word rambling stream-of-consciousness interspersed with LLM output story, I must admit, and to be quite honest it sounds a little concerning. Might be a good idea to back off from these tools for a moment and take a look at what they are doing to the way you work (unless this has been your energy pre-LLM as well, in which case carry on I guess).

i was bored for ten minutes and i’ve been reading a book written in this style. no, i am not engaging in literature as a result of using claude. why would using a programming tool cause someone to make a bunch of dumb reference jokes?

c’mon, don’t treat a series of jokes as mental illness befitting concern. that’s kinda harsh. someone was just trying to be friendly and entertain you.

i won’t do it again.

it seems you’d prefer to replace implementations. that’s fine, i do that sometimes too. but also i looked at some of the comments you’ve made on other prs.

should i still provide implementations and regressions, or would it be easier for you if i just documented issues? i don’t want to be a bother.

i’m sorry, man

this kind of bs is what gets upvoted on reddit when i do it.

i genuinely thought you’d think it was funny.

I generally prefer bug reports to PRs, yes.

I have a lot on my plate, I’m sorry if failing to engage with sprawling low-information texts comes across as rude, but I really can’t be bothered.

i see.

i will engage through bug reports in the future then. should a repro be included?

A reproduction is definitely useful, yes.

would you prefer the play by play kept, to document what was searched, or just the output and description retained, in the future