fix(desktop): isolate message render crashes from the root boundary

Streamdown runs our `preprocess` inside its own useMemo, and the user
bubble runs `extractEmbeddedImages`/directive parsing inside theirs — so
anything thrown while rendering one message (a regex/stack overflow on
adversarial content) escapes to the ROOT error boundary and takes down
the entire app, as seen in a reported `RangeError: Maximum call stack
size exceeded` from a single message.

Wrap both the assistant preprocess pipeline and the user-message
directive passes in try/catch that degrade to the raw text. One bad
message now renders plain instead of nuking the transcript.
This commit is contained in:
Brooklyn Nicholson 2026-06-17 00:46:17 -05:00
parent 547a014e7e
commit b82eca2beb
2 changed files with 27 additions and 3 deletions

View file

@ -327,8 +327,23 @@ function shortLabel(type: HermesRefType, id: string): string {
* inline chips. Embedded MEDIA images render below as a thumbnail row.
*/
export function DirectiveContent({ text }: { text: string }) {
const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText])
// Both passes run text through regexes; on pathological input they can throw
// (or overflow) and, since this renders inside a useMemo under the message,
// bubble up to the root error boundary. Degrade gracefully to plain text.
const { cleanedText, images } = useMemo(() => {
try {
return extractEmbeddedImages(text ?? '')
} catch {
return { cleanedText: text ?? '', images: [] }
}
}, [text])
const segments = useMemo(() => {
try {
return hermesDirectiveFormatter.parse(cleanedText)
} catch {
return [{ kind: 'text', text: cleanedText }] as Unstable_DirectiveSegment[]
}
}, [cleanedText])
return (
<span className="whitespace-pre-line" data-slot="aui_directive-text">

View file

@ -57,7 +57,16 @@ const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
// flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay
// module-scope so the prop identity is stable across renders.
function preprocessWithTailRepair(text: string): string {
return tailBoundedRemend(preprocessMarkdown(text))
// Streamdown runs `preprocess` inside its own useMemo, so anything thrown
// here escapes to the ROOT error boundary and takes down the whole app — a
// single adversarial message (e.g. content that overflows a regex/stack)
// shouldn't be able to do that. Degrade to the raw text instead; it still
// renders, just without our cosmetic normalization.
try {
return tailBoundedRemend(preprocessMarkdown(text))
} catch {
return text
}
}
// Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full