Fix Studio CPU saturation on long streaming replies by oobabooga · Pull Request #8750 · unslothai/unsloth · GitHub
Skip to content

Fix Studio CPU saturation on long streaming replies - #8750

Merged
oobabooga merged 15 commits into
unslothai:mainfrom
oobabooga:fix/studio-streamdown-sync
Aug 14, 2026
Merged

Fix Studio CPU saturation on long streaming replies#8750
oobabooga merged 15 commits into
unslothai:mainfrom
oobabooga:fix/studio-streamdown-sync

Conversation

@oobabooga

@oobabooga oobabooga commented Aug 13, 2026

Copy link
Copy Markdown
Member

Summary

Long assistant replies could saturate Studio's renderer, grow a very large DOM, and make the desktop UI unresponsive while generation continued.

This removes unnecessary animation DOM and makes Markdown parsing incremental. Streaming updates remain synchronized with browser paints while the work performed for each update stays bounded to the active tail of the response.

Root cause

Four costs compounded on every token update:

  1. Streamdown 2.5 couples update scheduling to its animated option. Studio uses duration: 0 and stagger: 0 to select direct updates and avoid React transition starvation. Those values disable visible timing, but they do not disable Streamdown's rehype animation transform. Every non-whitespace text segment was still wrapped in a data-sd-animate span.
  2. Streamdown repaired and split the complete growing Markdown document on every displayed update. A reply of length n therefore repeatedly performed O(n) work even though only its tail changed.
  3. Studio recreated Streamdown configuration objects on raw token updates, defeating top-level memoization.
  4. Studio's custom block wrapper was not memoized, so completed blocks repeatedly reran artifact detection and React reconciliation while only the final block was changing.

Fix

  • Keep Streamdown's direct update path and coalesce token events at the next requestAnimationFrame.
  • Remove Streamdown's animation rehype plugin before each block is parsed.
  • Retain completed Markdown blocks and repair and split only an eight-block rollback tail. The parser callback prepends the retained blocks so Streamdown receives the same block sequence and React keys as a full parse.
  • Retain a prefix only at a byte-exact source boundary with neutral document-global repair parity for emphasis, inline code, strikethrough, and math markers.
  • Preserve marker presence, relative marker order, inline-code placement, and display-math context that remend uses for whole-document repair decisions across the retained boundary.
  • Keep temporarily unmatched marker tails live so they can recover when a later block closes them.
  • Switch once to the normal full-document path when a mid-string repair makes the raw prefix unsafe to retain. This keeps that fallback cost equivalent to the existing renderer rather than repeatedly retrying the failed prefix.
  • Parse footnote documents as a complete unit because Streamdown treats their definitions and references as globally scoped.
  • Reset the incremental cache on edits or message changes.
  • Use Streamdown's normal whole-document path when streaming completes.
  • Memoize Studio's custom block wrapper and hoist stable Streamdown configuration props.
  • Advance the Markdown string whenever the retained prefix changes, and remount when it cannot.

Production benchmark

Fresh production builds mounted a long formatted reply, then appended 4,000 characters over eight seconds. The producer submitted an update on every animation frame.

Chromium, viewport following the active tail

Final reply size Metric Before After Change
175,000 chars Renderer CPU, one-core equivalent 106.1% 65.5% -38.3%
175,000 chars Committed stream updates 16 404 +2,425%
175,000 chars Animation wrappers 22,134 0 -100%
175,000 chars DOM elements 27,807 5,673 -79.6%
175,000 chars Process-tree RSS 797.5 MiB 765.2 MiB -4.0%

The old renderer was already saturated at 175,000 characters and committed only 16 of roughly 420 possible paints. The new renderer stayed current at about 58 paints per second.

Markdown repair and block splitting

This isolates the work that previously scanned the complete 175,000-character document on every update.

Workload Before After Change
420 late-stream updates 12,252.5 ms 132.3 ms -98.9%

Conservative fallback

Replies containing a mid-string remend rewrite use the normal full-document path. This benchmark begins with - >= 16 GB of RAM, the failure shape that prevents safe prefix retention.

Document size Updates Normal full repair Incremental fallback Ratio
20,000 chars 200 202.8 ms 185.3 ms 0.91x
175,000 chars 30 855.3 ms 896.9 ms 1.05x

Equation-heavy compatibility check

An equation-heavy reply was tested separately with 14,000 characters, 57 rendered KaTeX equations, and 4,000 characters appended over eight seconds at full display cadence.

Metric Before After Change
Equations rendered 57 57 unchanged
Committed stream updates 420 421 unchanged
Renderer CPU, one-core equivalent 76.2% 46.9% -38.4%
Animation wrappers 2,705 0 -100%
DOM elements 11,818 9,113 -22.9%
Process-tree RSS 657.2 MiB 647.3 MiB -1.5%

This preserves the direct update behavior added by #7892 while reducing the work performed for each update.

Memoization boundary

Hoisting the configuration props also arms Streamdown's own memoization. Its comparator checks children and a list of props that are now all module constants, and it does not check parseMarkdownIntoBlocksFn, so the Markdown string became the only thing that can schedule a render. On main the inline plugins object literal made that comparison fail on every render, which hid the boundary.

The rendered block list is the retained blocks followed by a parse of children, so both halves have to be signalled. Retaining blocks waits for an update whose string differs, which a repeating reply would otherwise never produce. Dropping retained blocks cannot wait, because the document really did change, so the chat <Streamdown> key carries a generation that advances only in that case.

Correctness

Streaming content remains fully formatted. Math, code, Mermaid, sanitization, URL handling, and artifact behavior continue through the existing Streamdown component and plugins.

Incremental block results were compared against Streamdown's normal full repair and split at every character prefix across headings, paragraphs, links, nested lists, blockquotes, fenced code, HTML, display math, reference links, horizontal rules, tables, footnotes, mid-string tilde and comparison-operator repairs, and unmatched global markers. The marker corpus includes single, double, and triple emphasis markers, five-asterisk runs, inline-code candidates, shell-code dollar signs, strikethrough, single-line display math, and inline and multiline math.

Review added the whole-document rules the retained prefix also has to reproduce: remend completing a dangling link or truncating at a dangling image, its inline-code state at a block-final backtick, an asterisk run scoring once, a fenced ** ordering the closers, an escaped fence, the marker contexts injected for inline code, and Marked absorbing a repeated link reference definition. Each has a reduced case in the corpus.

