vercel / vercel/streamdown

remend `htmlTags` handler has no math guard: `\sum_{j<k}` deletes the rest of the message

Open
#616 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
5.6k
Forks
302
Avg merge
3d 19h
Merged PRs (30d)
14

Description

Bug Description

remend's htmlTags handler deletes everything from an unclosed <tag to the end of the string. Two things make it fire far outside its intended scope:

  1. It has no math guard. The handler checks isWithinCodeBlock but never isWithinMathBlock — even though remend implements and exports the latter. A perfectly ordinary LaTeX expression like \sum_{j<k} therefore triggers it.
  2. It fires on complete text. The regex is /<[a-zA-Z/][^>]*$/, i.e. "a < with no > anywhere after it". That heuristic is meaningful for the tail of a streaming chunk, but remend(text) has no way to know whether the stream has ended, so it applies the same cut to text that is already final.

The two combine into a bad failure mode for chat UIs: an assistant message that mentions \sum_{j<k} loses its entire remainder. And because the katex handler (priority 70) runs after htmlTags (priority 10), the now-orphaned $$ gets auto-closed, so KaTeX receives truncated input and renders a katex-error in --color-muted-foreground. The user sees the answer stop mid-formula in grey text, with no error anywhere. It took a while to trace that back to markdown repair rather than to the model or to KaTeX.

This is not math-specific — any prose where < is followed by a letter and no > appears later is affected, e.g. the loop runs while i<n.

The relevant code in packages/remend:

const HTML_TAG_RE = /<[a-zA-Z/][^>]*$/

const handleHtmlTags = (text) => {
  const match = text.match(HTML_TAG_RE)
  return !match || match.index === undefined || isWithinCodeBlock(text, match.index)
    ? text
    : text.substring(0, match.index).trimEnd()
}

This looks like the same class of gap as #1 (handleIncompleteSingleUnderscoreItalic not math-aware) and #522 (emphasis handlers not recognising \(...\) / \[...\]). Those were fixed for the emphasis handlers; htmlTags was never covered.

Steps to Reproduce
import remend from 'remend'

remend('前文\n\n$$\nI = \\sum_{j<k} p_j\n$$\n\nTAIL')
// → '前文\n\n$$\nI = \\sum_{j\n$$'          TAIL is gone, math is truncated

remend('inline $$A_{j<k}$$ more\n\nTAIL')
// → 'inline $$A_{j$$'

remend('the loop runs while i<n\n\nTAIL')
// → 'the loop runs while i'

remend('the loop runs while i<n\n\nTAIL', { htmlTags: false })
// → unchanged (correct)

Same thing through the component — the second paragraph never reaches the DOM:

<Streamdown>{'前文\n\n$$\nI = \\sum_{j<k} p_j\n$$\n\nTAIL'}</Streamdown>
Expected Behavior

\sum_{j<k} inside $$...$$ is left alone and rendered by KaTeX, and the text following the math block still renders.

Concretely, I'd expect the handler to at least gain the isWithinMathBlock guard it already has available, alongside the existing isWithinCodeBlock one.

The broader half — stripping a mid-text < from text that is already complete — seems to need an explicit signal. remend(text) currently cannot distinguish "streaming tail" from "final text", so every tail-oriented heuristic keeps firing after the stream ends. An option (or a mode argument mirroring Streamdown's mode="static") would let callers turn the tail heuristics off for settled messages.

Actual Behavior

Everything from the < to the end of the string is deleted. The trailing $$ is then auto-closed by the katex handler, so KaTeX gets an unterminated expression and renders ParseError: Expected '}', got 'EOF' in the muted error colour. Net effect for the reader: the message silently ends mid-formula and the rest of the answer is lost.

Code Sample
import { Streamdown } from 'streamdown';

const markdown = `前文

$$
I = \\sum_{j<k} p_j
$$

TAIL`;

export default function App() {
  // Renders "前文" and a grey KaTeX parse error. "TAIL" is missing entirely.
  return <Streamdown>{markdown}</Streamdown>;
  // Workaround:
  // return <Streamdown remend={{ htmlTags: false }}>{markdown}</Streamdown>;
}
Streamdown Version

2.6.0 (remend 1.3.1)

React Version

19.2.8

Node.js Version

24.13.0

Browser(s)

Chrome

Operating System

macOS

Additional Context

Workarounds that do work, for anyone hitting this:

  • remend={{ htmlTags: false }} — narrow, keeps every other repair. This is what we shipped.
  • mode="static" — skips remend entirely (even with parseIncompleteMarkdown explicitly true), but also changes block splitting, dir inference and the animation path, so it's a bigger swap for a finished message.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in packages/remend with the htmlTags handler and its HTML_TAG_RE, then inspect the existing isWithinMathBlock and isWithinCodeBlock helpers. Reproduce the math-block and prose cases from the issue, and add regression coverage showing that math and trailing text are preserved. Confirm the intended behavior for settled text before changing the broader tail heuristic.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, typescript
Domain
frontend, web-dev
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.