Skip to content

refactor(cm): build table and image block widgets from one structural block contract - #487

Merged
mtskf merged 15 commits into
mainfrom
chore/arch-07-pr3-structural-range-model
Nov 10, 2026
Merged

mtskf merged 15 commits into
mainfrom
chore/arch-07-pr3-structural-range-model

Conversation

@mtskf

@mtskf mtskf commented Nov 10, 2026

Copy link
Copy Markdown
Owner

Summary

Table and image block widgets now come from one small structural-block contract. The contract covers tree identity, the settled range, the changed range and ownership. A range that is unparsed, over budget or throwing falls back to raw source, and input and edit-sync keep working while it is raw. Block widgets stay StateFields, and the decoration set itself is the record store.

Changes

  • New contract module src/webview/cm/structural-blocks.ts. It holds the types, the walk, the containment and ownership checks, and the bounded update arms. Ownership and determinacy derive from the contract.
  • The table field and the image field moved onto the contract. The per-field fallback-warn plumbing is gone, and one diagnostic latch now reports hook failures and drops the failed block to raw.
  • Fix: a bounded field whose records cover only a partial parse snapshot now does a full rebuild. The frontier arm (frontierIncomplete) stays, which is the Step 2 verdict.
  • 9R: the branded DecorationSet is now the record store, and the payload is position-free.
  • New tests cover:
    • bounded ≡ full equivalence, including a full-payload oracle with a same-length cell replace;
    • edits outside the viewport;
    • unparsed and partial-parse ranges;
    • a provider/hook throw that falls back to raw while input and edit-sync keep working;
    • reveal keeping the widget object;
    • upstream leaf-completeness of partial Markdown parses.
  • The red-first evidence is in the bodies of d99f2db and 064767e.

Related

  • TODO: ARCH-07 PR3: 構造範囲の共通モデルから装飾を作り、raw表示へ安全に退避する
  • Follow-up filed: position-free table/image widgets, which would let the structural-blocks rebase arm be deleted.

Performance gate (plan revision 14)

  • Method: caret at the edit offset, 30 runs × 5 repeats, post-GC retained heap. Bars come from a re-run baseline at 437a8b3.
  • Every row passes on median, and every heap rule passes.
  • Two p95 tails missed their bar by a small margin. We judged both to be noise and accepted them:
    • image-1200 prose@mid with the caret on the block: p95 was +0.093 ms against a 0.0564 ms bar. The median was −0.020 ms. The caret-on-block heap excess was 269.2 KB against a 360 KB limit.
    • fold list-2500: p95 was 14.681 ms against a 14.610 ms bar. The medians were 9.34 ms (branch) and 9.36 ms (base). We re-ran the row six times, three in the normal order and three swapped, and all six passed. Branch p95 was 9.66–10.04 ms and base p95 was 9.10–9.95 ms, so the 14.68 ms tail never came back.
  • Caret-on-block memory: putting the caret on a block adds a filtered copy of the touched decoration chunk. Those rows are held to their own excess limit (caret-on-block regression minus typing regression ≤ 360 KB) instead of the plain heap bar.
  • Measurement caveat: on doc-1mb prose@mid, the baseline heap noise (N_heap) is 3534.6 KB because two baseline repeats were outliers. The heap bar on that row is therefore not binding.

Test Plan

  • pnpm compile
  • Unit and browser vitest suites
  • env -u ELECTRON_RUN_AS_NODE pnpm test:e2e
  • pnpm package and a forced install (0.1.86)
  • Bench gate, as above

mtskf added 15 commits November 10, 2026 12:20
… (red)

Revert-check evidence for ARCH-07 PR3. Committed red on purpose against
the pre-contract table and image block fields; the rows go green at the
end of the Step 7 migration.

`pnpm vitest run test/webview/cm-structural-blocks.test.ts -t "failure table|failure image|widget identity table"`
on this commit (mount preconditions pass; each row fails on its
behaviour assertion):

- failure table: AssertionError: expected [Function] to not throw an
  error but 'Error: parseTable armed to throw' was thrown
