fix: rework remend code-region detection - #571
Conversation
|
@bendrucker is attempting to deploy a commit to the Vercel Team on Vercel. A member of the Team first needs to authorize it. |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
Independent corroboration of the same root cause, measured on the published Measurements (2026-08-06/07) Corpus: heading + paragraph with bold, inline code and one link per section;
Per-call scaling exponent 1.85 (re-measured on current Root-cause pair (verified with a standalone transcription of just the two functions): the backward One thing the single-pass scanner here handles that a narrower fix would not: five other handlers call Happy to help verify once merged. |
|
FYI, working on some other perf work in Streamdown itself (❤️ |
|
I profiled this in a browser to see what was left after it. Two fixes worth having, both branched off Numbers are three runs each on one M1 Max, headless Chrome, 60k mixed document at 24 characters per 16ms tick, production build. The stream is nominally 40.5s, so every arm is saturated and wall clock measures time to drain. Caret SuppressionEvery stall over 50ms was The fix keeps both constant for the life of the stream and marks the element the caret actually decorates with
At full speed, 46.6s to 43.6s and 20 long tasks to 0. I built this first as a static CSS rule keyed off the existing Block Segmentation
Zero divergence from a full re-parse on every prefix, mixed and prose, healed and unhealed. In the browser at 4x, block parsing goes from 12.4 / 12.6 / 12.8s to 1.9 / 2.0 / 2.0s and wall clock from 74.6 / 75.8 / 78.2s to 65.4 / 66.5 / 67.9s. Those runs sit on the superseded caret arm and predate the last round of guard hardening, so treat the wall clock as stale even though the mechanism above is current.
Tests stream each backward-merging construct a character at a time, healed and unhealed, against a full re-parse at every step, plus documents assembled from the CommonMark spec's own examples. That corpus arrives as a Not Worth DoingPer-block direction detection under Streaming commits twice per token, 5,276 over 2,530 ticks. Removing the second commit means restructuring the transition Highlighting adds 14.4% to a code-heavy 60k stream under throttling. That is a plugin doing real work rather than a bug, and I mention it only because I wrote it off earlier on a smaller document. Harness
|
A shared single-pass scanner (scan.ts) classifies fenced code and inline spans once per input, replacing the per-character rescans that made healing quadratic on delimiter-heavy input. Fence semantics now follow CommonMark: fences open only at line start with up to 3 spaces of indent, tilde fences are recognized, closers must be at least the opener's length, and info strings can neither open nor close emphasis. Inline code spans close on a backtick run of exactly the opener's length. Double underscores are counted per maximal run with flanking rules, so identifiers containing __ (snake__case) no longer invent or swallow emphasis closers. Healing is idempotent: incomplete link/image removal iterates to a fixed point and the trailing space exposed by a removal is stripped like any other, so healed output re-heals to itself. A fast-check property suite and an exhaustive prefix sweep enforce this along with a bounded- loss oracle, and size-scaled bench cases make the linear scaling visible.
Pin the boundary of the output-side trailing-space strip with a test showing a double-space hard break before a removed image survives.
Recognize fences at any indent (list-nested fences carry deeper absolute indents than CommonMark's top-level 3-space cap) and on CRLF lines, so their content is no longer misread as an inline code span and corrupted with appended backticks. Stop inline code spans at blank lines, matching paragraph-scoped inline parsing, so one stray backtick run no longer disables healing for the rest of the stream. Treat the run after an escaped underscore as a delimiter again. Make the math, link-URL, and HTML masks region-aware so delimiters inside code cannot corrupt mask state for later prose, and skip building each mask when its trigger character is absent. Bound the link/image healing loop, which cost a full rescan per removed construct and turned healing quadratic on adversarial tails of nested incomplete constructs. Fold the three identical double-marker counting loops into one countDoublePairs helper on the scanner.
Upstream added LaTeX paren and bracket math contexts, intraword asterisk chains, and image placeholders while this branch was open. The math mask now tracks the same contexts as the per-call helper it replaced, the asterisk counter keeps the chain rules while skipping code regions, and the image tests follow the placeholder behavior. The code-block lookup test from upstream asserted parity with the old context-free backtick toggle. Its cases now state CommonMark fence semantics instead: a backtick run that is not at line start is inline code, and one that is opens a fence.
2ba89e0 to
7df6957
Compare
|
Noticed that this was conflicted and that the conflict came from applying a similar improvement more narrowly. Got this up to date and re-ran the benchmarks. #574's case is optimized (unchanged) but there are several others, most notably |
Description
This change comes out of profiling streamdown output with a lot of double-underscored (
foo__bar) identifiers on screen and noticing poor frame rates. Wanted to contribute it back along with the testing techniques that caught a few bugs and the benchmarks that measure the cost.Two problems were compounding on long responses:
*and_, which rescans from position zero, and the underscore skip rules walk backward to the line start twice more. One$anywhere in a response, e.g. a price in USD, makes every emphasis delimiter after it cost O(position). Onmaintoday a 52k-character mixed document costs 130ms per healing call, and healing every 48-character prefix of a 26k document totals 5 seconds with a 30ms worst call.__occurrence. Identifiers containing double-underscore runs (snake__casestyle, common in generated code and schema names) invented a closer or swallowed one that was needed, corrupting emphasis for the rest of the stream.This PR replaces the per-handler rescans with a single-pass region scanner and makes healing linear, CommonMark-aware, and idempotent.
Type of Change
Related
Supersedes the lookup cache from #574, which becomes a thin wrapper over the scanner.
Changes
src/scan.ts) paints a region code for every position (prose, fence marker/info/body, complete span, open span), memoized per input string. Handlers query it in O(1), so healing is linear in input size regardless of delimiter count. Math, link-URL, and HTML-tag context come from lazily built masks on the same scan, including the\(and\[LaTeX contexts from fix(remend): recognize LaTeX paren and bracket math in emphasis completion #523.Intended behavior changes, each covered by updated or new tests:
snake__case) no longer invent or swallow emphasis delimiters.~~~fences are recognized, so their content is no longer healed as prose.``code`heals to``code``.$before__boldnow suppresses healing the same waymainalready suppresses_italicand*italicafter it, socosts $5. __openstays as written. Treating a lone$as prose would restore healing in all four cases and is a reasonable follow-up.Testing
All existing tests pass
Added new tests for the changes
Manually tested the changes
Property-based tests (fast-check) assert streaming safety on every generated prefix: bounded loss against the input, idempotence, and no-op behavior on complete documents, plus a deterministic exhaustive prefix sweep over a fixed corpus.
New unit suites cover fence semantics (list-indented and CRLF fences included), underscore runs, and dollar signs inside code.
A manual pass drove growing prefixes of a mixed document through the rendered
<Streamdown>component, checking every frame for leaked backticks.The
Scalingbenchmark group demonstrates linearity (pnpm bench).Measurements against current main
Per-call healing time, median of 7 calls in one process, current
main(after #574) against this branch. The mixed document repeats a heading, a paragraph with bold, italic, inline code, a link andsnake__caseidentifiers, a list, and a fenced block, with a trailing open construct.$in the text$Simulated stream: healing every 48-character prefix of a 26k-character mixed document, 538 calls, median of 3 runs.
$$Disabling the
italichandler onmaindrops the 130ms call to 3ms, which attributes the remaining cost to its per-delimiter math and context checks.Checklist
pnpm changeset)Changeset