fix(site): prevent rehype-raw from swallowing JSX in chat output (#23293)

Omit rehype-raw from the Streamdown rehype plugin list so
HTML-like syntax in LLM output is escaped as text instead of
being parsed by the HTML5 engine and stripped by rehype-sanitize.

When the LLM writes JSX fragments like <Component prop={val} />
outside code fences, remark-parse tags them as html nodes.
rehype-raw then feeds them to parse5, and rehype-sanitize strips
the unknown elements, silently destroying content. Without
rehype-raw, Streamdown auto-injects a remark plugin that converts
html nodes to text, preserving them as visible escaped text.

Markdown formatting (bold, italic, links, code blocks, tables)
is unaffected since those go through remark/rehype directly.
This commit is contained in:
Mathias Fredriksson
2026-03-19 17:34:45 +02:00
committed by GitHub
parent 00d292d764
commit ca57a0bcab
2 changed files with 54 additions and 1 deletions
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { Response } from "./response";
const sampleMarkdown = `
@@ -66,3 +67,39 @@ export const MarkdownAndLinksLight: Story = {
theme: "light",
},
};
// Verifies that JSX-like syntax in LLM output is preserved as
// escaped text rather than being swallowed by the HTML pipeline.
const jsxProseMarkdown = `
\`getLineAnnotations\` depends on \`activeCommentBox\` which could shift.
<RemoteDiffPanel
commentBox={commentBox}
scrollToFile={scrollTarget}
onScrollToFileComplete={handleScrollComplete}
/>
The props that might change on every \`RemoteDiffPanel\` re-render:
- \`isLoading\` only during refetch
- \`getLineAnnotations\` only when \`activeCommentBox\` changes
`;
export const JsxInProse: Story = {
args: {
children: jsxProseMarkdown,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// These strings live inside the <RemoteDiffPanel .../> JSX block.
// Without the rehype-raw fix they are silently eaten by the
// HTML sanitizer and never reach the DOM.
// The tag name itself is the token most likely to be consumed
// by HTML parsing, so assert it explicitly.
const tagName = await canvas.findByText(/<RemoteDiffPanel/);
expect(tagName).toBeInTheDocument();
const marker = await canvas.findByText(/scrollToFile=\{scrollTarget\}/);
expect(marker).toBeInTheDocument();
const marker2 = await canvas.findByText(/commentBox=\{commentBox\}/);
expect(marker2).toBeInTheDocument();
},
};
+17 -1
View File
@@ -5,7 +5,12 @@ import {
} from "@pierre/diffs/react";
import type { ComponentPropsWithRef, ReactNode } from "react";
import { useMemo } from "react";
import { type Components, Streamdown, type UrlTransform } from "streamdown";
import {
type Components,
defaultRehypePlugins,
Streamdown,
type UrlTransform,
} from "streamdown";
import { cn } from "utils/cn";
interface ResponseProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
@@ -13,6 +18,16 @@ interface ResponseProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
urlTransform?: UrlTransform;
}
// Omit rehype-raw so HTML-like syntax in LLM output is rendered as
// escaped text instead of being parsed by the HTML5 engine. Without
// this, JSX fragments such as <ComponentName prop={value} /> are
// consumed by rehype-raw and then stripped by rehype-sanitize,
// silently destroying content mid-stream.
const chatRehypePlugins = [
defaultRehypePlugins.sanitize,
defaultRehypePlugins.harden,
];
const fileViewerCSS =
"pre, [data-line], [data-diffs-header] { background-color: transparent !important; }";
@@ -241,6 +256,7 @@ export const Response = ({
controls={false}
components={components}
urlTransform={urlTransform}
rehypePlugins={chatRehypePlugins}
>
{children}
</Streamdown>