- failure image: AssertionError: expected [Function] to not throw an
  error but 'Error: commonMarkAltText armed to throw' was thrown
- widget identity table: AssertionError: expected TableBlockWidget{ …(5) }
  to be TableBlockWidget{ …(5) } // Object.is equality
…ts (red)

Row "frontier advance": a doc whose init snapshot is partial, the parse
context advanced to the end by ensureSyntaxTree without a republish, then
a single prose keystroke far above a table and an image. Every
precondition passes on this commit, including the two that put HEAD on
its bounded path (touchesStructuralReparse(tr) === false,
syntaxTreeAvailable(tr.state, doc.length) === true), and the behaviour
assertion fails:

  AssertionError: expected [] to deeply equal [ 20070 ]
  (the image widget beyond the old snapshot is never published)

Verdict: the gap is real, so the frontier-advance arm stays in scope.
The table half passes here only because this row mounts tableBlockField
without tableSkeletonField, and that fallback full-walks on every change;
the production skeleton field takes the bounded path like the image one.
…ainment, ownership)

structuralBlockWidgets(spec) builds one StateField whose value is
{ records, decorations }. This commit lands Steps 4a-4c of the ARCH-07 PR3
plan: the full walk, recordAt, hook containment (build in the walk, widget in
makeRecord, one latched console.error per arm that never reads err), and range
ownership (frontmatter filter, from>=to / containment drop reported as
errKind "invalid-range", mapped fmEnd comparison with the 0 sentinel).

Toy rows: "toy full", "toy build throws", "toy widget throws",
"toy ownership". Revert-checked: replacing either catch with a rethrow turns
"toy build throws" / "toy widget throws" red ("Error: toy build" /
"Error: toy widget").
Steps 4d-4i of the ARCH-07 PR3 plan. The bounded update, rebase through
makeRecord, the visited dedupe and the tree-identity arm landed with the
module in the previous commit; this commit adds their toy rows:
"toy bounded update", "toy rebase", "toy widget throws on rebase",
"toy dedupe", "toy tree identity", "toy branching", "toy seeded oracle".

Revert-checked: dropping the visited check turns "toy dedupe" red, and
returning prev on a republished tree turns "toy tree identity" red.
Step 4j of the ARCH-07 PR3 plan: row "reveal" (toy spec) — a caret on a
block line hides its decoration while the record stays; leaving restores the
same widget object; a same-line-span caret move returns the field value.
Rows "frontier pin table" and "frontier pin image" run the production
parser with stopAt(p) for every p strictly inside the complete target,
fresh and incremental (TreeFragment.addTree + applyChanges, for a distant
prose edit and an edit inside the target). The partial tree must hold the
target with its complete range or not at all. This is the evidence that
the structural block contract needs no "settled" rule; it goes red if a
future @lezer/markdown cuts a leaf block at stoppedAt.
…apshot

frontierIncomplete(tr) is the frontier half of requiresFullBoundedRebuild.
Besides G2 (post-edit context incomplete) it is true when the records were
built over a partial SNAPSHOT: after ensureSyntaxTree the parse context is
complete while syntaxTree(startState) is still partial, so the first
docChanged transaction took the bounded path and never published blocks
past the old snapshot end (Step 2 reproduced this for table and image).
The docblock is the canonical explanation of the snapshot/context split.

fenced-code-collapse docChangePlan calls frontierIncomplete in place of
its own syntaxTreeAvailable term and keeps its narrower structural check.
The frontier-gate guard counts frontierIncomplete calls by name (ALLOW:
structural-guard 2, fenced-code 1) with a non-vacuity fixture.

Rows frontier advance fenced/fold/callout pin the other consumers.
Revert checks: restoring the old fenced term reds only the fenced row;
restoring the old guard term reds the base, fold and callout rows.
…block contract

The table and image block fields are now specs of structuralBlockWidgets
(structural-blocks.ts): each says only which Lezer node it looks at, how to
build its block and which widget shows it. Range ownership, the bounded
update, the full-rebuild reasons, failure -> raw source and reveal live in the
contract. The field value is now { records, decorations }.

