fix(frontend): render user messages as plain text and cap blockquote nesting (#3502)

* fix(frontend): render user messages as plain text and cap blockquote nesting

User messages are typed or pasted plain text, not authored Markdown, but
they were rendered through the full Streamdown pipeline. Pasted source
files got fragmented (indented chunks become code blocks, paragraphs
collapse and lose indentation), "$...$" spans were KaTeX-ified, and a
message with thousands of nested ">" markers overflowed the call stack
in marked's recursive blockquote lexer, permanently crashing the thread
on every load.

Render human message content verbatim with pre-wrap instead, and cap
blockquote nesting at 100 levels at the Streamdown chokepoint so model
output cannot trigger the same recursion either.

Closes #3500

* fix(frontend): absorb marked lexer crashes with a render fallback boundary

Review found two gaps in the nesting cap: marked's list and blockquote
tokenizers are mutually recursive, so a list marker in front of the
quote chain ("- > > > ...") bypassed the blockquote-only regex and
still overflowed the stack; and the line-based rewrite was fence-blind,
silently truncating literal ">" runs inside code blocks.

Add an error boundary around Streamdown that renders the raw content as
plain pre-wrap text when rendering throws (retrying on the next content
change), keep the cap as a fast path for the dominant pure-">" case,
and make it skip fenced and indented code lines.
This commit is contained in:
Xinmin Zeng
2026-06-12 16:15:40 +08:00
committed by GitHub
parent aa015462a7
commit 503eeac788
6 changed files with 314 additions and 30 deletions
@@ -1,9 +1,10 @@
"use client";
import { type ComponentProps } from "react";
import { Component, useMemo, type ComponentProps, type ReactNode } from "react";
import { Streamdown } from "streamdown";
import { installClipboardFallback } from "@/core/clipboard";
import { capBlockquoteNesting } from "@/core/streamdown/preprocess";
export type ClipboardSafeStreamdownProps = ComponentProps<typeof Streamdown>;
@@ -12,6 +13,61 @@ if (typeof document !== "undefined") {
installClipboardFallback();
}
export function ClipboardSafeStreamdown(props: ClipboardSafeStreamdownProps) {
return <Streamdown {...props} />;
// marked (used by Streamdown to split content into blocks) has mutually
// recursive tokenizers — blockquote/list nesting a couple thousand levels
// deep overflows the call stack during render and would otherwise take down
// the whole route. When rendering a message throws, fall back to showing
// that message as plain pre-formatted text instead.
class StreamdownFallbackBoundary extends Component<
{ raw: ClipboardSafeStreamdownProps["children"]; children: ReactNode },
{ errored: boolean; prevRaw: ClipboardSafeStreamdownProps["children"] }
> {
state = { errored: false, prevRaw: this.props.raw };
static getDerivedStateFromError() {
return { errored: true };
}
static getDerivedStateFromProps(
props: { raw: ClipboardSafeStreamdownProps["children"] },
state: {
errored: boolean;
prevRaw: ClipboardSafeStreamdownProps["children"];
},
) {
// Retry rendering when the content changes (e.g. the next streaming chunk).
if (props.raw !== state.prevRaw) {
return { errored: false, prevRaw: props.raw };
}
return null;
}
render() {
if (this.state.errored) {
return (
<div className="break-words whitespace-pre-wrap">
{typeof this.props.raw === "string" ? this.props.raw : null}
</div>
);
}
return this.props.children;
}
}
export function ClipboardSafeStreamdown({
children,
...props
}: ClipboardSafeStreamdownProps) {
// Fast path for the dominant pathological input (pure ">" chains) so the
// error boundary below rarely has to absorb a full stack overflow.
const safeChildren = useMemo(
() =>
typeof children === "string" ? capBlockquoteNesting(children) : children,
[children],
);
return (
<StreamdownFallbackBoundary raw={children}>
<Streamdown {...props}>{safeChildren}</Streamdown>
</StreamdownFallbackBoundary>
);
}