From b82eca2bebd81706ab8d01fa96a9a43fe453c2ec Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 17 Jun 2026 00:46:17 -0500 Subject: [PATCH] fix(desktop): isolate message render crashes from the root boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../assistant-ui/directive-text.tsx | 19 +++++++++++++++++-- .../components/assistant-ui/markdown-text.tsx | 11 ++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index b870913b012..21059e897ba 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -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 ( diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index 2c87f6d0c33..d722e221215 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -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