From 3c76930964dcdd54fc056d07069eb7c342862be6 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 23:21:15 +0200 Subject: [PATCH] fix: preserve math through streaming HTML repair Disable only Remend's incomplete-HTML stripping in message/tool content and reasoning rendering. Keep TeX, source data, and sanitization unchanged; unfinished HTML can display literally. PR #266 identified the less-than comparison trigger. This uses an independently authored renderer configuration fix rather than its TeX-spacing workaround, with streaming, incremental, literal-code, and safety regressions. --- components/ai-elements/message.tsx | 7 ++ components/ai-elements/reasoning.tsx | 4 +- tests/render-streaming-math.test.tsx | 140 +++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 tests/render-streaming-math.test.tsx diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx index 9e22382..dbfa7a2 100644 --- a/components/ai-elements/message.tsx +++ b/components/ai-elements/message.tsx @@ -355,6 +355,12 @@ export const streamdownPlugins = { math: createMathPlugin({ singleDollarTextMath: true }), } as unknown as PluginConfig; +// Remend's incomplete-HTML heuristic mistakes ` { // Legacy backslash delimiters are converted in a single linear scan; @@ -375,6 +381,7 @@ export const MessageResponse = memo( )} components={mdComponents} plugins={streamdownPlugins} + remend={streamdownRemend} {...props} > {content} diff --git a/components/ai-elements/reasoning.tsx b/components/ai-elements/reasoning.tsx index 3fe0d1c..c938b80 100644 --- a/components/ai-elements/reasoning.tsx +++ b/components/ai-elements/reasoning.tsx @@ -24,7 +24,7 @@ import { import { Streamdown } from "streamdown"; import { transformMathDelimiters } from "@/src/core"; -import { streamdownPlugins } from "./message"; +import { streamdownPlugins, streamdownRemend } from "./message"; import { mdComponents } from "@/components/md-blocks"; import { Shimmer } from "./shimmer"; @@ -209,7 +209,7 @@ export const ReasoningContent = memo( )} {...props} > - + {transformMathDelimiters(children)} diff --git a/tests/render-streaming-math.test.tsx b/tests/render-streaming-math.test.tsx new file mode 100644 index 0000000..f1ba9eb --- /dev/null +++ b/tests/render-streaming-math.test.tsx @@ -0,0 +1,140 @@ +import React, { act } from "react"; +(globalThis as { React?: typeof React }).React = React; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { createRoot } from "react-dom/client"; +import { parseHTML } from "linkedom"; +import { MessageResponse, ToolContentResponse } from "../components/ai-elements/message.tsx"; +import { Reasoning, ReasoningContent } from "../components/ai-elements/reasoning.tsx"; +import { transformMathDelimiters } from "../src/core/math-delims.ts"; + +const surfaces = { + message: (source: string) => {source}, + tool: (source: string) => {source}, + reasoning: (source: string) => {source}, +}; +const documentFor = (element: React.ReactElement) => + parseHTML(`${renderToStaticMarkup(element)}`).document; +const annotations = (root: ParentNode) => [...root.querySelectorAll('annotation[encoding="application/x-tex"]')].map(node => node.textContent); + +// PR266 identified the less-than + letter trigger. Test the actual streaming +// render seam, not a TeX-space workaround: valid TeX must remain byte-identical. +for (const [name, surface] of Object.entries(surfaces)) describe(`streaming math — ${name}`, () => { + for (const [source, tex] of [ + ["$a { + const document = documentFor(surface(`${source}\n\nTAIL`)); + assert.deepEqual(annotations(document), [tex]); + assert.ok(document.body.textContent.includes("TAIL")); + assert.ok(!document.body.textContent.includes("[blocked]")); + assert.equal(document.querySelectorAll(".katex-error").length, 0); + }); + + it("retains probability brackets, both formulas, and the following question", () => { + const source = String.raw`$F_X(2)=0.60$이라고 하자. $P[-1 { + for (const source of ["$a { + for (const source of ["`$a { + const partial = documentFor(surface("before bold TAIL")); + assert.equal(complete.querySelector("b")?.textContent, "bold"); + assert.equal(complete.body.textContent, "before bold TAIL"); + }); + + it("keeps the other incomplete-Markdown repairs", () => { + const bold = documentFor(surface("**bold TAIL")); + assert.equal(bold.querySelector("strong")?.textContent, "bold TAIL"); + const link = documentFor(surface("[partial TAIL")); + assert.ok(link.body.textContent.includes("partial TAIL")); + assert.ok(link.body.textContent.includes("[blocked]"), "the incomplete-link placeholder is still hardened"); + }); + + it("still removes script/event attributes and blocks javascript links", () => { + const document = documentFor(surface('before bold [unsafe](javascript:alert) TAIL')); + assert.equal(document.querySelectorAll("script, [onclick]").length, 0); + assert.ok(![...document.querySelectorAll("[href]")].some(node => node.getAttribute("href")?.startsWith("javascript:"))); + assert.ok(!document.body.textContent.includes("alert(1)")); + assert.ok(document.body.textContent.includes("TAIL")); + assert.ok(document.body.textContent.includes("[blocked]")); + }); +}); + +it("preserves native TeX source in both streaming and static modes", () => { + const source = String.raw`$a{source}); + const staticDocument = documentFor({source}); + assert.deepEqual(annotations(streaming), ["a node.innerHTML), + [...staticDocument.querySelectorAll(".katex-html")].map(node => node.innerHTML)); +}); + +// Exercise successive updates on the SAME mounted components, rather than +// mistaking independent server renders for incremental streaming coverage. +it("retains less-than prefixes through mounted streaming updates on every entry point", async () => { + const dom = parseHTML(""); + // linkedom has no layout/animation engine; Radix's open reasoning panel + // only needs these inert browser hooks. This test checks text, not layout. + const globals = { window: dom.window, document: dom.document, Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true, + getComputedStyle: () => ({ display: "block", animationName: "none" }), + requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0), + cancelAnimationFrame: (handle: ReturnType) => clearTimeout(handle) }; + const previous = Object.getOwnPropertyDescriptors(globalThis); + Object.assign(globalThis, globals); + try { + for (const [name, surface] of Object.entries(surfaces)) { + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + try { + for (const source of ["$a", "$a<", "$a root.render(surface(source))); + if (source.includes(" root.unmount()); + host.remove(); + } + } + } finally { + for (const key of Object.keys(globals)) { + if (previous[key]) Object.defineProperty(globalThis, key, previous[key]); + else Reflect.deleteProperty(globalThis, key); + } + } +});