From 64f087c3276445f6f11bd4edd6f43318f294fa41 Mon Sep 17 00:00:00 2001 From: Shankar Date: Fri, 20 Mar 2026 01:20:32 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20frontend=20error=20handling=20=E2=80=94?= =?UTF-8?q?=20ErrorBoundary,=20type-safe=20errors,=20stable=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - React ErrorBoundary wrapping entire app for graceful crash recovery - fetchJSON error handling uses try/catch instead of .catch() chain - CertificateDetailPage: instanceof checks replace unsafe type casts - DataTable: keyField prop replaces array index keys Co-Authored-By: Claude Opus 4.6 --- web/src/api/client.ts | 10 ++++- web/src/components/DataTable.tsx | 5 ++- web/src/components/ErrorBoundary.tsx | 50 +++++++++++++++++++++++++ web/src/main.tsx | 49 ++++++++++++------------ web/src/pages/CertificateDetailPage.tsx | 8 ++-- 5 files changed, 91 insertions(+), 31 deletions(-) create mode 100644 web/src/components/ErrorBoundary.tsx diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 0afe806..828dca6 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -33,8 +33,14 @@ async function fetchJSON(url: string, init?: RequestInit): Promise { throw new Error('Authentication required'); } if (!res.ok) { - const body = await res.json().catch(() => ({ message: res.statusText })); - throw new Error(body.message || body.error || `HTTP ${res.status}`); + let errorMsg = res.statusText; + try { + const body = await res.json(); + errorMsg = body.message || body.error || errorMsg; + } catch { + // Response body is not JSON, use status text + } + throw new Error(errorMsg || `HTTP ${res.status}`); } return res.json(); } diff --git a/web/src/components/DataTable.tsx b/web/src/components/DataTable.tsx index 5518bd7..b06c12d 100644 --- a/web/src/components/DataTable.tsx +++ b/web/src/components/DataTable.tsx @@ -11,9 +11,10 @@ interface DataTableProps { onRowClick?: (item: T) => void; emptyMessage?: string; isLoading?: boolean; + keyField?: string; } -export default function DataTable({ columns, data, onRowClick, emptyMessage, isLoading }: DataTableProps) { +export default function DataTable({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id' }: DataTableProps) { if (isLoading) { return (
@@ -49,7 +50,7 @@ export default function DataTable({ columns, data, onRowClick, emptyMessage, {data.map((item, i) => ( )[keyField] as string ?? `row-${i}`} onClick={() => onRowClick?.(item)} className={`border-b border-slate-700/50 transition-colors hover:bg-blue-500/5 ${onRowClick ? 'cursor-pointer' : ''}`} > diff --git a/web/src/components/ErrorBoundary.tsx b/web/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..797e940 --- /dev/null +++ b/web/src/components/ErrorBoundary.tsx @@ -0,0 +1,50 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export default class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Uncaught component error:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+
+

Something went wrong

+

+ {this.state.error?.message || 'An unexpected error occurred'} +

+ +
+
+ ); + } + return this.props.children; + } +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 5604ce3..f820d6d 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import ErrorBoundary from './components/ErrorBoundary'; import AuthProvider from './components/AuthProvider'; import AuthGate from './components/AuthGate'; import Layout from './components/Layout'; @@ -30,28 +31,30 @@ const queryClient = new QueryClient({ createRoot(document.getElementById('root')!).render( - - - - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - - + + + + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + + ); diff --git a/web/src/pages/CertificateDetailPage.tsx b/web/src/pages/CertificateDetailPage.tsx index 022944c..4d43061 100644 --- a/web/src/pages/CertificateDetailPage.tsx +++ b/web/src/pages/CertificateDetailPage.tsx @@ -130,7 +130,7 @@ export default function CertificateDetailPage() { )} {renewMutation.isError && (
- Failed to trigger renewal: {(renewMutation.error as Error).message} + Failed to trigger renewal: {renewMutation.error instanceof Error ? renewMutation.error.message : 'Unknown error'}
)} {deployMutation.isSuccess && ( @@ -140,12 +140,12 @@ export default function CertificateDetailPage() { )} {deployMutation.isError && (
- Failed to deploy: {(deployMutation.error as Error).message} + Failed to deploy: {deployMutation.error instanceof Error ? deployMutation.error.message : 'Unknown error'}
)} {archiveMutation.isError && (
- Failed to archive: {(archiveMutation.error as Error).message} + Failed to archive: {archiveMutation.error instanceof Error ? archiveMutation.error.message : 'Unknown error'}
)} @@ -226,7 +226,7 @@ export default function CertificateDetailPage() {

Deploy Certificate

{deployMutation.isError && (
- {(deployMutation.error as Error).message} + {deployMutation.error instanceof Error ? deployMutation.error.message : 'Unknown error'}
)}