perf(streaming): incremental committed-prefix rendering (fixes #21) - #23
Merged
Merged
Conversation
A single StreamingMarkdownRenderer.update() / renderStreamingMarkdown() call previously re-tokenized the streamed string ~5-8 times: once in splitForStreaming, again in getIncompleteFenceSource, a third time inside formingTableSource's own fence check, a fourth in getIncompleteTableSource, plus up to three tokenizations of `complete` via renderMarkdown and the pendingLineBelongsInTable checks. Thread the tokens computed during the split back through the helpers: - splitForStreaming now returns the tokenizeBlocks(content) result via the new StreamingSplitWithTokens; the boundary logic moves to an internal splitForStreamingCore that takes the pre-computed tokens. - getIncompleteFenceSource / getIncompleteTableSource / completeEndsInOpenTable / pendingLineBelongsInTable accept an optional pre-tokenized array. - renderMarkdown accepts options.tokens to reuse the render-path tokenization. - update() / renderStreamingMarkdown() compute tokenizeBlocks(complete) at most once, lazily (pending-only frames with no table pipe never force it), and pass content tokens into the forming-source helpers. Net effect (instrumented): ~4.5-6.0 -> ~1.5-2.0 tokenizations per update. Stateless refactor, no behavioral change; full suite and CommonMark conformance unchanged (428 tests, 2 conformance suites green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LnAFvAgDY3RVYT6zJqN1fy
… (Layer 2, #21) The committed prefix of a streaming message used to be fully re-rendered, re-sanitized and re-morphed on every block commit, making a whole stream O(n²) in the message length — the render/sanitize/morph layer dominated. Split the committed subtree (`stream-complete`) into two regions: - a *frozen* region — DOM for top-level groups that can never change again. Rendered, sanitized and inserted exactly once; node identity is permanent. - a *tail group* — the last still-mergeable group (open list, growing table, open blockquote, or an unsettled trailing paragraph). The only thing re-rendered per commit, and morphed (not rebuilt) so a growing table/list keeps its node identity. `FrozenTailRenderer.update` is now constant-time per commit regardless of how long the committed prefix already is (verified: a settled paragraph keeps one DOM node instance across a 10-commit stream). Correctness is guarded by a full-morph fallback to today's exact behaviour on any uncertainty (a changed committed link-ref map, a non-monotonic boundary), so the fast path is a pure optimization — a mishandled case degrades to slower-but-correct output, never to wrong output. Details: - New `src/streaming-frozen-tail.ts`: `settledTailStart` computes the boundary (conservative, always a top-level group boundary; open blanks stay in the tail so a `" " + "***"` re-tokenization can't strand a frozen node), and `FrozenTailRenderer` owns the frozen/tail split with the link-ref guard. - `morphInnerHtmlFrom(container, startIndex, html)` reconciles only the tail region, leaving the frozen prefix untouched. - Guard `findLastCommittedTable`'s whole-subtree `querySelectorAll('table')` behind a cheap `complete.includes('|')` check — a committed GFM table always contains a pipe — removing an O(prefix) DOM scan from every table-free update. - Byte-parity is asserted across the CommonMark baseline corpus at every fully-committed frame of a full streaming history, plus targeted tests for each settling hazard (setext retro-conversion, loose-list flip, table body append, blockquote blank-run continuation, indented raw-HTML, trailing-open re-tokenization), the forward-reference fallback, and the seam/sweep edges. The DOM path now runs well below the full-re-render string path in the bench (dom/str ~0.3-0.7×, was ~0.9-1.3×). Residual per-update O(prefix) costs remain outside this layer (issue #21 limitation K): the full-string tokenize, the per-commit link-ref scan, and the pending-sync `querySelector` traversals — follow-up work, documented in the module header. Full suite (439 tests) and CommonMark conformance unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LnAFvAgDY3RVYT6zJqN1fy
…notonic (#21) Follow-up to the frozen/tail split: two O(prefix)-per-update DOM costs remained in the streaming path, keeping whole-stream growth at ~4×/doubling despite the committed-prefix render being flattened. Both are fixed here; the plain-prose bench now grows ~2×/doubling (down from ~4×) — ~12× faster at 100 paragraphs. 1. Tail-scope the pending-sync DOM queries. `clearListContinuationDom`, the pending-`<li>` / direct-block lookups, and `findOpenListItemHost` used `querySelector`/`:scope >` over the whole committed subtree — ~700µs each on a large tree in jsdom, run every frame. Pending elements always live at the streaming tail (inside the last element child, or as the last direct child), and every clear runs before the next append, so scope the queries to `completedEl.lastElementChild` (~8µs). New `tailPendingDescendant` / `tailDirectPendingBlock` helpers; pending-only frames are now O(1). 2. Keep the frozen boundary monotonic without a fallback storm. `complete` is NOT append-only — a new unresolved inline in the trailing open paragraph retreats the split, shrinking `complete` and nudging the settled boundary back over a committed blank. The old `settledOffset < frozenEnd` fallback fired on that jitter ~once per paragraph, each time resetting frozen state and re-rendering the whole prefix (the residual O(n²)). Replace it: never retreat `frozenEnd` (clamp the advance), and fall back only when `frozenEnd` no longer lands on a block boundary — i.e. a later chunk extended an earlier block over it via lazy paragraph continuation (`"Foo\n"` + `" ***"` → one paragraph). New `tokenStraddles` binary-searches for that case. Verification: 441 tests + CommonMark conformance green; exhaustive convergence fuzz (every prefix of every baseline example) passes. New tests: frozen first paragraph keeps one node instance across a 30-paragraph inline-heavy stream (the jitter path that used to churn), lazy-continuation absorption byte-parity, and no stale pending element across pending-kind transitions. bench-streaming.mts gains a scaling regression guard (mean growth < 3×/doubling). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LnAFvAgDY3RVYT6zJqN1fy
…ath costs)
An adversarial review of the frozen/tail work surfaced four confirmed
correctness regressions (each reproduced on the branch; all pass on main)
plus two hot-path inefficiencies and a tooling problem. All fixed here,
each with a regression test.
1. Indented pending list item after a non-list block (streaming.ts).
The tail-scoped findOpenListItemHost could return the pending <li>
itself as the nesting host, so syncListPendingDom nested a list inside
it, then detached it — the pending item's text became invisible and a
fresh empty <ul> accumulated every other frame. findOpenListItemHost
now skips the pending item (the host must be a committed <li>), and the
indent>0 path with no committed host falls through to the trailing
top-level list, reusing the wrapper created on the previous frame.
2. Non-append-only updates kept stale frozen DOM (streaming-frozen-tail.ts).
Rewriting content in place ("# Alpha" -> "# Bravo") keeps block
boundaries intact, so every existing guard passed and the frozen region
showed the old text forever. New frozenSource guard: the exact source
bytes of the frozen region are kept and each commit checks
complete.startsWith(frozenSource) (a memcmp, no allocation), falling
back to the full morph on mismatch. Subsumes the frozenEnd-past-end check.
3. Unclosed benign raw inline tags broke byte-parity (streaming-frozen-tail.ts).
Whole-string sanitization re-opens an unclosed <b> into every later
block (HTML formatting-element reconstruction); per-fragment sanitize
cannot reproduce that. A delta whose rendered HTML has unbalanced
benign raw inline tags (b/i/u/s/del/ins/sub/sup/kbd/mark) is now never
frozen — the stream degrades to the full-morph path, correct output.
4. Node identity was destroyed at each block's freeze frame
(streaming-frozen-tail.ts). insertFrozenDelta parsed fresh nodes for the
newly-settled delta while the byte-identical existing tail nodes were
discarded — CSS transitions restarted, selection dropped, media
reloaded, once per block. The delta and tail are now reconciled in ONE
morphInnerHtmlFrom call, adopting the existing tail nodes; the frozen
node count advances via an O(delta) probe parse of the delta fragment
alone. insertFrozenDelta is deleted.
Also:
- serializeLinkRefs embedded literal NUL bytes as field separators, which
made this source file binary to git (unreviewable diffs). Entries are
now JSON-encoded lines — unambiguous and printable.
- Pending-only frames no longer pay O(prefix): tokenizeBlocks(complete)
and complete.includes('|') are computed once per COMMIT and cached on
the renderer (committedTokens/committedHasPipe); when the split commits
everything, split.blocks is reused instead of re-tokenizing.
- The per-commit deltaTokens/tailTokens are now binary-searched slices
(shared lowerBound, also used by tokenStraddles) instead of two
O(#tokens) filter passes.
- Module header now documents the long-open-list tail bound honestly.
445 tests + CommonMark conformance green; exhaustive convergence fuzz
(every prefix of every baseline example) passes; bench scaling holds at
~2.2x/doubling under the <3x regression guard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LnAFvAgDY3RVYT6zJqN1fy
This was referenced Jul 5, 2026
Closed
Merged
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.
Fixes the O(n²) committed-prefix re-rendering described in #21. Plain-prose DOM streaming now grows ~2×/doubling (down from ~4×) — ~12× faster at 100 paragraphs — while staying byte-identical to a fresh whole-string render.
Layer 1 — tokenize once per update (
5166457)A single
update()/renderStreamingMarkdown()call re-tokenized the streamed string ~5–8×. This threads the tokens computed during the split back through the helpers:splitForStreamingreturnstokenizeBlocks(content)via the newStreamingSplitWithTokens; the boundary logic moves to an internalsplitForStreamingCore.getIncompleteFenceSource/getIncompleteTableSource/completeEndsInOpenTable/pendingLineBelongsInTableaccept an optional pre-tokenized array.renderMarkdownacceptsoptions.tokens.Instrumented result: ~4.5–6.0 → ~1.5–2.0 tokenizations per update (now ~1 amortized after the review fixes' per-commit cache). Stateless refactor, no behavioral change.
Layer 2 — freeze the settled prefix, re-render only the tail (
e566e60)stream-completeis split into a frozen region (top-level groups that can never change again; rendered/sanitized once, permanent node identity) and a tail group (the last still-mergeable group; the only thing re-rendered per commit, morphed so a growing table/list keeps node identity).FrozenTailRenderer.updateis constant-time per commit regardless of prefix length. Every uncertainty falls back to today's exact full-morph behaviour.Scaling fixes (
2610f02)querySelectors (~700µs each in jsdom) becomelastElementChild-scoped (~8µs); pending-only frames are O(1).completeis not append-only (inline-hold retreat); never retreatfrozenEnd, and fall back only when a later chunk extends an earlier block over the boundary (tokenStraddles).Adversarial review + fixes (
e8e287b)A multi-agent review (line-by-line scan, removed-behavior audit, cross-file trace, plus differential fuzzes vs
main) found 4 confirmed regressions, all reproduced, all fixed with regression tests:findOpenListItemHostcould return the pending<li>as its own nesting host, detaching the pending subtree and accumulating empty<ul>s. Now skips the pending item and reuses the trailing wrapper list.# Alpha→# Bravoshowed Alpha forever (every guard passed). NewfrozenSourcebyte-prefix guard (complete.startsWith(frozenSource), a memcmp) falls back on mismatch.<b>into later blocks (formatting-element reconstruction); per-fragment sanitize can't. A delta with unbalanced benign tags is never frozen; the stream degrades to full-morph (correct output).morphInnerHtmlFrom;insertFrozenDeltadeleted.Plus:
serializeLinkRefsno longer embeds literal NUL bytes (the file was git-binary — its diff is now reviewable); pending-only frames no longer re-tokenizecompleteor re-scan for|(per-commit instance cache, reusingsplit.blockswhen everything commits); delta/tail token slices are binary-searched instead of two O(#tokens) filters.Verification
STREAMING_FUZZ_ALL=1, every prefix of every baseline example) plus two independent review-time differential fuzzes vsmain(~1,400 randomized streams).bench-streaming.mtsscaling section + regression guard (mean growth < 3×/doubling; currently ~2.2×).Known bounds (documented in the module header)
tokenizeBlocksand per-commitparseLinkReferenceDefinitionsstring scans stay O(prefix) (measured: 4% of wall-clock at 9 KB, 17% at 37 KB, 32% at 74 KB), so the asymptote is still Θ(n²); true linearity needs an incremental tokenizer + link-ref scan.lastCompleteintofrozenEnd) intentionally skipped.🤖 Generated with Claude Code
https://claude.ai/code/session_01LnAFvAgDY3RVYT6zJqN1fy