Validation

  • npm test: 2,295 tests passed
  • npm run typecheck
  • npm run build
  • Production Chromium synthetic streaming at 175,000 characters
  • Production WebKit late-stream stress testing
  • Equation-heavy regression workload
  • 1,304,785 adversarial streamed prefixes across 3,250 synthetic documents matched full Streamdown block splitting exactly

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b66dbca6d3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// into one block so definitions can resolve references anywhere in the
// document. Such a construct is globally scoped and cannot retain a prefix.
if (
FOOTNOTE_REFERENCE_RE.test(repaired) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip fenced code when detecting footnotes

When a long coding reply contains an ordinary negated character class such as /[^a]/ inside a fenced or inline code span, the raw regex matches [^a] as a footnote reference here. That switches the cache into sticky full-document mode, so every subsequent token repairs and parses the complete growing response even though the Markdown contains no footnote; make this detection Markdown/code-context-aware rather than testing the raw text.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both regexes are copied from Streamdown 2.5.0. Its parseMarkdownIntoBlocks begins with the same /\[\^[\w-]{1,200}\](?!:)/ and /\[\^[\w-]{1,200}\]:/ applied to the raw string and returns the whole document as a single block on a match, code context included. Making this detection Markdown-aware would desync the cache from the splitter it has to agree with.

It would also buy nothing. With /[^a]/ present, Streamdown returns one block for the entire reply either way, so there is no prefix left to retain. Verified on that input: the incremental path and a full parse both return 1 block.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would leave this as it is. The regex does over-match /[^a-z]/, but Streamdown 2.5.0's own parseMarkdownIntoBlocks short-circuits on the identical pair of raw regexes and returns the whole document as a single block. Mirroring it is what keeps the two in agreement, and making this detection code-aware would produce N blocks where the splitter produces 1, which is a rendering bug rather than a speedup.

The impact is also not what is described. Since Streamdown collapses those documents to one block anyway, incremental parsing was never available for them, and the full-document path is the cheap answer: I measured that case at +2.7% against the full repair, not a reproduction of the saturation.

The one thing worth adding is coverage. The existing case escapes the regex only because /[^\s]+/ contains a backslash, which is not in [\w-]; /[^a-z]/ does match, so a regression test asserting parity with Streamdown's own splitting would pin the behaviour.

@mahiatlinux

Copy link
Copy Markdown
Collaborator

I spent a while on this one because the premise is good and I wanted to be sure before pushing back. Everything below is reproducible with one script against the code on this branch; it is included at the bottom.

First, the parts that hold up. I checked the three claims in the description against streamdown@2.5.0 in node_modules:

  • The animate rehype plugin really does wrap every word regardless of duration and stagger. Its visitor splits text nodes unconditionally and duration 0 only sets --sd-duration: 0ms.
  • The direct commit path really is gated on animated alone: t === "streaming" && !ge ? startTransition(...) : Tt(fe). Since ge is built inside Streamdown, filtering the plugin per block is the only place it can be removed. Setting animatePlugin: null on its own would not do it.
  • remend and the block split really do re-run over the whole document each update, and remend is about 88% of that cost, so it is the right thing to attack.

So the diagnosis is right. My concern is with the incremental path itself. Three things.

1. The reply stops updating when the repaired tail repeats

IncrementalMarkdownCache.parseMarkdownIntoBlocks (streaming-render-schedule.ts:448-451) is a stable bound method whose result depends on mutable committedBlocks. That means the only prop that can tell React a commit happened is children (markdown-text.tsx:503).

Streamdown is memoised, and its comparator does not include parseMarkdownIntoBlocksFn:

(e,t)=>e.children===t.children&&e.shikiTheme===t.shikiTheme&&e.isAnimating===t.isAnimating&&
e.animated===t.animated&&e.mode===t.mode&&e.plugins===t.plugins&&e.className===t.className&&

Every other compared prop is now a module constant thanks to the hoisting in this PR, so when children repeats the component does not render at all. Even if it did, fe = useMemo(() => h(Se), [Se, h]) inside would return the cached array for the same reason.

update() returns repairTail() of the tail after committing (:617-620), so when a frame's delta is exactly what got committed and the remaining tail is unchanged, the returned string is byte identical while committedBlocks grows. A reply that repeats a line, which is what a degenerate checkpoint produces, locks permanently:

  frame  4  reply   52 chars  children  52 chars  on screen   8 blocks  should be   8
  frame  5  reply   65 chars  children  52 chars  on screen   8 blocks  should be  10   <- stalled
  frame  8  reply  104 chars  children  52 chars  on screen   8 blocks  should be  16   <- stalled
  frame 12  reply  156 chars  children  52 chars  on screen   8 blocks  should be  24   <- stalled
  frame 24  reply  312 chars  children  52 chars  on screen   8 blocks  should be  48   <- stalled

It never recovers, because the frozen children value is reproduced every frame. The message appears to stop mid-generation and snaps to the full text at completion. Under random chunk sizes the milder version shows up on ordinary repeated content too: repeated identical paragraphs stalled 195 of 200 simulated streams, about 22% of frames, while numbered paragraphs and prose stalled zero times.

The fix that works here is making markdown change whenever the block list changes, for example by remembering the last returned string and deferring a commit that would collide with it. Changing the callback identity does not help, since the outer comparator ignores that prop.

Worth noting that tests/streaming-render-schedule.test.ts:83-96 cannot catch this: it calls render.parseMarkdownIntoBlocks fresh at every prefix, and the memo is the only thing that reads it in production.

2. Eight cases where the incremental blocks differ from the full repair

The invariant the PR rests on is that incremental output matches parseMarkdownIntoBlocks(remend(input)) at every prefix. These break it, all run through the real pipeline (stabilizeStreamingMarkdown(preprocessLaTeX(prefix), true)):

  four-backtick fence, then ~~   prefix  30  rendered "~~ y~~"           expected "~~ y"
  unclosed [ in prose            prefix  23  rendered "\n\n"             expected "](streamdown:incomplete-link)"
  escaped backtick, then $$      prefix  22  rendered "$$$$"             expected "$$"
  run of eight asterisks         prefix  28  rendered "*a*"              expected "*a"
  ** inside a fence              prefix  39  rendered "_u then **v**_"   expected "_u then **v_**"
  * inside inline code           prefix  41  rendered "****x"            expected "****x***"
  escaped fence \```             prefix  41  rendered "- \\>= 4"         expected "- >= 4"
  repeated link label            prefix  89  rendered "[x]: h"           expected "\n\n"

Cause by cause:

hasNeutralRepairParity omits emphasisInlineCode (:406). Together with boldFence that field mirrors remend's isWithinInlineCode, which gates seven of its handlers. A four-backtick fence leaves an inline span open that isTripleBacktick's three character window cannot see, and the tail then gets closers it should not. Adding the field to the array at :406 fixes it.

Unclosed [ is not tracked at all. remend's link handler scans the whole document backwards for an unmatched bracket and appends ](streamdown:incomplete-link) at the end. RepairParity has no bracket state, so a committed unmatched bracket disappears. This one has a realistic trigger: prose containing buffer[i diverges on 35 of 110 prefixes, and the user sees a placeholder link that vanishes the moment retention kicks in. A context prefix cannot express this since remend needs the actual unmatched bracket; it needs a cross-block bracket depth in the neutral check.

updateDisplayMathParity stops one character early (:359). remend uses that same bound but over the whole document, so it loses one character in total. Here it loses the last character of every committed block. index < text.length is enough; reading text[index + 1] past the end is undefined, which the $$ check already handles.

updateAsteriskParity returns before counting (:257-262). remend evaluates every * position independently, so a run of three or more always contributes exactly one to its single-asterisk count. The text[index + 1] === "*" branch returns before countsAsSingleAsterisk runs, so runs contribute zero. bold and tripleAsterisk mask most run lengths; eight is the smallest that slips through. Calling countsAsSingleAsterisk first fixes it.

Fenced ** is invisible to the ordering logic. remend picks between a mid-string _** and a trailing _ using a raw indexOf("**") that sees inside fences, while firstBoldOrSingleUnderscore (:259) is only set on the non-fence path. A fenced 2 ** 3 or **ptr is enough, and the stray _ lands on the wrong side of the bold marker.

INLINE_CODE_ASTERISK_CONTEXT is not parity neutral (:14). remend's countSingleAsterisks skips fences but not inline code, and one of its two call sites has no code guard, so the injected `a *b` flips the boldItalic decision for the tail. This is the one that contradicts the comment at :8-9. `a *b* c`\n\n keeps the same shape and restores neutrality. INLINE_CODE_UNDERSCORE_CONTEXT has the same asymmetry, currently latent behind a guard.

Escape and fence are checked in the wrong order (:294-305). remend's isWithinInlineCode applies the \` escape before the fence check; updateEmphasisParity does the fence first and the escape only after the boldFence early continue. An exhaustive scan of all 265,719 strings up to length 11 over {`, \, x} found 1,148 where remend says "inside code" and the parity model says neutral. The worst shape drops text rather than adding it: "The tag <s" renders as "The tag".

Link reference definitions are globally scoped too. The footnote bail-out at :534-539 treats footnotes as the only such construct, but marked registers link definitions in a per document tokens.links map and emits no token for a repeated label. Lexing only the tail never sees the earlier label, so the duplicate renders as a visible [x]: https://… line. 127 of 200 fuzzed documents with a repeated label diverge; 0 of 1000 with unique labels.

For scale: a differential fuzzer over the marker grammar found 108 divergences across 1,039,256 prefixes on this branch. With all seven parity fixes applied to a scratch copy, 0 across 4,174,597 prefixes. So the model is close, and these look like finishable gaps rather than a dead end.

3. The optimisation turns off on the replies it is meant to help

This is the part I would most want addressed before merge. repairTail().length / input.length on a 60 paragraph reply, where 1.00 means the whole document is still repaired and split on every token:

  plain prose (baseline)             0.07
  paragraph ends in `inline code`    1.00   <- optimisation off
  ```sh   echo $HOME                 1.00   <- optimisation off
  ```php  $x = 1;                    1.00   <- optimisation off
  ```make $(CC) x.c                  1.00   <- optimisation off
  `echo $HOME` inline                1.00   <- optimisation off
  ```js   const s = 'a`b';           1.00   <- optimisation off
  ```js   /[^a]/  (reads as footnote) 1.00   <- optimisation off
  prose: takes 5~10 minutes          1.00   <- optimisation off

Two roots.

updateEmphasisMathParity runs at :293, before the fence check at :294 and the inline code check at :302, so a $ inside a fence leaves emphasisInlineMath set for the rest of the message. That is pure conservatism, not safety, because remend does not act on fenced dollars at all:

remend("```sh\necho $HOME\n```\n\npara") === "```sh\necho $HOME\n```\n\npara"  // true

Unlike bold, asterisk and underscore there is no context prefix that can recover it, so every shell, PHP and Makefile snippet disables retention permanently.

Paragraphs ending in inline code are the other symptom of the text.length - 1 bound in updateDisplayMathParity. That shape is very common in coding replies.

Two more all-or-nothing bail-outs are worth a look. FOOTNOTE_REFERENCE_RE (:16) matches any regex character class, so /[^a]/ in a code block sends the message into sticky full document mode. The mid-string fallback fires on takes 5~10 minutes and - >= 16 GB of RAM, and one such string anywhere disables the optimisation for the whole reply. Separately, an unclosed HTML tag degrades silently: fullDocumentMode stays false, nothing is ever committable, and the cache measures slightly slower than not being there (365 ms against 350 ms on my run).

None of this shows up in the benchmark table because it uses prose and equations. Adding one code-heavy reply would make the picture much more representative.

Smaller things

markdown-text.tsx:446-452 swaps text.startsWith(displayed) for a length comparison, justified by the comment at :443 that the producer only appends. That is not the case: chat-adapter.ts:4446 yields "Generating audio..." and :4471 replaces the same part with <audio-player src="data:audio/wav;base64,…" />, which is longer and not a prefix. The old guard rejected it, the new one does not, so the placeholder survives an extra frame (longer if the tab is backgrounded and rAF is suspended). text.startsWith(displayed.text) restores it; keep the length check in front if the scan cost matters.

ROLLBACK_BLOCKS = 8 (:7) has no comment explaining the value, and since the block list interleaves "\n\n" separators it is roughly four content blocks rather than eight. Mutation testing only pins it above 1: 2, 3 and 20 all pass the suite.

parity.inlineMath and parity.inlineMathInlineCode look dead. They mirror a remend handler gated on options.inlineKatex === true, and remend is called with no options everywhere, so remend("$x") === "$x".

src/index.css:2494-2513 is now dead. With the animate plugin filtered out, no data-sd-animate span is ever emitted, and nothing else in the repo reads that attribute.

tests/markdown-block-remount-on-complete.test.ts no longer tests remount on complete. Its three tests are about animation-free props and memoisation, so the name is misleading now.

resetIncrementalState (:490) deliberately leaves this.source alone, which makes renderFullDocument (:502) correct only because all three call sites already assigned it. That is easy to break later without noticing.

On the tests

The prefix equivalence oracle in streaming-render-schedule.test.ts is exactly the right invariant, and because it calls remend live it would fail loudly if remend changed its rules. Both dependencies are pinned exactly, which helps. The corpus is where it falls short: 12 of 15 mutations survive the full 2,289 test suite, including deleting the getEmphasisContext ordering added in b66dbca (the whole content of that commit), stubbing isWithinHtmlTag and isWithinLinkDestination to return false, removing the escaped backtick and escaped dollar skips, and setting ROLLBACK_BLOCKS to 2, 3 or 20.

Instrumenting the corpus explains why: across all 30 cases at every prefix, hasSingleUnderscoreContext, firstSingleAsteriskContext === "inlineCode" and firstBoldOrSingleUnderscoreContext === "singleUnderscore" are never true. Three of the six context constants are never exercised. useCoalescedStreamingText has no behavioural test at all, only a source check that it mentions requestAnimationFrame.

Each of the eight documents in section 2 is short enough to drop straight into MARKDOWN_CASES.

What I checked that is fine

source === committedBlocks.join("") + tail holds on every path (2,811 update calls, no violations). update() is idempotent on repeated input, so the render-phase mutation at markdown-text.tsx:471-479 has no reproducible consequence beyond four new react-hooks/refs lint errors, and lint is not a CI gate. ROLLBACK_BLOCKS = 8 is sufficient against retro-merging constructs; 33 adversarial documents pass, and the real safety comes from the this.tail.startsWith(block, exactLength) check at :570. memo(StreamdownBlockContent) does not break the zustand or useSyncExternalStore subscriptions, and the useMemo on rehypePlugins is load bearing, since streamdown's Block comparator checks e.rehypePlugins !== t.rehypePlugins. parseIncompleteMarkdown={!incrementalRender} matches the library default for completed messages. Block keys, indices and isIncomplete match a full parse across 16,123 prefixes. No conflict with current main. #7892's direct commit path is preserved and #7949 is superseded properly, since no wrappers are created at all now. npm test (2,289 passing), npm run typecheck, npm run build and biome on the changed files are all green.

Reproduction script

Save as studio/frontend/.review/pr8750-repro.ts and run node --experimental-strip-types .review/pr8750-repro.ts from studio/frontend.

import remend from "remend";
import { parseMarkdownIntoBlocks } from "streamdown";
import { IncrementalMarkdownCache } from "../src/components/assistant-ui/streaming-render-schedule.ts";
import { stabilizeStreamingMarkdown } from "../src/components/assistant-ui/streaming-markdown.ts";
import { preprocessLaTeX } from "../src/lib/latex.ts";

// exactly what MarkdownTextImpl feeds the cache
const pipe = (text: string) => stabilizeStreamingMarkdown(preprocessLaTeX(text), true);
const filler = (count: number, label = "p") =>
  Array.from({ length: count }, (_, i) => `${label} ${i}\n\n`).join("");

console.log("=== 1. streamdown skips the render when the repaired tail repeats ===\n");
{
  const cache = new IncrementalMarkdownCache();
  let source = "";
  let renderedChildren: string | null = null;
  let renderedBlocks = 0;
  for (let frame = 1; frame <= 24; frame += 1) {
    source += "I am sorry.\n\n";
    const render = cache.update(pipe(source));
    // streamdown's memo compares `children`; it does not compare parseMarkdownIntoBlocksFn
    if (render.markdown !== renderedChildren) {
      renderedChildren = render.markdown;
      renderedBlocks = render.parseMarkdownIntoBlocks(render.markdown).length;
    }
    const actual = render.parseMarkdownIntoBlocks(render.markdown).length;
    if (frame % 4 === 0 || frame === 5) {
      console.log(
        `  frame ${String(frame).padStart(2)}  reply ${String(source.length).padStart(4)} chars` +
          `  children ${String(render.markdown.length).padStart(3)} chars` +
          `  on screen ${String(renderedBlocks).padStart(3)} blocks` +
          `  should be ${String(actual).padStart(3)}` +
          (renderedBlocks === actual ? "" : "   <- stalled"),
      );
    }
  }
}

console.log("\n=== 2. incremental blocks diverge from the full repair ===\n");
{
  const gap = "\n\np0\n\np1\n\np2\n\np3\n\n";
  const cases: [string, string][] = [
    ["four-backtick fence, then ~~", "```x````" + gap + "~~ y"],
    ["unclosed [ in prose", "see [note" + gap + "tail"],
    ["escaped backtick, then $$", "\\`" + gap + "$$"],
    ["run of eight asterisks", "********" + gap + "*a"],
    ["** inside a fence", "```\n**\n```" + gap + "_u then **v then **"],
    ["* inside inline code", "use `a *b* c` here" + gap + "****x"],
    ["escaped fence \\```", "a ``` b\n\nc \\``` d" + gap + "- >= 4 GB"],
    ["repeated link label", `[x]: https://e.test\n\n${filler(12)}[x]: https://e.test\n\nq\n\n`],
  ];
  for (const [name, document] of cases) {
    const cache = new IncrementalMarkdownCache();
    let report = "identical at every prefix";
    for (let length = 0; length <= document.length; length += 1) {
      const input = pipe(document.slice(0, length));
      const render = cache.update(input);
      const incremental = render.parseMarkdownIntoBlocks(render.markdown);
      const full = parseMarkdownIntoBlocks(remend(input));
      if (JSON.stringify(incremental) !== JSON.stringify(full)) {
        report =
          `prefix ${String(length).padStart(3)}  ` +
          `rendered ${JSON.stringify(incremental.at(-1))}  expected ${JSON.stringify(full.at(-1))}`;
        break;
      }
    }
    console.log(`  ${name.padEnd(30)} ${report}`);
  }
}

