Skip to content

test(webview): settle every mounted harness whose fields read the tree - #389

Merged
mtskf merged 2 commits into
mainfrom
fix/settle-mounted-view-parse-reads
Sep 1, 2026
Merged

test(webview): settle every mounted harness whose fields read the tree#389
mtskf merged 2 commits into
mainfrom
fix/settle-mounted-view-parse-reads

Conversation

@mtskf

@mtskf mtskf commented Sep 1, 2026

Copy link
Copy Markdown
Owner

What

Settle the parse in every MOUNTED test/webview harness whose assertions depend on syntax-tree
CONTENT. 15 sites across 10 files, all test/** — no production source changes.

Why

Under happy-dom, CodeMirror's background-parse ViewPlugin never gets scheduler time, so a
mounted EditorView does NOT self-heal: its StateFields stay built on the init-viewport parse
fragment forever. Measured on this branch:

PROBE doc= 4616  treeBefore= 3012   <- mount publishes a truncated snapshot
PROBE treeAfter= 4616               <- settledView() repairs it

treeBefore is CM's min(3000, doc.length) init viewport; it never advances on its own.

Honest severity: nearly every fixture here is UNDER that 3000-char viewport, so its init parse
completes synchronously at EditorState.create time. These sites are therefore LATENT, not
currently broken — they rest on an undocumented internal budget (20 ms over
min(3000, doc.length)) continuing to hold under CPU load. That is the same load-sensitivity
behind the fold flake in LEARNING.md (2026-07-23), and this suite has already been bitten: the
baseline run for this PR had validate-for-write.test.ts blow its 15 s timeout purely because
five triage agents were loading the machine. It passes alone.

The roster was not what the TODO said

The TODO listed "32 files use no settle helper at all" and noted the list "has NOT been triaged
one-by-one". That count is of unsettled mounts, not of defects. Triaging all 329 construct hits
(93 new EditorView(, 233 EditorState.create(, 3 .setState() across 99 files reduced it to
15 real sites, and found several roster entries to be architecturally immune rather than merely
small-doc:

  • all four lint/* files — lintMarkdown() / createIncrementalLinter() call
    markdownLanguage.parser.parse(text) directly (complete, un-budgeted, synchronous); zero
    syntaxTree references under src/webview/cm/lint/;
  • table/cm-table-widget-render — widgets built from the host-side parseTable() string parser,
    not Lezer at all;
  • outline/outline-panel — production forces ensureSyntaxTree(state, doc.length, 500) on every
    sidebar open, and every content-asserting test calls toggle() first, so it self-heals;
  • paste/*list-tree.ts's caretInCode/listItemAt force their own local bounded parse;
  • frontmatter/* — detection is line-native by design ("NOT a Lezer-tree walk");
  • cm-context-handoff — its states carry NO language extension, so settling would throw
    assertHasLanguage.

Sites that must NOT be settled

More valuable than the 15 fixes. Settling any of these damages a working guard, and three of the
five fail GREEN:

site what settling does
list/cm-list-hang-integration.test.ts:141 Asserts the RAW snapshot IS truncated (toBeLessThan(3)) under a mocked clock, as the non-vacuity guard for the fullTree fix beneath it. Settling breaks it outright — fails RED.
fenced-code/cm-fenced-collapse-bounded.test.ts:379-387 Pins record object identity across a bounded update. Settling allocates a fresh record — fails RED.
table/cm-table-skeleton.test.ts:158-164 Asserts the bounded field's output against the oracle before self-heal — fails RED.
cm-block-widget-byte-identity.test.ts:314-325 Fails SILENTLY GREEN. The bounded≡full comparison degrades to full-vs-full and still passes, so the harness quietly stops testing the bounded path. Its only protection is a comment plus a load-fragile syntaxTreeAvailable gate.
decorations/cm-decoration-block-style.test.ts :1131/:1166/:1185/:1204 and table/cm-table-field.test.ts :291/:297 Pin decoration-set object identity across a SELECTION-only dispatch. A settle between dispatch and read allocates a fresh set and destroys the property under test.

Mount-time settle is safe for the last group because their identity baselines (bqBefore,
span1) are captured AFTER the factory returns — forceParsing's own empty transaction has
already happened and is not observed by the comparison.

Post-edit reads: audited, deliberately not settled

A mount-time settle covers the INIT read only; CM's per-transaction incremental reparse has its
own 20 ms budget, so a snapshot can in principle re-truncate after an edit. Every read-point was
classified (INIT-READ / POST-EDIT-READ / per-dispatch pin) and the result is that NO post-edit
settles are added, because the remedy would be actively harmful here:

almost every POST-EDIT-READ in this suite exists to assert THAT THE FIELD RESPONDED TO THE EDIT.
table/cm-table-field.test.ts:223 asserts toHaveLength(0), dispatches, then asserts
toHaveLength(1). A settle between them fires an extra transaction that recomputes the field —
so if tableBlockField ever failed to recompute on docChanged, the settle would supply the
recompute and the test would still pass. "POST-EDIT-READ" and "pins the update cycle" are nearly
the same set in practice.

The residual risk is real, bounded (all fixtures far under the init viewport), and recorded here
rather than silently closed. Fixing it properly needs a settle that does not perturb the field,
which is a different change from this PR's purpose.

Scope boundary

This PR closes the current gaps; it does NOT prevent recurrence. Nothing stops a new harness from
calling new EditorView against a production tree reader and reintroducing the bug — the
inventory is a convention, not CI enforcement. That enforcement question is its own TODO DECISION
entry, which already records two prior failures of the naive text-scan approach, so it is not
re-litigated here.

One measurement that feeds that decision without pre-empting it: its option (b), "a narrow lint
rule banning forceParsing imports outside test/webview/helpers/", would land GREEN today.
Measured on this branch — exactly one import (helpers/settled-view.ts:1) and one call (:151),
both inside the helper; every other forceParsing occurrence under test/** is prose in a
comment.

Also fixed

table/cm-table-field.test.ts — the "drag reveal" test constructed three views BEFORE its try.
Once mount() settles, a throw on the 2nd or 3rd stranded the earlier ones, because
settledMount by contract destroys only the view IT built. Constructions moved inside the try,
disposal via ?.destroy().

decorations/cm-decoration-orchestrator.test.ts — used settledView(new EditorView(…)), the
exact leak shape the helper's docblock calls out (a throw strands the view before anything owns
it). Changed to settledMount.

Two readOnly inline mounts (cm-task-checkbox-widget-toggle-target.test.ts:87,
cm-task-checkbox-widget-toggle.test.ts:213) are settled too, though their guard aborts before
the tree read. Settling is inert there, and applying it uniformly removes a silent dependence on
the internal ORDER of toggleTaskCheckbox's guards.

Coverage

"Every hit accounted for" is measured, not assumed:

  • MOUNTED (this PR's scope): 47 files contain new EditorView(. 45 triaged site-by-site; the
    other 2 are helpers/settled-view.ts and its own test — the settle machinery itself. No
    untriaged mount.
  • STATE-ONLY: of the 42 state-carrying files outside the triaged set, 41 already route through
    settledState/fullTree (PR2). The single exception,
    inline/cm-inline-formatting-commands.test.ts, is NO-SETTLE BY DESIGN: it builds a
    deliberately parser-free state as the pin proving computeInlineFormat never consults the
    parser. Settling would attach a language and destroy that pin.

Non-vacuity

This migration adds call sites, not new guarantees, so it adds no test cases. The load-bearing
proof already exists upstream, shipped in PR1: helpers/settled-view.test.ts:70-90 asserts on a

3000-char doc that "a freshly-mounted view's snapshot is truncated (the precondition)" and then
that "the settled view's snapshot spans the whole doc", with the twin pair in
helpers/settled-state.test.ts.

An earlier draft of this PR planned to prove non-vacuity by passing a 0 ms budget at one site and
watching it go RED. Two independent design reviews killed that, correctly: settledState takes
no budget parameter at all, and even for settledMount a 0 ms budget proves nothing here because
ParseContext.work short-circuits on isDone BEFORE consuming budget or checking its deadline —
so any sub-3000-char fixture returns true in 0 ms and never throws. That probe would have come
back GREEN and been read as "the settle is decorative".

Verification

  • pnpm compile — clean (5 tsconfig projects)
  • pnpm test:unit — 270 files, 5145 tests, all pass
  • pnpm lint — clean on all 11 changed files (the 5 remaining repo warnings are pre-existing in
    test/markdown/validate-for-write-incremental.test.ts, untouched here)
  • pnpm build — clean
  • git diff -U4 | grep -A4 "dispatch(" | grep '^\+.*settled' — empty: no settle inserted between
    any dispatch and a following assertion

pnpm package / force-install do not apply: test/** only, no shipped bytes.

Under happy-dom CM's background-parse ViewPlugin never gets scheduler time, so
a mounted EditorView does not self-heal: its StateFields stay built on the
init-viewport parse fragment. Measured here, a 4616-code-unit doc publishes a
3012-code-unit snapshot and never advances.

Settle the 15 mounted sites whose assertions trace to a production tree reader,
via settledMount / settledState. Fixtures under the ~3000-char init viewport
parse synchronously at create time, so these were latent rather than broken —
they rested on an undocumented 20ms budget holding under CPU load, the same
sensitivity behind the fold flake.

Triaging all 329 construct hits across 99 files cut the roster from the TODO's
"32 files" to 15 sites in 10 files: the lint suite parses via
markdownLanguage.parser.parse directly, cm-table-widget-render uses the
host-side parseTable, and outline-panel forces its own ensureSyntaxTree on
open — none are exposed.

Deliberately not settled, with reasons recorded in the PR body: five harnesses
whose assertions would be damaged by a settle, three of which would fail
silently green rather than red. No post-edit settles are added — in this suite
a post-dispatch read is almost always the pin that the field responded to the
edit, so settling there would mask a failure to recompute.

Also fixes a mount-ordering leak in cm-table-field's drag-reveal test (three
views constructed before its try) and a settledView(new EditorView(...)) in
cm-decoration-orchestrator, which strands the view if the settle throws.
…le comments

Review caught that both settle comments credited collectTableRanges with the
syntaxTree(state) read. It does not do that read: table-ranges.ts takes an
already-resolved tree and its header states "Pure: a lazy reader of the passed
tree, no field/state dependency".

The read is tableModels(state) in table-skeleton.ts, and this fixture reaches it
only through resolveModels' fallback arm, because mount() registers
tableBlockField without tableSkeletonField. Say that instead, so a reader
chasing the comment does not land on a function documenting the opposite.

Comment-only; the settle itself was and remains correct.
@mtskf
mtskf merged commit a1a26ba into main Sep 1, 2026
2 checks passed
@mtskf
mtskf deleted the fix/settle-mounted-view-parse-reads branch September 1, 2026 19:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant