Skip to content

fix: rework remend code-region detection - #571

Open
bendrucker wants to merge 6 commits into
vercel:mainfrom
bendrucker:remend-perf
Open

fix: rework remend code-region detection#571
bendrucker wants to merge 6 commits into
vercel:mainfrom
bendrucker:remend-perf

Conversation

@bendrucker

@bendrucker bendrucker commented Aug 7, 2026

Copy link
Copy Markdown

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:

  1. Handlers re-derived context per candidate delimiter with a scan from the start of the text. fix: quadratic code-block scan in isInsideCodeBlock #574 fixed one of those scans, the code-block check. The italic handler still runs the math check per * 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). On main today 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.
  2. Double underscores were counted per raw __ occurrence. Identifiers containing double-underscore runs (snake__case style, 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

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Performance improvement
  • Refactoring (no functional changes)

Related

Supersedes the lookup cache from #574, which becomes a thin wrapper over the scanner.

Changes

  • A single-pass region scanner (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.
  • Fence and span semantics follow CommonMark.

Intended behavior changes, each covered by updated or new tests:

  • Word-internal double underscores (snake__case) no longer invent or swallow emphasis delimiters.
  • ~~~ fences are recognized, so their content is no longer healed as prose.
  • Multi-backtick spans complete with the right run length: ``code` heals to ``code``.
  • Text-only link mode resolves every unmatched bracket in one call instead of one per call.
  • A $ before __bold now suppresses healing the same way main already suppresses _italic and *italic after it, so costs $5. __open stays as written. Treating a lone $ as prose would restore healing in all four cases and is a reasonable follow-up.
  • The lookup test from fix: quadratic code-block scan in isInsideCodeBlock #574 asserted parity with the previous context-free ``` toggle at every position. Its cases now state the CommonMark rules instead: a backtick run away from line start is inline code, and one at line start opens a fence.

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 Scaling benchmark 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 and snake__case identifiers, a list, and a fenced block, with a trailing open construct.

Input Characters main This PR
#574's bracket-heavy unclosed fence 58,006 0.69ms 0.69ms
Mixed document, one $ in the text 51,802 130ms 2.6ms
Mixed document, no $ 208,462 172ms 12ms
Delimiter-heavy single-line prose 128,646 4032ms 8ms

Simulated stream: healing every 48-character prefix of a 26k-character mixed document, 538 calls, median of 3 runs.

Document main total main worst call This PR total This PR worst call
Contains one $ 5054ms 29.7ms 270ms 1.2ms
No $ 1007ms 5.4ms 363ms 1.7ms

Disabling the italic handler on main drops the 130ms call to 3ms, which attributes the remaining cost to its per-delimiter math and context checks.

Checklist

  • My code follows the project's code style
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings or errors
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have created a changeset (pnpm changeset)

Changeset

  • I have created a changeset for these changes

@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

@bendrucker is attempting to deploy a commit to the Vercel Team on Vercel.

A member of the Team first needs to authorize it.

@socket-security

socket-security Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​fast-check@​4.9.010010010087100

View full report

@Xuepoo

Xuepoo commented Aug 7, 2026

Copy link
Copy Markdown

Independent corroboration of the same root cause, measured on the published remend@1.3.0 and re-verified on current main — posting in case it helps the review.

Measurements (2026-08-06/07)

Corpus: heading + paragraph with bold, inline code and one link per section; remend() per-call cost (median of 9–11 runs, real headed Chrome 150, COI isolated):

document per call ns/char
25 sections, 3 070 ch 0.44 ms 132
50 sections, 6 170 ch 1.47 ms 239
100 sections, 12 370 ch 4.71 ms 390
200 sections, 25 070 ch 18.08 ms 718
400 sections, 50 470 ch 77.17 ms 1500

Per-call scaling exponent 1.85 (re-measured on current main in Bun: ≈1.77). Same-length corpus without the [..](..) construct: exponent 1.02, and the 400-section cost drops 75.7 ms → 1.44 ms (52×) — attribution by construct, not by document length.

Root-cause pair (verified with a standalone transcription of just the two functions): the backward [-scan in handleIncompleteLinksAndImages calls isInsideCodeBlock(text, i) — a full 0..i prefix scan — once per bracket. 82.9 ms at 400 sections vs remend's own 77.2 ms, exponent 2.013 in isolation. A left-to-right prefix table for isInsideCodeBlock measures 0.168 ms (495× faster, exponent 0.959) and agrees with the original at all 4 096 positions checked (fences, escaped backticks, unclosed spans).

One thing the single-pass scanner here handles that a narrower fix would not: five other handlers call isInsideCodeBlock from inside String.replace callbacks (single-tilde-handler.ts:29, comparison-operator-handler.ts:26, html-tag-handler.ts, strikethrough-handler.ts, link-image-handler.ts:24) — the same superlinear family.

Happy to help verify once merged.

@bendrucker

Copy link
Copy Markdown
Author

FYI, working on some other perf work in Streamdown itself (❤️ agent-browser profiler). Weighing how best to propose those. In theory I'd want to stack them rather than mix up multiple significant refactors. I haven't tried the new GitHub-native stacking in a fork but I don't think it can work.

@bendrucker

bendrucker commented Aug 11, 2026

Copy link
Copy Markdown
Author

I profiled this in a browser to see what was left after it. Two fixes worth having, both branched off main and measured on top of this PR.

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 Suppression

caret-suppression-host

Every stall over 50ms was UpdateLayoutTree, not JavaScript. shouldHideCaret flips every few tokens during a mixed stream, and it gates an inline custom property and three [&>*:last-child]:after:* classes on the container wrapping the whole document. Toggling either one against a rendered 4,661-element document costs about 48ms. A no-op control costs 0.00ms.

The fix keeps both constant for the life of the stream and marks the element the caret actually decorates with data-sd-caret-hidden, which Blink invalidates in O(1). Suppression stays derived from the markdown source, so it still works for consumer-supplied renderers.

4x throttle wall clock tasks > 50ms
before 114.2 / 114.5 / 115.1s 66 / 68 / 70
after 63.5 / 63.7 / 66.6s 0

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 data-streamdown markers. It measured nearly as well and is wrong: it misses every consumer-supplied renderer, and it makes correct behaviour depend on importing styles.css.

Block Segmentation

incremental-block-segmentation

parseMarkdownIntoBlocks re-lexes the whole document on every token, so segmenting a stream is quadratic in its length. Reusing the blocks ahead of a three-block trailing window makes it linear. Cumulative cost over the same 2,530-tick stream, measured outside a browser:

ticks re-parse reuse
250 0.3s 0.07s
1000 9.1s 0.29s
2500 109.9s 0.75s

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.

parseMarkdownIntoBlocks is lossy today, which is worth knowing independently of the branch. marked keys link reference definitions by label and drops a repeat, consuming the text and pushing no token, so a document with two [a]: lines lexes to less than it contains and blocks.join("") does not reproduce the input. Repeated citation definitions are a plausible model output. Nothing depends on that invariant today, and anything that starts to will be wrong. The branch falls back to a full parse whenever the tail holds ]:.

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 commonmark-spec devDependency under CC-BY-SA-4.0. It is test-only and nothing from it ships, but a copyleft licence entering the tree is your call, and the branch works without it.

Not Worth Doing

Per-block direction detection under dir="auto" looked like the same quadratic shape as segmentation. The difference changes sign across three paired runs.

Streaming commits twice per token, 5,276 over 2,530 ticks. Removing the second commit means restructuring the transition displayBlocks exists for, and I have no trustworthy number for what it buys.

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

streamdown-perf and bench-throttling add a /bench page to apps/test, a driver, a CPU-throttling hook, and a trace analyzer that attributes main-thread time per package through source maps. I kept them on my fork rather than proposing them, since they are a fair amount of tooling to own. Happy to open them if they would be useful.

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.
@bendrucker

bendrucker commented Sep 7, 2026

Copy link
Copy Markdown
Author

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 $ and __. PR body is updated with the latest.

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.

2 participants