Skip to content

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

Description

@WHQ25

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions