remend `htmlTags` handler has no math guard: `\sum_{j<k}` deletes the rest of the message
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:
- It has no math guard. The handler checks
isWithinCodeBlockbut neverisWithinMathBlock— even thoughremendimplements 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, 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 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"— skipsremendentirely (even withparseIncompleteMarkdownexplicitlytrue), but also changes block splitting,dirinference and the animation path, so it's a bigger swap for a finished message.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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