fix: add ErrorBoundary around Gen-UI cards to prevent black screen
Deploy LangGraph Server to Azure Container App / build-and-deploy (push) Failing after 14s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 52s

Wraps all LoadExternalComponent usages with CardErrorBoundary so
card rendering errors show an inline error message instead of
crashing the entire app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-12 23:34:08 +08:00
co-authored by Claude Opus 4.6
parent 4267d0a62a
commit d8b989f681
2 changed files with 73 additions and 21 deletions
+26 -1
View File
@@ -1,7 +1,30 @@
import { Loader2, CheckCircle2, ChevronRight, ChevronDown, XCircle, ChevronsUpDown, AlertCircle, CornerDownRight } from "lucide-react";
import { useState, useCallback } from "react";
import { useState, useCallback, Component, type ReactNode } from "react";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
// ErrorBoundary to prevent Gen-UI card crashes from taking down the whole app
class CardErrorBoundary extends Component<
{ children: ReactNode; fallbackLabel?: string },
{ hasError: boolean; error?: Error }
> {
state = { hasError: false, error: undefined as Error | undefined };
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
<div className="rounded-md border border-red-200 bg-red-50 dark:bg-red-950/20 dark:border-red-900 px-3 py-2">
<p className="text-xs text-red-600 dark:text-red-400">
卡片渲染失败:{this.state.error?.message ?? "未知错误"}
</p>
</div>
);
}
return this.props.children;
}
}
const TOOL_NAME_MAP: Record<string, string> = {
kb_search: "知识库检索",
ticket_list: "工单查询",
@@ -213,11 +236,13 @@ function ToolCallRow({
{expanded && !isFailed && uiItem && stream && components && (
<div className="mt-2 ml-7">
<div className="animate-in fade-in duration-300">
<CardErrorBoundary>
<LoadExternalComponent
stream={stream}
message={uiItem as any}
components={components as any}
/>
</CardErrorBoundary>
</div>
</div>
)}
+28 -1
View File
@@ -6,7 +6,7 @@ import type { Message } from "@langchain/langgraph-sdk";
// UIMessage is not exported directly — use a local shape
type UIMsgLocal = { id: string; type: string; name: string; props: Record<string, unknown>; metadata?: { message_id?: string } };
import { useState, useRef, useEffect, useCallback } from "react";
import { useState, useRef, useEffect, useCallback, Component, type ReactNode } from "react";
import ComponentMap from "./agent-uis/index.tsx";
import "./index.css";
import { BookOpen, Search, Terminal, Ticket, Zap, Cpu, Bot, Menu, Copy, Check, RefreshCw, Square, Pencil, ChevronDown, Sparkles, Loader2, Clock } from "lucide-react";
@@ -24,6 +24,29 @@ import { ExecutionLogPanel } from "@/components/ExecutionLogPanel.tsx";
const LANGGRAPH_URL =
import.meta.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024";
// ─── ErrorBoundary for Gen-UI cards ─────────────────────────────────────────
class CardErrorBoundary extends Component<
{ children: ReactNode },
{ hasError: boolean; error?: Error }
> {
state = { hasError: false, error: undefined as Error | undefined };
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
<div className="rounded-md border border-red-200 bg-red-50 dark:bg-red-950/20 dark:border-red-900 px-3 py-2 my-1">
<p className="text-xs text-red-600 dark:text-red-400">
卡片渲染失败:{this.state.error?.message ?? "未知错误"}
</p>
</div>
);
}
return this.props.children;
}
}
// ─── Card deduplication by card_id ──────────────────────────────────────────
// When the backend pushes loading then complete cards with the same card_id,
// keep only the last (most recent) card per card_id. Cards without card_id pass through.
@@ -825,6 +848,7 @@ function App() {
if (standaloneUiItems.length === 0) return null;
const cards = standaloneUiItems.map((ui: UIMsgLocal) => (
<div key={ui.id} className="card-enter">
<CardErrorBoundary>
<LoadExternalComponent
stream={thread}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -832,6 +856,7 @@ function App() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
components={ComponentMap as any}
/>
</CardErrorBoundary>
</div>
));
if (standaloneUiItems.length >= 2) {
@@ -895,6 +920,7 @@ function App() {
)
.map((ui: UIMsgLocal) => (
<div key={ui.id} className="card-enter">
<CardErrorBoundary>
<LoadExternalComponent
stream={thread}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -902,6 +928,7 @@ function App() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
components={ComponentMap as any}
/>
</CardErrorBoundary>
</div>
))}