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:
- 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.
- 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.
Bug Description
remend'shtmlTagshandler deletes everything from an unclosed<tagto the end of the string. Two things make it fire far outside its intended scope:isWithinCodeBlockbut neverisWithinMathBlock— even thoughremendimplements and exports the latter. A perfectly ordinary LaTeX expression like\sum_{j<k}therefore triggers it./<[a-zA-Z/][^>]*$/, i.e. "a<with no>anywhere after it". That heuristic is meaningful for the tail of a streaming chunk, butremend(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 thekatexhandler (priority 70) runs afterhtmlTags(priority 10), the now-orphaned$$gets auto-closed, so KaTeX receives truncated input and renders akatex-errorin--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:This looks like the same class of gap as #1 (
handleIncompleteSingleUnderscoreItalicnot math-aware) and #522 (emphasis handlers not recognising\(...\)/\[...\]). Those were fixed for the emphasis handlers;htmlTagswas never covered.Steps to Reproduce
Same thing through the component — the second paragraph never reaches the DOM:
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
isWithinMathBlockguard it already has available, alongside the existingisWithinCodeBlockone.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 amodeargument mirroringStreamdown'smode="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 thekatexhandler, so KaTeX gets an unterminated expression and rendersParseError: 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
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"— skipsremendentirely (even withparseIncompleteMarkdownexplicitlytrue), but also changes block splitting,dirinference and the animation path, so it's a bigger swap for a finished message.