- table: tableBlockField replaces tableSkeletonField + the old table field;
  table-skeleton.ts, the resolveModels fallback and its warn latch are gone.
  table-ranges.ts stays for the lint rule only.
- image: image-field.ts keeps its eligibility rules, render gate and alt
  extraction; computeBounded / computeExtendedSpan / G3 are gone.
- markdown.ts tableEmitsBlockWidget reads the records through recordAt.
- Tests read .decorations / .records; cm-table-skeleton.test.ts ports to
  cm-table-blocks.test.ts; cm-table-fallback-warn.test.ts is deleted; the
  fold blockquote harness registers tableBlockField explicitly, plus a row
  without it.
- New production rows: ownership table/image, frontmatter body
  insert/delete, no frontmatter offset-0 insert, viewport outside
  table/image, reveal table; "structural build failure keeps sync" in
  cm-widget-containment.

Revert-checks run before committing:

(i) frontmatterMoved -> unmapped `oldEnd !== newEnd`:
  x frontmatter body insert: one character inside the body rebases the blocks
    AssertionError: expected [ ...(2) ] to deeply equal []
  x frontmatter body delete: one character out of the body rebases the blocks
    AssertionError: expected [ ...(2) ] to deeply equal []
  no frontmatter offset-0 insert: green
(ii) frontmatterMoved -> `tr.changes.mapPos(oldEnd, 1) !== newEnd` (no sentinel):
  x no frontmatter offset-0 insert: a keystroke at offset 0 rebases the blocks
    AssertionError: expected [ ...(2) ] to deeply equal []
(iii) build catch in structural-blocks.ts -> rethrow:
  x structural build failure keeps sync: a throwing parseTable never stops edit-sync
    AssertionError: expected [Function] to not throw an error but
    'Error: parseTable armed to throw' was thrown
All restored; structural-blocks.ts unchanged by this commit.
… import frontierIncomplete

The full suite went red on two guards the earlier steps did not run:

- no-bare-unstarved-gate flagged the two bare
  expect(syntaxTreeAvailable(...)).toBe(true) gates in the frontier-advance
  rows. They now go through withUnstarvedFrontierState, the repo's
  load-robust form. Non-vacuity rechecked: with the snapshot term of
  frontierIncomplete forced to false, all four frontier-advance rows go red.
- cm-lezer-table-internals-tripwire pinned that fenced-code-collapse.ts never
  imports structural-guard.js, which the frontierIncomplete step made false
  by design. It now pins that the one import names frontierIncomplete alone
  (red when requiresFullBoundedRebuild is added to it).
The pre-9R shape kept a parallel records array next to the DecorationSet
and allocated fresh block/record objects for every shifted block on each
edit. Measured cost: ~1.8-2.1 MB retained over HEAD; image-1200 prose@mid
retained 8580 KB on the branch vs 6439 KB on HEAD, failing the Step 10
heap bar.

Now the DecorationSet is the store: the position-free payload rides
deco.spec.structural, StructuralRecordSet<B> brands it at compile time,
recordAt scans records.between, a moved record reuses its payload, an
all-unchanged update returns the previous set, and reveal shares the
record set as the decoration set when no selection meets a block.

Revert-checks (9R-d):
- payload spread on rebase -> "toy rebase":
  AssertionError: expected { text: '```\nalpha\n```' } to be { text: '```\nalpha\n```' } // Object.is equality
- meetsSelection short-circuit removed -> "reveal":
  AssertionError: expected RangeSet{ chunkPos: [ 18 ], …(3) } to be RangeSet{ chunkPos: [ 18 ], …(3) } // Object.is equality
Return early from tableEmitsBlockWidget when the table field is absent, and rewrap comment lines past 100 columns. No behaviour change.
@mtskf
mtskf merged commit 75cf0de into main Nov 10, 2026
2 checks passed
@mtskf
mtskf deleted the chore/arch-07-pr3-structural-range-model branch November 10, 2026 16:11
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