console.log("\n=== 3. share of the reply still re-parsed per update ===\n");
console.log("  1.00 means the whole document is repaired and split on every token.\n");
{
  const probe = (name: string, document: string) => {
    const input = pipe(document);
    const render = new IncrementalMarkdownCache().update(input);
    const ratio = render.markdown.length / input.length;
    console.log(
      `  ${name.padEnd(34)} ${ratio.toFixed(2)}${ratio > 0.5 ? "   <- optimisation off" : ""}`,
    );
  };
  const tail = filler(60);
  probe("plain prose (baseline)", tail);
  probe("paragraph ends in `inline code`", "run `foo`\n\n" + tail);
  probe("```sh   echo $HOME", "```sh\necho $HOME\n```\n\n" + tail);
  probe("```php  $x = 1;", "```php\n$x = 1;\n```\n\n" + tail);
  probe("```make $(CC) x.c", "```make\nall:\n\t$(CC) x.c\n```\n\n" + tail);
  probe("`echo $HOME` inline", "run `echo $HOME` now\n\n" + tail);
  probe("```js   const s = 'a`b';", "```js\nconst s = 'a`b';\n```\n\n" + tail);
  probe("```js   /[^a]/  (reads as footnote)", "```js\nconst re = /[^a]/;\n```\n\n" + tail);
  probe("prose: takes 5~10 minutes", "takes 5~10 minutes\n\n" + tail);
}

