Skip to content

fix: rework remend code-region detection - #571

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

fix: rework remend code-region detection#571
bendrucker wants to merge 5 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 an investigation in usage of remend that got me doing performance profiling of streamdown output and noticing poor frame rates with a lot of foo__bar identifiers 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:

  • Handlers re-derived code-block state with a scan per candidate delimiter, so healing cost grew super-linearly with document size. At 133k characters a single healing call took ~945ms, per token.
  • 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 Issues

None.

Changes Made

  • 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.
  • 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 trailing space exposed by removing an incomplete image is stripped.

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).

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 Scaling bench group covers the new implementation at doubling sizes so linearity stays checkable with pnpm bench.

Characters Before After
1,990 1.01ms 0.41ms
3,974 1.75ms 0.35ms
7,942 4.86ms 0.54ms
15,878 16.12ms 1.03ms
29,766 51.9ms 1.9ms
132,686 945ms 9.9ms

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

  • 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

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

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.010010010089100

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.

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