fix: roll up [label](/n/…) chrome on first editor paint - #85
Merged
Conversation
Opening a note painted its links as raw `[label](/n/id)` until the first keystroke rolled them up, and `hrefAtPos` reads the same tree, so those links were unclickable too — not just mispainted. Not the parse-budget race it looks like. `LanguageState` snapshots `context.tree` once, at construction, and `syntaxTree(state)` returns that snapshot; `ensureSyntaxTree` advances the shared mutable context and returns the advanced tree. `collectMarkdownLinks` called `ensureSyntaxTree` for its side effect and then read the stale snapshot, which after `LanguageState.init` covers at most the first 3000 chars (`Work.InitViewport`) — and, once a doc exceeds that, systematically stops short of the final paragraph, because an incremental Markdown parse leaves the last line open. The first edit builds a LanguageState around the by-then-advanced context, which is why one Enter "fixed" it. Iterating the tree `ensureSyntaxTree` returns fixes that. Rebuilding on `docChanged` alone was the other half: `LanguageState.apply` only advances to `max(mapPos(treeLen), viewport.to)`, so on a large enough note the tail stayed raw even after edits. Both fields now also rebuild when tree identity changes, which is how the background parseWorker reports progress (an effect-only transaction). Reading `tr.state` in a field update is safe — `tr._state` is assigned before any slot is computed, and `field()` computes dependencies on demand. Tests read decorations out of the facet rather than the DOM: jsdom has no layout, so a link below the fold never renders even when correctly decorated. The two new cases fail against the old code; the control (a link inside the initially parsed region) passes both ways. Closes #84 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #84.
The bug
On first open of a note, in-app Markdown links painted raw —
[label](/n/id)withall chrome visible. One Enter rolled them up into the blue label. Worse than
cosmetic:
hrefAtPosreads the same tree, so those links were unclickable untilthat first edit.
Root cause — not the race it looks like
The obvious read is "
ensureSyntaxTree(…, 50)times out on a long note". It doesn't —the 50ms budget is almost never the binding constraint. It's a deterministic
stale-snapshot read.
collectMarkdownLinkscalledensureSyntaxTreefor its side effect on the sharedmutable parse context, discarded the return value, and then iterated
syntaxTree(state)— the snapshot fromLanguageState.init, which covers at most thefirst 3000 chars. Links outside it have no
Linknode: no decorations, no click target.The first edit constructs a
LanguageStatearound the by-then-advanced context, so thelinks roll up. Hence "press Enter once".
Measured (jsdom, decorations read from the facet)
syntaxTree(state).lengthensureSyntaxTreeThe snapshot ends exactly 40 chars — the final paragraph — short of the doc while
ensureSyntaxTreereports success: the complete tree was available and thrown away.Deterministic, because an incremental Markdown parse deliberately leaves the last line
open, so once a doc passes the 3000-char init viewport the snapshot systematically
excludes the trailing paragraph.
The 889k row is why
docChangedwas the other half of the bug:LanguageState.applyonly advances to
max(mapPos(treeLen), viewport.to), so "press Enter" is not even ageneral workaround.
The fix
ensureSyntaxTreereturns (documentTree), falling back tosyntaxTreewhen the budget is exhausted — a partial tree still decorates everythingit covers.
docChanged. The backgroundparseWorkerreports progress with an effect-only transaction(
Language.setState.of(new LanguageState(field.context))), whichsyntaxTree(tr.startState) !== syntaxTree(tr.state)catches — the canonical CM6 idiomfor syntax-dependent decorations. This makes the budget-exhausted case self-healing
instead of permanent.
Reading
tr.stateinside aStateField.updateis safe here:@codemirror/stateassignstr._state = thisbefore computing any slot, andfield()routes throughensureAddr,which computes a dependency on demand with cycle detection. The language field has no
dependency on these fields, so no cycle — and it does not rely on
markdown()beinginstalled first, though it is.
Tests
New
agentnoteLinks first paintblock inlib/editor/links.test.ts. Assertions readdecorations out of
view.state.facet(EditorView.decorations)rather than the DOM —jsdom has no layout, so the viewport is a couple of lines and a link further down
never renders regardless of correctness. That is why the existing DOM-based decoration
test never caught this.
](url)replaced, withno doc change — fails on main
hrefAtPosresolves it on first paint — fails on mainso the suite proves the fix rather than the fixture
Verification
pnpm vitest run— 358 passed / 34 files (was 355; +3)pnpm exec tsc --noEmit— cleanpnpm build— compiled successfullyeslint lib/editor components— 12 problems before, 12 after (all pre-existing)Notes / residuals
the parse completes, and each rebuild's
ensureSyntaxTreeshort-circuits throughparse.isDone(upto).deterministically blow the 50ms budget, which is machine-dependent and would be flaky in
CI; part 1 alone already fixes every case measured above, including 889k chars. Part 2 is
defense-in-depth for slower hardware and bigger notes.
decorations and are untouched.
🤖 Generated with Claude Code