@oobabooga

Copy link
Copy Markdown
Member Author

Two follow-ups on top of this round.

f2a7904 is the fix for the retained-block report above.

771fb27 covers three more places where the retained prefix did not model what remend does over the whole document. Each produced a block list that differs from a full repair, and each is now in the prefix-equality corpus:

  • An unmatched [ in a retained block moved remend's dangling-link completion out of the tail. Pick x in the interval [0, 1) for the ratio. lost the completion block, and see ![alt text diverged on the whole body because remend truncates there. Brackets outside code now hold the neutrality gate.
  • updateDisplayMathParity stopped one character short of every block, so a backtick ending a retained block never closed inline code and the tail gained a spurious $$. remend's own scan can stop early because it only ever reads a whole document; a retained block boundary is always interior to one.
  • remend orders its bold and underscore closers from a raw indexOf("**"), so char **argv; inside a fence still has to seed the bold context even though it must not reach the fence-aware counter.

Verification for both commits: npm test 2291 passed, npm run typecheck, prefix equality against a full Streamdown split at every character prefix over the corpus plus periodic and bracket-heavy documents, 600 randomized streaming cases, and a Chromium run of the production render path.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: e652271d45

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@oobabooga

Copy link
Copy Markdown
Member Author

Thanks @mahiatlinux, this is an unusually careful review and it found real problems. Your reproduction script ran as written. Everything below is on dad4740.

1. The stall when the repaired tail repeats

Your diagnosis matches what I landed in f2a7904, including the part that matters most: changing the callback identity does not help, because the comparator does not look at it. The cache now prices a candidate commit before applying it and holds those blocks in the live tail for one more update when the resulting string would repeat. In Chromium the repeating reply goes from 5 of 61 paragraphs to 61 of 61.

Your point about plugins is the piece I had not stated: the hoisting in this PR is what armed the bail-out, since the inline object literal made e.plugins === t.plugins false on every render before. That belongs in the description and I have added it.

2. The eight divergences

All eight are now identical at every prefix, and all eight are in MARKDOWN_CASES. Three had already gone in with 771fb27, the rest are in c1ccbb9.

  • hasNeutralRepairParity omits emphasisInlineCode: added to the gate. It mirrors remend's fence-aware inline-code scan, which the three character isTripleBacktick window cannot express.
  • Unclosed [: RepairParity now carries a bracket depth, counted outside code as remend counts it, and the gate holds on it. Measured against prose, closed links, reference links, code containing brackets and tables, the retention cost is nil; it only bites where the model was unsound.
  • updateDisplayMathParity stopping a character early: bound corrected. remend can stop early because it only ever reads a whole document; a retained block boundary is always interior to one.
  • updateAsteriskParity returning before counting: countsAsSingleAsterisk now runs before the pair branch consumes the next character.
  • Fenced **: it seeds the bold context without reaching the fence-aware counter, since remend orders its closers from a raw indexOf("**") while A() skips fences.
  • INLINE_CODE_ASTERISK_CONTEXT: now `a *b* c`, and the underscore twin now `a _b_ c`. You were right that the single marker contradicted the comment above it.
  • Escape before fence: updateEmphasisParity consumes an escaped backtick first, as remend's inline-code scan does. The later text[index - 1] !== "\\" guard became redundant and is gone.
  • Repeated link label: the cache records the definition labels it retains and gives up the prefix when the live tail redefines one, rather than adding another blanket bail-out.

3. Retention

paragraph ends in inline code was the text.length - 1 bound and is now 0.07 rather than 1.00, which is the shape you called very common in coding replies.

The rest of that table is unchanged, and here I think the reasoning is wrong rather than the measurement. remend's math parity is not inert on fenced dollars, it is fence-blind, and the effect is visible as soon as the tail has a marker to act on:

remend("see *italic")                                  // "see *italic*"
remend("```sh\necho $HOME\n```\n\nsee *italic")        // "see *italic"   closer withheld

Your para example has no marker, so nothing distinguishes the two runs. Retaining a prefix whose dollar parity is not neutral therefore does change how the tail is repaired, and ignoring fenced dollars would break the prefix oracle rather than satisfy it. Recovering that retention needs a context prefix that reproduces an open inline-math state, which the current mechanism cannot express and which I would rather do as its own change than fold in here.

The remaining bail-outs I have left alone on the same reasoning: the footnote regexes are copied from Streamdown's own splitter, which single-blocks the whole reply on the same raw match, so making ours context aware would only desync it.

Smaller things

  • text.startsWith(displayed.text) is restored, with the length check still in front. The audio path is a real trigger: chat-adapter.ts:4447 yields Generating audio... and :4476 replaces the same part with the player. Measured cost of the scan is 59 ms across a 175,000 character stream, against the 132 ms the whole incremental parse now takes, so it is not worth trading correctness for.
  • ROLLBACK_BLOCKS now says what it is and that the list interleaves separators.
  • resetIncrementalState assigns this.source itself instead of relying on its callers.
  • parity.inlineMath and parity.inlineMathInlineCode: the handler is indeed unreachable at remend 1.3.0. I left them, because emphasisInlineMath already dominates them on every input I could construct, so removing them changes nothing measurable while loosening a safety gate against a pinned dependency.
  • The data-sd-animate rule in index.css: left in place. It costs nothing at runtime and it is the fix for a real visual bug if the animation ever comes back.

Tests

Your eight documents are in the corpus, plus three that reach contexts nothing previously touched. Instrumenting the corpus before, hasSingleUnderscoreContext, firstSingleUnderscoreContext === "inlineCode" and firstBoldOrSingleUnderscoreContext === "singleUnderscore" were never true at any prefix, which is how two of the context constants above stayed wrong. All seven are exercised now.

npm test 2293 passed, npm run typecheck, biome clean on the changed files, prefix equality over the corpus plus periodic and bracket-heavy documents, 600 randomized streaming cases, and a Chromium run of the production render path for the stall and the reset.

@oobabooga

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dad4740099

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +103 to +104
if (character === "(") {
return cursor > 0 && text[cursor - 1] === "]" ? cursor : -1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle nested parentheses in link destinations

When a valid link destination contains nested parentheses before a single underscore, such as [topic](https://example.test/a_(b)_overview), this scan reaches the inner ( first and immediately returns -1 because it is not preceded by ]. The underscore is then counted as an unmatched emphasis marker rather than URL content, so no later commit boundary is considered neutral and the live tail grows with the rest of the stream; long replies containing such links consequently return to reparsing an ever-growing suffix on every frame. Track balanced/escaped parentheses while searching for the destination opener instead of rejecting the first nested opener.

Useful? React with 👍 / 👎.

@oobabooga oobabooga Aug 14, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This scan is a deliberate mirror of remend's own, which stops at the first ) in exactly the same way, so tracking balanced parentheses here would make the parity disagree with the repair it exists to predict.

remend counts that underscore as an unmatched marker itself:

remend("plain text")                                                        // "plain text"
remend("[topic](https://example.test/a_(b)_overview)\n\nplain text")        // tail becomes "plain text_"
remend("see _italic")                                                       // "see _italic_"  closes
remend("[topic](https://example.test/a_(b)_overview)\n\nsee _italic")       // tail stays "see _italic"

So the document genuinely has an open marker as far as remend is concerned, and no prefix is safely retainable while that is true. The retention loss you measured is real and I reproduced it, 217 characters live against 24 for the same link without parentheses, but it is inherited from remend rather than introduced here, and the alternative is a tail repair that disagrees with a full one. Deliberate, same as the fenced-dollar case above.

@oobabooga

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a2a026428f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +715 to +718
this.committedLinkLabels.size > 0 &&
linkDefinitionLabels(blocks).some((label) =>
this.committedLinkLabels.has(label),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve global reference-link resolution

When a long stream commits [x]: /url and a later tail contains see [label][x]—or commits the reference before a distant definition—Marked's document-wide definition table resolves the link in a full parse, but this condition only detects duplicate definitions. The tail is therefore lexed without earlier definitions, and committed references are never re-lexed for later definitions, leaving the link as plain text until streaming completes; track reference/definition dependencies or use the full-document path once definitions are present.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Streamdown does not resolve a reference link across blocks at all, so there is nothing here for the retained prefix to preserve. Rendering the full document through <Streamdown mode="streaming"> and looking for the anchor:

definition in the very next block   resolves: false
definition 20 blocks later          resolves: false
a lone reference paragraph          resolves: false

Each block is rendered by its own remark pipeline, so the document-wide table you describe never reaches the block holding the reference. That is true on main as well, with no incremental cache in play, so the behaviour is unchanged rather than degraded here.

The block split is also unaffected: a reference is a paragraph token with the same raw whether or not a definition exists, which is why retainedCase("A [ref][x]\n\n[x]: https://example.com") matches a full parse at every prefix. Definitions are handled for the one thing that does change the split, Marked absorbing a repeated label, and 15d19b8 does that by keeping every definition in the live tail.

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 14, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 14, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 14, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 14, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 14, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 15d19b89ca

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danielhanchen

Copy link
Copy Markdown
Member

Reviewed this at head dad474009 against merge base 10f34dbf6. The core claim holds up, and I have independent numbers for it plus a verdict on each of the three Codex findings.

The optimisation works

Two isolated Studio installs (install.sh --local from each tree, so each serves its own bundle), one deterministic mocked SSE producer, no model and no GPU. Both sides get the same 175,000 character reply, then 4,000 characters appended over 8 seconds, and are photographed at the same elapsed time from the producer's start.

In the same 8 seconds of producer time:

Metric Before After Change
Markers painted SEQ_000021 SEQ_000223 10.6x
Animation wrappers 26,594 0 -100%
DOM elements 33,427 6,842 -79.5%
Long task time 13,903 ms 2,708 ms -80.5%

The DOM figure lands within a tenth of a percent of the -79.6% in the description. The saturation also showed up in my own harness clock: the 5s and 7s capture deadlines landed at 7.004s and 10.016s on the old renderer because the page could not service them, while the new one hit 5.002s and 7.000s.

At 2 seconds:

before and after at 2s

At 5 seconds, where the old renderer has already finished its (much shorter) stream:

before and after at 5s

One caveat on my own harness rather than on this PR: the producer is driven by requestAnimationFrame, so a starved renderer emits fewer markers and the two sides genuinely stream different text. That means my final text hash cannot be used as an equivalence control. Equivalence rests on the every-prefix block equality test in the suite instead.

Separately, replaying just the repair and split path over 420 late-stream updates on a 175,000 character reply reproduces the headline number: 8,689 ms to 76 ms, or -99.1% against the -98.9% in the description.

On the three review comments

Parser identity (streaming-render-schedule.ts:451). This was real, and it was a correctness bug rather than a throughput one. On the earlier head I could drive the display to freeze at 8 blocks while 120 existed, with 112 blocks simply never appearing, on any run of repeated blocks (identical list items, repeated rules). Worth noting for the future that the existing assertion style could not catch it: render.parseMarkdownIntoBlocks(render.markdown) calls the parser directly, which always recomputes, whereas Streamdown reaches it through useMemo(() => h(Se), [Se, h]). The renderGeneration work on the current head resolves it, and I can no longer reproduce any missing blocks.

Footnote detection (streaming-render-schedule.ts:535). I would leave this exactly as it is. The regex does over-match /[^a-z]/, but Streamdown 2.5.0's own parseMarkdownIntoBlocks short-circuits on the identical pair of raw regexes and returns the whole document as one block. Matching it is what keeps the two in agreement; making the detection code-aware would produce N blocks where the splitter produces 1. The suggested cost is also not what I measured: that case runs at +2.7% against the full-document path, because full-document mode is the correct and cheap answer there. The only gap is test coverage, since /[^\s]+/ in the existing case escapes the regex purely because a backslash is not in [\w-].

Math markers in fenced code (streaming-render-schedule.ts:293). This one is real, still present at dad474009, and worse than the comment suggests. The remedy in the comment would break things though: remend counts $ and $$ deliberately without fence awareness (yn and Nn skip backticks only when they are not part of a ``` run), so a fence-aware tracker would disagree with the repair it is mirroring.

The problem is the retry, not the parity. Because emphasisInlineMath never returns to neutral, nothing ever commits, and every update pays the incremental scan on top of the full repair it was meant to replace:

175,000 char reply, 420 updates Incremental Full repair Change
Clean 76.5 ms 8,806.2 ms -99.1%
One ```sh / echo $HOME fence 20,173.7 ms 8,662.6 ms +132.9%
One ```text / cost $$ each fence 19,970.4 ms 9,025.3 ms +121.3%

So a shell snippet anywhere in a reply makes this path about 2.3x slower than the renderer it replaces, which matters because code-heavy replies are the common case.

The description already has the right principle for this ("keeps that fallback cost equivalent to the existing renderer rather than repeatedly retrying the failed prefix"), it is just applied only to the mid-string repair path. Extending it to a tail that has committed nothing fixes it:

const STALLED_TAIL_CHARACTERS = 32_768;

if (!commit.parity) {
  if (commit.repairBroke || this.tail.length > STALLED_TAIL_CHARACTERS) {
    return this.renderFullDocument(markdown);
  }
  return this.render(repaired);
}

That takes echo $HOME from +132.9% to -2.6% and cost $$ each from +121.3% to +1.2%, with the clean case unchanged at -99.1% and the suite green at 2,293 passing. A character budget rather than a block count because characters are what the repair scan actually costs, and a genuinely transient imbalance closes far below it, so the recovery behaviour is kept.

Nice work on this one, the measurements back up what the description claims.

@oobabooga

Copy link
Copy Markdown
Member Author

Thanks @danielhanchen. The stalled-tail finding is right and I had it wrong, so that one is fixed in bb228e5.

I reproduced your table independently on 15d19b89c, timing repair plus the split Streamdown then runs, 420 late-stream updates on a 175,000 character reply:

                                  incremental   full repair    change
clean                                  103 ms       2436 ms    -95.8%
one ```sh / echo $HOME fence          7319 ms       2723 ms   +168.8%
one ```text / cost $$ each fence      7544 ms       2739 ms   +175.5%
inline `echo $HOME`                   7190 ms       2723 ms   +164.0%

With the character budget applied those become -2.7%, +1.9% and +0.6%, clean unchanged at -96.0%. Your -2.6% and my -2.7% on the same case is closer agreement than I expected across two machines.

What I got wrong is worth stating plainly, because it is a measurement mistake rather than a judgement call. I had rejected this on the grounds that the cost "falls back to what main already pays", and I reached that by comparing characters repaired over the stream, 892k against 895k for the full-document path. That metric looks equivalent and is not, because it counts neither the boundary scan nor the second block split. Wall clock says 2.7x. I have corrected that reply and flipped the reaction on the item.

I also took the test-coverage gap you and @mahiatlinux both named. The existing character-class case used /[^\s]+/, which never matched the footnote regex in the first place, so it asserted nothing about the branch it was named for. It now covers both sides: /[^a-z]/ does match, Streamdown's splitter returns the whole reply as one block for it, and the cache agrees by taking the full-document path; /[^\s]+/ keeps retaining.

One tradeoff in the fix worth flagging. The budget reuses the existing sticky fallback, so a reply whose live tail passes 32,768 characters with nothing committed stays on the full-document path even if the marker later closes. A single fenced block longer than the budget is the realistic way to hit that. It is equal to the current renderer rather than better, which seemed like the right end of the trade against retrying a prefix that keeps failing, but it is a real limit rather than none.

Your other two verdicts match where this ended up. The footnote regexes are unchanged and I would leave them: they are copied from Streamdown 2.5.0's own short-circuit, so matching it is what keeps the two in agreement. And renderGeneration is what closed the parser-identity bug, after lastMarkdown alone turned out to cover only the retaining half; dropping retained blocks cannot wait for a different string, so that path moves the render identity instead.

Since dad474009 there are three more commits, all from review: a2a0264 and 15d19b8 for link reference definitions, where my first attempt at the duplicate-label check was itself wrong in both directions against Marked's table and got replaced by a single neutrality term, and 15d19b8 also stops the cache redoing its work on renders that carry unchanged text. npm test is at 2,296.

@oobabooga

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@oobabooga

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bb228e55a9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
const incrementalCache = incrementalCacheRef.current.cache;
const incrementalRender = isStreaming
? incrementalCache.update(processedText)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer cache mutation until the render commits

When React interrupts or discards a concurrent render, this call has already mutated source, committedBlocks, lastMarkdown, and possibly renderGeneration even though Streamdown never received that render. This is observable for repeating tails whose child Markdown cycles back to the last committed value: Streamdown's comparator ignores the parser callback and can skip that later render, leaving newly retained blocks absent until another distinct update or completion. Keep render calculation pure or commit the cache state in an effect/layout step.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mechanism is real and I reproduced it, but the only render this path actually discards is StrictMode's, and that one is safe.

Forcing renders to be thrown away, 35% of updates over repeating content, does lose blocks, so the hazard you describe is not theoretical:

                                             blocks missing from the DOM
normal                                                                0
StrictMode double invoke, first result discarded                      0
renders forcibly discarded                                           48

The middle row is the one that decides it. React calls the component twice under StrictMode and throws the first result away, which is exactly your scenario, and it loses nothing because update returns its previous result unchanged when the text has not moved. That guard went in for a different reason, and on the commit before it the same run loses 392 blocks, so this path was genuinely exposed until then.

Beyond StrictMode, nothing in the chat render path is interruptible: no startTransition, no useDeferredValue, no useTransition anywhere in components/assistant-ui/ or features/chat/components/. Streamdown has its own useTransition but that is inside the memoised child, below this call.

So I am leaving the call in render. It is worth writing down that this rests on React not discarding a committed render here rather than on the cache being pure, and that the remedy if that ever changes is the one you name, moving the commit into an effect.

<div data-status={status.type} className="min-w-0 max-w-full">
<Streamdown
key={messageId}
key={`${messageId}:${incrementalCache.renderGeneration}`}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Force Streamdown to render when streaming completes

When the final text was already painted before the status changes—common for a balanced fenced-code reply—the streaming child and completed child are byte-identical, and this key also remains unchanged. Streamdown 2.5's memo comparator ignores isAnimating, parseIncompleteMarkdown, and the parser callback; now that the other compared configuration props are stable, it skips the completion render and leaves the last block permanently isIncomplete. As a result, code copy/download controls remain disabled and completed SVG or HTML fences can remain in their loading/raw placeholders; include the streaming state in the render identity or otherwise force the completion render.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comparator does compare isAnimating, and that is the prop this flips at completion, so the completion render is not skipped.

Streamdown.compare.toString() contains isAnimating: true

identical children, isAnimating true -> false   equal (skip)? false
identical children, isAnimating unchanged       equal (skip)? true
only parseIncompleteMarkdown differs            equal (skip)? true

You are right that parseIncompleteMarkdown and the parser callback are both ignored, but isAnimating={isStreaming} changes with the status, so the comparator returns false whatever the text did.

Confirmed in Chromium on the exact shape you describe, a balanced fenced-code reply whose final text is painted while still streaming, then the status flipped with byte-identical text: the DOM changes on completion (9,367 to 9,343 characters, and the first data-streamdown element is not the same node). If the render had been skipped both would be identical.

The budget is what gets spent before the retained prefix is abandoned, and that
spending grows with the square of the tail, so the value is not free. A long
fenced block is not what reaches it: while the fence is open the tail lexes to a
handful of blocks, candidateCount stays 0, and the prefix is still retainable.
What reaches it is an inline marker with many paragraphs before its closer.

Measured on an emphasis marker closing 40,000 characters later, 420 updates,
five repetitions, median against the full-document path: +73% at 32,768 and
+1.6% at 8,192. 8,192 is still about 1,300 words of slack, so a marker a later
line genuinely closes keeps its recovery.

Adds regression cover for both shapes, since the existing case only covers a
marker that never closes at all.
@danielhanchen

Copy link
Copy Markdown
Member

Thanks for the correction, and for reproducing the table independently. Agreement that close across two machines is worth more than either number alone.

I put the branch through a broader simulation before signing off, aimed at the things that only show up off this machine: other browser engines, other line endings, and inputs that are not ASCII. Everything ran in isolated sandboxes on Linux against bb228e55, with npm ci from the committed lockfile.

What was checked

Differential fuzz, 14,234,746 prefixes, 0 failures. A grammar of about 40 Markdown fragment generators (headings, setext, fenced and tilde and indented code, ordered and nested and task lists, blockquotes, GFM tables, rules, closed and unclosed HTML, all four math syntaxes, footnotes, link reference definitions, autolinks, escapes) crossed with truncated tails and the mid-string repair shapes (5~10, >= 16 GB, 2 ** 3, *.py, _private). Every document replayed at every prefix, asserted against parseMarkdownIntoBlocks(remend(input)). Two assertion modes: the direct call, and one that goes through Streamdown's real useMemo(() => h(Se), [Se, h]) semantics, since a direct call always recomputes and structurally cannot see a stale committed prefix.

Browser engines. The cache was bundled and the same corpus run inside Chromium, Firefox and WebKit as well as Node. All four produce byte-identical digests over the whole prefix history, and each matches a full Streamdown split, across 1.7M prefixes per engine. Chromium covers Chrome and Edge; the desktop shell's WebView is Chromium on Windows and Linux and WebKit on macOS, so those three are the shipped matrix. No lookbehind, named groups or newer built-ins in the changed files; the only notable construct is \p{L}\p{N} with the u flag, which every target has had for years.

Whole app, all three engines. A real Studio, a mocked SSE stream and no model, streaming a fixed sequence of chunks so content is identical by construction:

structure content hash chars elements animation wrappers
chromium 26a6aaa5f43cd292 979a1a1e 45,118 1,461 0
firefox 26a6aaa5f43cd292 979a1a1e 45,118 1,461 0
webkit 26a6aaa5f43cd292 979a1a1e 45,118 1,461 0

Identical across engines, and identical to the merge base on all three. That also retires the caveat on my earlier comment: the final-hash control failed there only because that producer was driven by requestAnimationFrame, so a starved renderer received fewer markers and the two sides genuinely streamed different text. With the content pinned it is a real equivalence check, and it passes.

Line endings and Unicode. CRLF documents across every construct, lone CR, mixed endings; streams cut through a surrogate pair (emoji, ZWJ sequences, flag pairs); CJK, RTL, combining marks, zero-width joiners; astral-plane word characters around emphasis markers. All match a full split at every prefix.

Lifecycle and compatibility. Non-prefix edits, shrinking replies, repeated identical updates, empty and single-character inputs, aborts mid-token in six shapes, two concurrent caches. Plus the forwards-compatible degradation of withoutStreamdownAnimationPlugin when animatePlugin is absent, when it stops exposing rehypePlugin, and when the plugin is not in the array. Old installs are unaffected: the PR touches five frontend files and nothing outside assistant-ui, there is no persisted format, and a completed message takes the normal whole-document path.

One thing on the tradeoff you flagged

The limit is real but the example is not the one that triggers it. A single fenced block longer than the budget keeps retaining, at any size I tried up to 80,000 characters: while the fence is open the tail lexes to a handful of blocks, candidateCount stays 0, and the stalled check is never reached.

What does reach it is an inline marker with many paragraphs before its closer. And since the budget is what gets spent before giving up, and that spending grows with the square of the tail, the value matters more than it looks. An emphasis marker closing 40,000 characters later, 420 updates, five repetitions, median against the full-document path:

Budget Median cost
32,768 +73.0%
8,192 +1.6%

The distributions do not overlap. I pushed e679d24 setting the budget to 8,192 with that measurement in the comment, plus regression cover for both shapes, since the existing case only covers a marker that never closes at all. 8,192 is still about 1,300 words of slack, so a marker a later line genuinely closes keeps its recovery, and the transient case still passes.

npm test 2,298, typecheck, biome and eslint all clean.

@oobabooga

Copy link
Copy Markdown
Member Author

Correcting the limitation I stated above, since @danielhanchen's e679d244 shows I had the shape wrong.

I said a single fenced block longer than the budget was the realistic way to reach it. It is not, and the reason is the one his commit message gives: while a fence is open the tail lexes to one block, so candidateCount stays 0, findCommitBoundary never runs, and the branch that spends the budget is never reached. Measured on this head:

open fence   10,026 chars -> blocks=1  candidateCount=0  sticky=false
open fence   40,026 chars -> blocks=1  candidateCount=0  sticky=false
open fence  120,006 chars -> blocks=1  candidateCount=0  sticky=false

and once the fence closes, retention resumes fully: a 12,000 character fence followed by 200 paragraphs ends with a 24 character live tail. What actually reaches the budget is an inline marker with many paragraphs before its closer, which is what he measured and sized against.

His 8,192 is the better number and I can reproduce why. On the same 420-update, 175,000 character benchmark the stalled cases now sit at -1.1%, +0.7% and -1.3% against the full-document path, against -2.7%, +1.9% and +0.6% at 32,768, with the clean case unchanged at -95.9%.

4100f918 on top handles the last round: a link reference label may contain an escaped bracket, [foo\]bar]: /url, which Marked registers and the character class did not, so that definition was retainable and a redefinition diverged at prefix 105. The label grammar now takes escapes.

The other two from that round I have argued rather than changed, both on the comments themselves. The completion render is not skipped, because the comparator does check isAnimating and that is what the status flips. And the cache call in render survives the only render React discards here, StrictMode's double invoke, which loses 0 blocks now against 392 on the commit before the unchanged-text guard went in.

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 14, 2026
@unslothai unslothai deleted a comment from danielhanchen Aug 14, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 14, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 4100f918b1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@oobabooga

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

@oobabooga
oobabooga merged commit d8c1145 into unslothai:main Aug 14, 2026
36 of 41 checks passed
danielhanchen added a commit that referenced this pull request Aug 16, 2026
The long-task total is the metric the budgets turn on, and it reads 0 both
when the render is free and when the observer never ran. observe({type:
"longtask"}) is specified to abort silently on an engine that lacks the
entry type rather than throw, so the try/catch around it never fired: under
firefox or webkit the harness scored a perfect zero and exited 0. Detect
support with PerformanceObserver.supportedEntryTypes, record it, and fail
the run when no long tasks were seen or when throttling was disabled.

Also:
- add the entry to tsconfig.app.json, which lists the smoke entries one by
  one, so npm run typecheck actually covers the 270 lines it was reported
  against
- write the JSON under logs/ like every sibling harness instead of dropping
  an untracked stream-pacing.json in the repo root, and create the directory
- treat an exported-but-empty SMOKE_BASE_URL as unset, matching the siblings;
  it drove "" as the base URL and burned the full readiness timeout
- register the harness in the two contract tests, which is what surfaced the
  SMOKE_BASE_URL bug, and pin the new guards there
- record the second mutation: reverting #7892 moves the longest stall 4-5x
  while leaving the long-task total inside the clean range, the exact
  opposite of reverting #8750, so both budgets are load-bearing
danielhanchen added a commit that referenced this pull request Aug 16, 2026
buffered: true replays whatever the performance timeline already held, so
module evaluation and the first React render landed in the budgeted total:
one entry, ~140ms, about 2.6% of a clean run here, and larger on a cold or
loaded runner. Nothing filtered by startTime and run() reset nothing, so a
slow page load read as a slow renderer.

Open the measurement window in run() and drop entries that began before it.
Pre-stream share goes 2.6% to 0.00% while the stream's own tasks are
unchanged (60 and 52 entries over two clean runs), and reverting #8750 still
fails the budget at 52,465ms.
danielhanchen added a commit that referenced this pull request Aug 16, 2026
… the stream

Two holes left by the measurement window added in the previous commits.

A long task carries the start time of its whole task, so appending the user
message in the same task that assigned measureFrom stamped runtime startup
and the first publish as earlier than the window and dropped them as page
load. Hand the append to a later task so the work that begins the stream
sorts inside it.

longestStallMs was only ever written when a later paint closed the stall, so
a freeze that ran to the end of the stream was never recorded: the tail can
go missing inside the 90% floor and the quiet-frame loop then calls it
settled. Measure the stall in progress while text is still arriving, which is
what the number means, and not afterwards, where the settle window's own
quiet frames would read as a freeze.

Clean runs unchanged (stall 933 to 1,050ms, long tasks 4,749 to 5,159ms over
three) and both mutations still caught: #7892 reverted fails the stall at
5,233ms, #8750 reverted fails long tasks at 52,263ms.
danielhanchen added a commit that referenced this pull request Aug 16, 2026
The stall in progress was measured only while text was still arriving. A
freeze that spans the moment the stream ends blocks the frame loop across
it, so the first frame afterwards already observes a non-null
streamEndedAtMs and the whole frozen interval was skipped. With the lost
tail able to hide inside the 90% workload floor, thirty quiet frames then
settled the reply and the run reported a short longest stall, which is the
one shape this number exists to catch.

Cap the interval at the absolute stream-end timestamp instead. A freeze
across that moment is recorded in full, and the stall stops growing once
there is no more text to wait for, so the settle check's own quiet frames
are still not counted as a freeze.

The rule moves into smoke-stream-pacing-stall.ts so it can be tested
without importing the harness entry, which mounts React on import. The new
tests cover the spanning freeze, the settle-window bound, idempotence and
late tail paint; restoring the previous rule fails two of the five.

Clean runs unchanged (stall 967 to 983ms, long tasks 5,442 to 5,842ms) and
both mutations still caught: #7892 reverted fails the stall at 5,017ms,
#8750 reverted fails long tasks at 63,687ms.
danielhanchen added a commit that referenced this pull request Aug 18, 2026
#8969)

* Studio: keep the streaming render harness the perf PRs kept rebuilding

* Studio: close the false-green paths in the stream pacing harness

The long-task total is the metric the budgets turn on, and it reads 0 both
when the render is free and when the observer never ran. observe({type:
"longtask"}) is specified to abort silently on an engine that lacks the
entry type rather than throw, so the try/catch around it never fired: under
firefox or webkit the harness scored a perfect zero and exited 0. Detect
support with PerformanceObserver.supportedEntryTypes, record it, and fail
the run when no long tasks were seen or when throttling was disabled.

Also:
- add the entry to tsconfig.app.json, which lists the smoke entries one by
  one, so npm run typecheck actually covers the 270 lines it was reported
  against
- write the JSON under logs/ like every sibling harness instead of dropping
  an untracked stream-pacing.json in the repo root, and create the directory
- treat an exported-but-empty SMOKE_BASE_URL as unset, matching the siblings;
  it drove "" as the base URL and burned the full readiness timeout
- register the harness in the two contract tests, which is what surfaced the
  SMOKE_BASE_URL bug, and pin the new guards there
- record the second mutation: reverting #7892 moves the longest stall 4-5x
  while leaving the long-task total inside the clean range, the exact
  opposite of reverting #8750, so both budgets are load-bearing

* Studio: budget only the long tasks the stream itself caused

buffered: true replays whatever the performance timeline already held, so
module evaluation and the first React render landed in the budgeted total:
one entry, ~140ms, about 2.6% of a clean run here, and larger on a cold or
loaded runner. Nothing filtered by startTime and run() reset nothing, so a
slow page load read as a slow renderer.

Open the measurement window in run() and drop entries that began before it.
Pre-stream share goes 2.6% to 0.00% while the stream's own tasks are
unchanged (60 and 52 entries over two clean runs), and reverting #8750 still
fails the budget at 52,465ms.

* Studio: tighten the stream pacing harness comments

Comments and docstrings only, no code change. Every measured number, PR
reference and causal reason is kept verbatim.

* Studio: check the reply that settled, not the peak it once reached

paintedChars is a high-water mark and only ever climbs, so a completion
render that truncated the bubble would leave the peak behind and the 90%
workload floor would still pass on a DOM that no longer held the reply.
Record what is on screen at settlement and check that too. Measured equal
to the peak today (24,033 both), so this is a guard rather than a live
discrepancy, and it is pinned in the harness contract test.

Also count slow frames only inside the measurement window and reset the
counter in run(), the same rule long tasks now follow. Contamination
measured at 0 of 286 here, but an external server or a slower box need not
be 0 and the number is meant to be comparable across them.

* Studio: record a stall that never ends, and keep the task that starts the stream

Two holes left by the measurement window added in the previous commits.

A long task carries the start time of its whole task, so appending the user
message in the same task that assigned measureFrom stamped runtime startup
and the first publish as earlier than the window and dropped them as page
load. Hand the append to a later task so the work that begins the stream
sorts inside it.

longestStallMs was only ever written when a later paint closed the stall, so
a freeze that ran to the end of the stream was never recorded: the tail can
go missing inside the 90% floor and the quiet-frame loop then calls it
settled. Measure the stall in progress while text is still arriving, which is
what the number means, and not afterwards, where the settle window's own
quiet frames would read as a freeze.

Clean runs unchanged (stall 933 to 1,050ms, long tasks 4,749 to 5,159ms over
three) and both mutations still caught: #7892 reverted fails the stall at
5,233ms, #8750 reverted fails long tasks at 52,263ms.

* Studio: tighten the comments added since the first pass

Comments only, no code change. Every measured number and every causal
reason is kept.

* Studio: record a freeze that spans the end of the stream

The stall in progress was measured only while text was still arriving. A
freeze that spans the moment the stream ends blocks the frame loop across
it, so the first frame afterwards already observes a non-null
streamEndedAtMs and the whole frozen interval was skipped. With the lost
tail able to hide inside the 90% workload floor, thirty quiet frames then
settled the reply and the run reported a short longest stall, which is the
one shape this number exists to catch.

Cap the interval at the absolute stream-end timestamp instead. A freeze
across that moment is recorded in full, and the stall stops growing once
there is no more text to wait for, so the settle check's own quiet frames
are still not counted as a freeze.

The rule moves into smoke-stream-pacing-stall.ts so it can be tested
without importing the harness entry, which mounts React on import. The new
tests cover the spanning freeze, the settle-window bound, idempotence and
late tail paint; restoring the previous rule fails two of the five.

Clean runs unchanged (stall 967 to 983ms, long tasks 5,442 to 5,842ms) and
both mutations still caught: #7892 reverted fails the stall at 5,017ms,
#8750 reverted fails long tasks at 63,687ms.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Make the stream pacing smoke page load again

Merging main brought in ASSISTANT_PART_COMPONENTS, which thread.tsx builds
at module scope with Text: MarkdownText. Entering the markdown-text ->
features/chat -> chat-page -> thread cycle from markdown-text runs that
object literal while the MarkdownText binding is still in its temporal dead
zone, so the page died with Cannot access MarkdownText before initialization
and rendered nothing. Import the chat barrel first, as the app's entry does.

The page is also a new HTML entry, so it has to load the crypto polyfill
before its module entry like every other one.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
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.

4 participants