From 0a902790cacb20d38c6f36fe331ac283a2c74b0f Mon Sep 17 00:00:00 2001 From: "Ahmed R." Date: Sat, 7 Mar 2026 04:12:54 -0600 Subject: [PATCH] Fix crash when rendering code blocks with unsupported languages (#266 & #210) (#279) The app crashed when displaying code blocks with language identifiers not bundled in Shiki (e.g. "env" from .env file edits). The language string was cast to SupportedLanguages without validation, causing resolveLanguage to throw an unhandled error that killed the thread. Three layers of defense added: - getHighlighterPromise falls back to "text" when Shiki rejects - codeToHtml call wrapped in try/catch with "text" fallback - CodeHighlightErrorBoundary prevents component tree crashes Closes #266 Co-authored-by: Ahmed R --- apps/web/src/components/ChatMarkdown.tsx | 61 +++++++++++++++++++----- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 2cc4c37b44a26faf9a5e0169530e148761568763..da44045c98aa1adb4de2baee025ec8fa4967363b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -4,7 +4,7 @@ import { type SupportedLanguages, } from "@pierre/diffs"; import { CheckIcon, CopyIcon } from "lucide-react"; -import { +import React, { Children, Suspense, isValidElement, @@ -28,6 +28,27 @@ import { resolveMarkdownFileLinkTarget } from "../markdown-links"; import { readNativeApi } from "../nativeApi"; import { preferredTerminalEditor } from "../terminal-links"; +class CodeHighlightErrorBoundary extends React.Component< + { fallback: ReactNode; children: ReactNode }, + { hasError: boolean } +> { + constructor(props: { fallback: ReactNode; children: ReactNode }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + override render() { + if (this.state.hasError) { + return this.props.fallback; + } + return this.props.children; + } +} + interface ChatMarkdownProps { text: string; cwd: string | undefined; @@ -99,6 +120,14 @@ function getHighlighterPromise(language: string): Promise { themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], langs: [language as SupportedLanguages], preferredHighlighter: "shiki-js", + }).catch((err) => { + highlighterPromiseCache.delete(language); + if (language === "text") { + // "text" itself failed — Shiki cannot initialize at all, surface the error + throw err; + } + // Language not supported by Shiki — fall back to "text" + return getHighlighterPromise("text"); }); highlighterPromiseCache.set(language, promise); return promise; @@ -179,10 +208,14 @@ function SuspenseShikiCodeBlock({ } const highlighter = use(getHighlighterPromise(language)); - const highlightedHtml = useMemo( - () => highlighter.codeToHtml(code, { lang: language, theme: themeName }), - [code, highlighter, language, themeName], - ); + const highlightedHtml = useMemo(() => { + try { + return highlighter.codeToHtml(code, { lang: language, theme: themeName }); + } catch { + // If highlighting fails for this language, render as plain text + return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); + } + }, [code, highlighter, language, themeName]); useEffect(() => { if (!isStreaming) { @@ -235,14 +268,16 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { return ( - {children}}> - - + {children}}> + {children}}> + + + ); }, -- 2.54.0