authorgravatar for realahmedroach@gmail.comAhmed R. <realahmedroach@gmail.com> 2026-03-07 04:12:54-06:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-07 02:12:54-08:00
log0a902790cacb20d38c6f36fe331ac283a2c74b0f
tree34cf6c134bb2d78857282765c395c3bf877daaaf
parent0837c8b639f3fb54c155d824c308c6b03038fa9c
signature Signed by PGP key B5690EEEBB952194

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 <Ahmed@Ahmeds-MacBook-Pro.local>

1 files changed, 48 insertions(+), 13 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+48-13
...@@ -4,7 +4,7 @@ import {...@@ -4,7 +4,7 @@ import {
4 type SupportedLanguages,4 type SupportedLanguages,
5} from "@pierre/diffs";5} from "@pierre/diffs";
6import { CheckIcon, CopyIcon } from "lucide-react";6import { CheckIcon, CopyIcon } from "lucide-react";
7import {7import React, {
8 Children,8 Children,
9 Suspense,9 Suspense,
10 isValidElement,10 isValidElement,
...@@ -28,6 +28,27 @@ import { resolveMarkdownFileLinkTarget } from "../markdown-links";...@@ -28,6 +28,27 @@ import { resolveMarkdownFileLinkTarget } from "../markdown-links";
28import { readNativeApi } from "../nativeApi";28import { readNativeApi } from "../nativeApi";
29import { preferredTerminalEditor } from "../terminal-links";29import { preferredTerminalEditor } from "../terminal-links";
3030
31class CodeHighlightErrorBoundary extends React.Component<
32 { fallback: ReactNode; children: ReactNode },
33 { hasError: boolean }
34> {
35 constructor(props: { fallback: ReactNode; children: ReactNode }) {
36 super(props);
37 this.state = { hasError: false };
38 }
39
40 static getDerivedStateFromError() {
41 return { hasError: true };
42 }
43
44 override render() {
45 if (this.state.hasError) {
46 return this.props.fallback;
47 }
48 return this.props.children;
49 }
50}
51
31interface ChatMarkdownProps {52interface ChatMarkdownProps {
32 text: string;53 text: string;
33 cwd: string | undefined;54 cwd: string | undefined;
...@@ -99,6 +120,14 @@ function getHighlighterPromise(language: string): Promise<DiffsHighlighter> {...@@ -99,6 +120,14 @@ function getHighlighterPromise(language: string): Promise<DiffsHighlighter> {
99 themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")],120 themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")],
100 langs: [language as SupportedLanguages],121 langs: [language as SupportedLanguages],
101 preferredHighlighter: "shiki-js",122 preferredHighlighter: "shiki-js",
123 }).catch((err) => {
124 highlighterPromiseCache.delete(language);
125 if (language === "text") {
126 // "text" itself failed — Shiki cannot initialize at all, surface the error
127 throw err;
128 }
129 // Language not supported by Shiki — fall back to "text"
130 return getHighlighterPromise("text");
102 });131 });
103 highlighterPromiseCache.set(language, promise);132 highlighterPromiseCache.set(language, promise);
104 return promise;133 return promise;
...@@ -179,10 +208,14 @@ function SuspenseShikiCodeBlock({...@@ -179,10 +208,14 @@ function SuspenseShikiCodeBlock({
179 }208 }
180209
181 const highlighter = use(getHighlighterPromise(language));210 const highlighter = use(getHighlighterPromise(language));
182 const highlightedHtml = useMemo(211 const highlightedHtml = useMemo(() => {
183 () => highlighter.codeToHtml(code, { lang: language, theme: themeName }),212 try {
184 [code, highlighter, language, themeName],213 return highlighter.codeToHtml(code, { lang: language, theme: themeName });
185 );214 } catch {
215 // If highlighting fails for this language, render as plain text
216 return highlighter.codeToHtml(code, { lang: "text", theme: themeName });
217 }
218 }, [code, highlighter, language, themeName]);
186219
187 useEffect(() => {220 useEffect(() => {
188 if (!isStreaming) {221 if (!isStreaming) {
...@@ -235,14 +268,16 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {...@@ -235,14 +268,16 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
235268
236 return (269 return (
237 <MarkdownCodeBlock code={codeBlock.code}>270 <MarkdownCodeBlock code={codeBlock.code}>
238 <Suspense fallback={<pre {...props}>{children}</pre>}>271 <CodeHighlightErrorBoundary fallback={<pre {...props}>{children}</pre>}>
239 <SuspenseShikiCodeBlock272 <Suspense fallback={<pre {...props}>{children}</pre>}>
240 className={codeBlock.className}273 <SuspenseShikiCodeBlock
241 code={codeBlock.code}274 className={codeBlock.className}
242 themeName={diffThemeName}275 code={codeBlock.code}
243 isStreaming={isStreaming}276 themeName={diffThemeName}
244 />277 isStreaming={isStreaming}
245 </Suspense>278 />
279 </Suspense>
280 </CodeHighlightErrorBoundary>
246 </MarkdownCodeBlock>281 </MarkdownCodeBlock>
247 );282 );
248 },283 },