fix: rework remend code-region detection - #571
Conversation
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.
|
@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
|
Description
This change comes out of an investigation in usage of
remendthat got me doing performance profiling of streamdown output and noticing poor frame rates with a lot offoo__baridentifiers on screen. Wanted to contribute this back along with some of the testing techniques that helped detect a few bug cases as well as benchmark the performance.remend heals the full accumulated text on every streaming token, so it pays that cost per streamed token. Two problems were compounding on long responses:
__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 Issues
None.
Changes Made
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.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``.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).Scaling measurements
Healing time for a mixed-markdown document with a trailing open construct, as input scales. Measured with an ad hoc script running the published implementation and this branch on the same documents. The committed
Scalingbench group covers the new implementation at doubling sizes so linearity stays checkable withpnpm bench.Before grows ~4x per doubling at scale. After grows ~2x, staying linear. An adversarial tail of thousands of nested incomplete images was also quadratic through the link-healing loop and is now bounded, measured ad hoc at 146ms → 1.3ms for 16k characters.
Differential sweep
Every non-empty prefix of a fixed 8-document corpus mixing the interacting constructs, old output vs. new: 460 prefixes, 352 byte-identical, 108 changed. Every change falls in one of the intended classes:
"Use snake__c": old healed to"Use snake__c__", new leaves the identifier alone."~~~\nt": old healed to"~~~\nt~~"(strikethrough inside a tilde fence), new recognizes the fence."A ``d": old left it unchanged, new completes the double-backtick span to"A ``d``"."...and ![": old left the exposed trailing space, new strips it so healed output re-heals to itself.Idempotence holds on all 460 prefixes.
Checklist
pnpm changeset)Changeset