Skip to content

perf(streaming): intra-list tail bounding — freeze settled items of an open list (fixes #29) - #39

Merged
jonathanKingston merged 1 commit into
mainfrom
claude/issue-21-ccvlpo
Jul 5, 2026
Merged

jonathanKingston merged 1 commit into
mainfrom
claude/issue-21-ccvlpo

Conversation

@jonathanKingston

Copy link
Copy Markdown
Collaborator

Fixes #29 — the biggest perf gap left after #23: a long, still-open trailing list kept its whole group in the tail (loose/tight is a whole-list property, so no item could settle), re-rendering/re-sanitizing/re-morphing every item on every commit. That's the old O(n²) for arguably the most common long LLM output shape.

Result: streaming a 200-item list drops 8,870ms → 1,053ms (8.4×); growth per doubling ~4.1× → ~2.1×.

How

Settled items freeze inside the shared <ul>/<ol> (two sibling lists would break byte-parity — CommonMark renders one), per the design on #29:

  • render-blocks.ts — the list-group knowledge is factored into exported primitives (listGroupSignature, listSliceContinuesGroup, listItemSliceIsMultiParagraph, scanListGroup, renderListItemsSlice, listGroupOpenTag/CloseTag) and collectListGroup is rewired onto them: rendering and streaming share one source of truth for group membership, looseness, and item HTML. An item slice renders byte-identically to the corresponding items of a whole-group render — the property everything else rests on.
  • streaming-dom-morph.ts — morphElementChildrenFrom (reconcile an element's children from an offset) and syncAttributes (the shared list element may legitimately change attributes — e.g. gaining class="contains-task-list" when a later item adds a checkbox — while its frozen <li>s are never touched).
  • streaming-frozen-tail.ts — the intra-list mode. Activation is pure state init after a generic commit whose trailing group is a signature-uniform open list with ≥4 items starting at the frozen boundary. Per commit, the unfrozen item slice is scanned (incremental loose evidence, exactly scanListGroup's rule), all but the last item freeze (the last can still absorb continuations; token extension over the boundary trips the existing straddle guard), and the region is reconciled with one wrapped-list morph so settling items keep DOM node identity. Sealing: when a non-continuing token ends the group, the list is promoted to a fully frozen top-level node and the generic path handles what follows in the same commit — no full-morph at list end.

Safety (tight-optimistic, everything behind the existing full-morph fallback)

  • Tight→loose flip against frozen items → one-time fallback per list (looseness is monotonic, so amortized O(n); the rebuilt state freezes loose thereafter).
  • Signature break, unbalanced benign raw-inline tags in a would-freeze slice, in-place rewrites (frozenSource byte guard), DOM shape mismatch → fallback.
  • Mixed-marker giant runs and long open blockquotes stay on the generic path (conservative; noted in the module header — blockquote grouping re-renders merged content and isn't sliceable the same way).

Verification

  • 472 tests + CommonMark conformance + exhaustive convergence fuzz green.
  • Full-history byte-parity (every committed frame, char-by-char) through freeze → seal → following ordered list → seal, and each hazard: tight→loose flip, late task-class, marker change, late multi-paragraph item, start attribute, nested lists, rewrite-while-active.
  • Frozen-<li> node identity preserved for the rest of the stream.
  • Deterministic CI guard for the list shape: renderedChars grows 1.95–1.99× per item-count doubling (< 3× threshold; ~4× = regression).

🤖 Generated with Claude Code

https://claude.ai/code/session_01LnAFvAgDY3RVYT6zJqN1fy


Generated by Claude Code

…n open list (#29)

A long, still-open trailing list kept its whole group in the tail (loose/tight
is a whole-list property, so no item could settle), re-rendering, re-sanitizing
and re-morphing every item on every commit — the old O(n²) for arguably the
most common long LLM output shape. Streaming a 200-item list: 8,870ms -> 1,053ms
(8.4x), growth per doubling ~4.1x -> ~2.1x.

Settled items now freeze INSIDE the shared <ul>/<ol>:

- render-blocks.ts: the list-group knowledge is factored into exported
  primitives — listGroupSignature / listSliceContinuesGroup /
  listItemSliceIsMultiParagraph / scanListGroup / renderListItemsSlice /
  listGroupOpenTag+CloseTag — and collectListGroup is rewired onto them, so
  rendering and streaming share ONE source of truth for group membership,
  looseness, and item HTML (an item slice renders byte-identically to the
  corresponding items of a whole-group render).
- streaming-dom-morph.ts: morphElementChildrenFrom (reconcile an element's
  children from an offset — morphChildren already supported it) and
  syncAttributes (the shared list element may legitimately change attributes,
  e.g. gaining class="contains-task-list", while its frozen items must not be
  touched).
- streaming-frozen-tail.ts: intra-list mode. Activation is pure state init
  after a generic commit whose trailing group is a signature-uniform open list
  with >=4 items starting at the frozen boundary. Per commit, the unfrozen
  item slice is scanned (blanks + signature-continuing items, incremental
  loose evidence per scanListGroup's exact rule), all but the last item are
  frozen (the last can still absorb continuations; token extension over the
  boundary trips the existing straddle guard), and the region is reconciled
  with one wrapped-list morph so settling items keep DOM node identity. When
  a non-continuing token ends the group, the list is sealed — promoted to a
  fully frozen top-level node — and the generic path processes what follows
  in the same commit, with no full-morph.

Safety, all via the existing full-morph fallback (correct-but-slower, never
wrong): tight->loose flip against frozen items (one-time per list — looseness
is monotonic), signature break, unbalanced benign raw inline tags in a
would-freeze slice, in-place rewrites (frozenSource byte guard), and any DOM
shape mismatch. Mixed-marker giant runs and long open blockquotes stay on the
generic path (conservative; noted in the module header).

Tests: full-history byte-parity through freeze/seal/ordered-list sequences and
every hazard above; frozen-<li> node identity; a deterministic renderedChars
CI guard for the list shape (1.95x-1.99x measured, <3x threshold). 472 tests +
CommonMark conformance + exhaustive convergence fuzz green.

Closes #29.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LnAFvAgDY3RVYT6zJqN1fy
@jonathanKingston
jonathanKingston merged commit 82182e0 into main Jul 5, 2026
1 check passed
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.

Bound the tail for long open lists/blockquotes (frozen/tail v2)

2 participants