Files
deer-flow/frontend/src/components/ai-elements/streamdown.tsx
T
Eilen Shin 25fbd25b05 fix(frontend): cap deeply nested list indentation to prevent render crash (#3393) (#3570)
* fix(frontend): cap deeply nested list indentation to prevent render crash

Deeply nested lists make marked's recursive list tokenizer overflow the
call stack during Streamdown's lexing useMemo, throwing an uncaught
"RangeError: Maximum call stack size exceeded" that replaces the chat
route with an error page (issue #3393); on larger stacks the same input
exhausts the heap, which the render error boundary cannot catch.

Mirror the existing capBlockquoteNesting guard with capListNesting, which
clamps leading whitespace to 200 columns (~100 nesting levels) only when
pathologically deep indentation is present, leaving normal content and
fenced code untouched. Wire both through capMarkdownNesting.

* fix(frontend): satisfy prettier format check in preprocess test

* fix(frontend): exempt indented code from list-indent cap (PR #3570 review)

* fix(frontend): keep capping all deep indentation outside fenced code

Revert the indented-code exemption from the PR #3570 review nit. Taken
literally the suggested guard (insideFence || INDENTED_CODE_RE.test(line))
no-ops capListNesting, because INDENTED_CODE_RE matches every line with
4+ leading spaces — i.e. exactly the deep-indent lines the cap targets.
A context-aware exemption (only treat 4+-space lines as code after a
blank line) instead reopens the crash: blank-separated deeply nested list
items get exempted and still blow up marked (verified: OOM at depth ~1.5k).

Unlike blockquotes (markers take <=3 leading spaces, so deep-quote lines
never look like indented code), list vs. indented-code indentation is
ambiguous line-by-line, so any exemption is exploitable. Keep capping all
deep indentation outside fenced code; the only cost is mild corruption of
a >200-column indented-code line, which never occurs in real content and
is strictly preferable to a render crash. Add a regression test locking
the blank-line case.
2026-06-14 22:19:54 +08:00

77 lines
2.5 KiB
TypeScript

"use client";
import { Component, useMemo, type ComponentProps, type ReactNode } from "react";
import { Streamdown } from "streamdown";
import { installClipboardFallback } from "@/core/clipboard";
import { capMarkdownNesting } from "@/core/streamdown/preprocess";
export type ClipboardSafeStreamdownProps = ComponentProps<typeof Streamdown>;
// Only patch browser globals in client context; skip during SSR
if (typeof document !== "undefined") {
installClipboardFallback();
}
// 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 inputs (deep ">" chains and deeply
// nested lists both blow up marked's recursive tokenizers) so the error
// boundary below rarely has to absorb a stack overflow — and never has to
// face the heap exhaustion the same lists cause on larger stacks, which it
// cannot catch.
const safeChildren = useMemo(
() =>
typeof children === "string" ? capMarkdownNesting(children) : children,
[children],
);
return (
<StreamdownFallbackBoundary raw={children}>
<Streamdown {...props}>{safeChildren}</Streamdown>
</StreamdownFallbackBoundary>
);
}