feat: implement react-error-boundary for component error isolation

- Add react-error-boundary dependency to webview-ui package
- Create custom ErrorBoundary component with VSCode-themed fallback UI
- Add internationalization support for error messages
- Wrap all major view components (ChatView, SettingsView, HistoryView, etc.) with error boundaries
- Implement error reporting utility for centralized error collection
- Add comprehensive test coverage for ErrorBoundary component
- Configure Vitest for React development mode to support testing

Fixes #5731
This commit is contained in:
Roo 2025-07-15 11:24:58 +00:00
parent 8a3dcfb593
commit 3121885657
8 changed files with 414 additions and 46 deletions

27
pnpm-lock.yaml generated
View file

@ -994,6 +994,9 @@ importers:
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
react-error-boundary:
specifier: ^6.0.0
version: 6.0.0(react@18.3.1)
react-i18next:
specifier: ^15.4.1
version: 15.5.1(i18next@25.2.1(typescript@5.8.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)
@ -8047,6 +8050,11 @@ packages:
peerDependencies:
react: ^18.3.1
react-error-boundary@6.0.0:
resolution: {integrity: sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==}
peerDependencies:
react: '>=16.13.1'
react-hook-form@7.57.0:
resolution: {integrity: sha512-RbEks3+cbvTP84l/VXGUZ+JMrKOS8ykQCRYdm5aYsxnDquL0vspsyNhGRO7pcH6hsZqWlPOjLye7rJqdtdAmlg==}
engines: {node: '>=18.0.0'}
@ -11120,14 +11128,14 @@ snapshots:
'@manypkg/find-root@1.1.0':
dependencies:
'@babel/runtime': 7.27.4
'@babel/runtime': 7.27.6
'@types/node': 12.20.55
find-up: 4.1.0
fs-extra: 8.1.0
'@manypkg/get-packages@1.1.3':
dependencies:
'@babel/runtime': 7.27.1
'@babel/runtime': 7.27.6
'@changesets/types': 4.1.0
'@manypkg/find-root': 1.1.0
fs-extra: 8.1.0
@ -13295,7 +13303,7 @@ snapshots:
sirv: 3.0.1
tinyglobby: 0.2.14
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.15.29)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
'@vitest/utils@3.2.4':
dependencies:
@ -14494,7 +14502,7 @@ snapshots:
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.27.4
'@babel/runtime': 7.27.6
csstype: 3.1.3
dom-serializer@2.0.0:
@ -15931,7 +15939,7 @@ snapshots:
is-it-type@5.1.2:
dependencies:
'@babel/runtime': 7.27.4
'@babel/runtime': 7.27.6
globalthis: 1.0.4
is-map@2.0.3: {}
@ -17931,6 +17939,11 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
react-error-boundary@6.0.0(react@18.3.1):
dependencies:
'@babel/runtime': 7.27.6
react: 18.3.1
react-hook-form@7.57.0(react@18.3.1):
dependencies:
react: 18.3.1
@ -18031,7 +18044,7 @@ snapshots:
react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.27.4
'@babel/runtime': 7.27.6
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
@ -18355,7 +18368,7 @@ snapshots:
rtl-css-js@1.16.1:
dependencies:
'@babel/runtime': 7.27.4
'@babel/runtime': 7.27.6
run-applescript@7.0.0: {}

View file

@ -54,6 +54,7 @@
"pretty-bytes": "^7.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^6.0.0",
"react-i18next": "^15.4.1",
"react-markdown": "^9.0.3",
"react-remark": "^2.1.0",

View file

@ -22,6 +22,7 @@ import { AccountView } from "./components/account/AccountView"
import { useAddNonInteractiveClickListener } from "./components/ui/hooks/useNonInteractiveClick"
import { TooltipProvider } from "./components/ui/tooltip"
import { STANDARD_TOOLTIP_DELAY } from "./components/ui/standard-tooltip"
import { ErrorBoundary } from "./components/common/ErrorBoundary"
type Tab = "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account"
@ -169,44 +170,68 @@ const App = () => {
// Do not conditionally load ChatView, it's expensive and there's state we
// don't want to lose (user input, disableInput, askResponse promise, etc.)
return showWelcome ? (
<WelcomeView />
<ErrorBoundary componentName="WelcomeView">
<WelcomeView />
</ErrorBoundary>
) : (
<>
{tab === "modes" && <ModesView onDone={() => switchTab("chat")} />}
{tab === "mcp" && <McpView onDone={() => switchTab("chat")} />}
{tab === "history" && <HistoryView onDone={() => switchTab("chat")} />}
{tab === "modes" && (
<ErrorBoundary componentName="ModesView">
<ModesView onDone={() => switchTab("chat")} />
</ErrorBoundary>
)}
{tab === "mcp" && (
<ErrorBoundary componentName="McpView">
<McpView onDone={() => switchTab("chat")} />
</ErrorBoundary>
)}
{tab === "history" && (
<ErrorBoundary componentName="HistoryView">
<HistoryView onDone={() => switchTab("chat")} />
</ErrorBoundary>
)}
{tab === "settings" && (
<SettingsView ref={settingsRef} onDone={() => setTab("chat")} targetSection={currentSection} />
<ErrorBoundary componentName="SettingsView">
<SettingsView ref={settingsRef} onDone={() => setTab("chat")} targetSection={currentSection} />
</ErrorBoundary>
)}
{tab === "marketplace" && (
<MarketplaceView
stateManager={marketplaceStateManager}
onDone={() => switchTab("chat")}
targetTab={currentMarketplaceTab as "mcp" | "mode" | undefined}
/>
<ErrorBoundary componentName="MarketplaceView">
<MarketplaceView
stateManager={marketplaceStateManager}
onDone={() => switchTab("chat")}
targetTab={currentMarketplaceTab as "mcp" | "mode" | undefined}
/>
</ErrorBoundary>
)}
{tab === "account" && (
<AccountView
userInfo={cloudUserInfo}
isAuthenticated={cloudIsAuthenticated}
cloudApiUrl={cloudApiUrl}
onDone={() => switchTab("chat")}
/>
<ErrorBoundary componentName="AccountView">
<AccountView
userInfo={cloudUserInfo}
isAuthenticated={cloudIsAuthenticated}
cloudApiUrl={cloudApiUrl}
onDone={() => switchTab("chat")}
/>
</ErrorBoundary>
)}
<ChatView
ref={chatViewRef}
isHidden={tab !== "chat"}
showAnnouncement={showAnnouncement}
hideAnnouncement={() => setShowAnnouncement(false)}
/>
<HumanRelayDialog
isOpen={humanRelayDialogState.isOpen}
requestId={humanRelayDialogState.requestId}
promptText={humanRelayDialogState.promptText}
onClose={() => setHumanRelayDialogState((prev) => ({ ...prev, isOpen: false }))}
onSubmit={(requestId, text) => vscode.postMessage({ type: "humanRelayResponse", requestId, text })}
onCancel={(requestId) => vscode.postMessage({ type: "humanRelayCancel", requestId })}
/>
<ErrorBoundary componentName="ChatView">
<ChatView
ref={chatViewRef}
isHidden={tab !== "chat"}
showAnnouncement={showAnnouncement}
hideAnnouncement={() => setShowAnnouncement(false)}
/>
</ErrorBoundary>
<ErrorBoundary componentName="HumanRelayDialog">
<HumanRelayDialog
isOpen={humanRelayDialogState.isOpen}
requestId={humanRelayDialogState.requestId}
promptText={humanRelayDialogState.promptText}
onClose={() => setHumanRelayDialogState((prev) => ({ ...prev, isOpen: false }))}
onSubmit={(requestId, text) => vscode.postMessage({ type: "humanRelayResponse", requestId, text })}
onCancel={(requestId) => vscode.postMessage({ type: "humanRelayCancel", requestId })}
/>
</ErrorBoundary>
</>
)
}
@ -214,15 +239,17 @@ const App = () => {
const queryClient = new QueryClient()
const AppWithProviders = () => (
<ExtensionStateContextProvider>
<TranslationProvider>
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={STANDARD_TOOLTIP_DELAY}>
<App />
</TooltipProvider>
</QueryClientProvider>
</TranslationProvider>
</ExtensionStateContextProvider>
<ErrorBoundary componentName="App">
<ExtensionStateContextProvider>
<TranslationProvider>
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={STANDARD_TOOLTIP_DELAY}>
<App />
</TooltipProvider>
</QueryClientProvider>
</TranslationProvider>
</ExtensionStateContextProvider>
</ErrorBoundary>
)
export default AppWithProviders

View file

@ -0,0 +1,82 @@
import React from "react"
import { ErrorBoundary as ReactErrorBoundary, FallbackProps } from "react-error-boundary"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useTranslation } from "react-i18next"
import { errorReporter } from "../../utils/errorReporting"
interface ErrorFallbackProps extends FallbackProps {
componentName?: string
}
function ErrorFallback({ error, resetErrorBoundary, componentName }: ErrorFallbackProps) {
const { t } = useTranslation("common")
return (
<div className="flex flex-col items-center justify-center p-6 bg-vscode-editor-background border border-vscode-widget-border rounded-md m-4">
<div className="flex items-center mb-4">
<span className="codicon codicon-error text-vscode-errorForeground text-2xl mr-3" />
<h2 className="text-lg font-semibold text-vscode-editor-foreground">
{t("errorBoundary.title", "Something went wrong")}
</h2>
</div>
{componentName && (
<p className="text-sm text-vscode-descriptionForeground mb-2">
{t("errorBoundary.componentError", "Error in {{componentName}} component", { componentName })}
</p>
)}
<p className="text-sm text-vscode-descriptionForeground mb-4 text-center max-w-md">
{t(
"errorBoundary.description",
"An error occurred in this part of the interface. You can try to recover by clicking the button below.",
)}
</p>
<details className="mb-4 w-full max-w-md">
<summary className="cursor-pointer text-sm text-vscode-descriptionForeground hover:text-vscode-editor-foreground">
{t("errorBoundary.showDetails", "Show error details")}
</summary>
<pre className="mt-2 p-3 bg-vscode-textCodeBlock-background border border-vscode-widget-border rounded text-xs text-vscode-editor-foreground overflow-auto max-h-32">
{error.message}
{error.stack && (
<>
{"\n\n"}
{error.stack}
</>
)}
</pre>
</details>
<VSCodeButton appearance="primary" onClick={resetErrorBoundary}>
{t("errorBoundary.retry", "Try again")}
</VSCodeButton>
</div>
)
}
interface ErrorBoundaryProps {
children: React.ReactNode
componentName?: string
onError?: (error: Error, errorInfo: React.ErrorInfo) => void
}
export function ErrorBoundary({ children, componentName, onError }: ErrorBoundaryProps) {
const handleError = (error: Error, errorInfo: React.ErrorInfo) => {
// Report error using our error reporting utility
errorReporter.reportError(error, errorInfo, componentName)
// Call custom error handler if provided (for potential Sentry integration)
onError?.(error, errorInfo)
}
return (
<ReactErrorBoundary
FallbackComponent={(props) => <ErrorFallback {...props} componentName={componentName} />}
onError={handleError}>
{children}
</ReactErrorBoundary>
)
}
export default ErrorBoundary

View file

@ -0,0 +1,142 @@
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"
import { vi, beforeEach, afterEach, describe, it, expect } from "vitest"
import { ErrorBoundary } from "../ErrorBoundary"
import { errorReporter } from "../../../utils/errorReporting"
// Mock the error reporter
vi.mock("../../../utils/errorReporting", () => ({
errorReporter: {
reportError: vi.fn(),
},
}))
// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string, options?: any) => {
// Handle interpolation for componentError
if (key === "errorBoundary.componentError" && options?.componentName) {
return `Error in ${options.componentName} component`
}
return defaultValue || key
},
}),
}))
// Component that throws an error
const ThrowError = ({ shouldThrow }: { shouldThrow: boolean }) => {
if (shouldThrow) {
throw new Error("Test error")
}
return <div>No error</div>
}
describe("ErrorBoundary", () => {
beforeEach(() => {
vi.clearAllMocks()
// Suppress console.error for these tests
vi.spyOn(console, "error").mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
it("renders children when there is no error", () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={false} />
</ErrorBoundary>,
)
expect(screen.getByText("No error")).toBeInTheDocument()
})
it("renders error fallback when there is an error", () => {
render(
<ErrorBoundary componentName="TestComponent">
<ThrowError shouldThrow={true} />
</ErrorBoundary>,
)
expect(screen.getByText("Something went wrong")).toBeInTheDocument()
expect(screen.getByText("Error in TestComponent component")).toBeInTheDocument()
expect(screen.getByText("Try again")).toBeInTheDocument()
})
it("shows error details when expanded", () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>,
)
const detailsButton = screen.getByText("Show error details")
fireEvent.click(detailsButton)
// Look for the error message in the details section (it's part of a larger text block)
expect(screen.getByText(/Test error/)).toBeInTheDocument()
})
it("calls error reporter when error occurs", () => {
const mockReportError = errorReporter.reportError as ReturnType<typeof vi.fn>
render(
<ErrorBoundary componentName="TestComponent">
<ThrowError shouldThrow={true} />
</ErrorBoundary>,
)
expect(mockReportError).toHaveBeenCalledWith(
expect.objectContaining({
message: "Test error",
}),
expect.any(Object),
"TestComponent",
)
})
it("calls custom onError handler when provided", () => {
const mockOnError = vi.fn()
render(
<ErrorBoundary onError={mockOnError}>
<ThrowError shouldThrow={true} />
</ErrorBoundary>,
)
expect(mockOnError).toHaveBeenCalledWith(
expect.objectContaining({
message: "Test error",
}),
expect.any(Object),
)
})
it("resets error boundary when retry button is clicked", () => {
const TestComponent = () => {
const [shouldThrow, setShouldThrow] = React.useState(true)
return (
<ErrorBoundary>
<button onClick={() => setShouldThrow(false)}>Fix error</button>
<ThrowError shouldThrow={shouldThrow} />
</ErrorBoundary>
)
}
render(<TestComponent />)
// Error should be shown initially
expect(screen.getByText("Something went wrong")).toBeInTheDocument()
// Click retry button
const retryButton = screen.getByText("Try again")
fireEvent.click(retryButton)
// Component should be reset and try to render again
// Since we haven't fixed the error, it should show the error again
expect(screen.getByText("Something went wrong")).toBeInTheDocument()
})
})

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "Image data URI copied to clipboard"
}
},
"errorBoundary": {
"title": "Something went wrong",
"componentError": "Error in {{componentName}} component",
"description": "An error occurred in this part of the interface. You can try to recover by clicking the button below.",
"showDetails": "Show error details",
"retry": "Try again"
}
}

View file

@ -0,0 +1,93 @@
/**
* Error reporting utility for the webview
* This can be extended to integrate with services like Sentry in the future
*/
export interface ErrorReport {
error: Error
errorInfo?: React.ErrorInfo
componentName?: string
timestamp: number
userAgent: string
url: string
}
class ErrorReporter {
private errors: ErrorReport[] = []
private maxErrors = 50 // Keep only the last 50 errors
/**
* Report an error that occurred in a React component
*/
reportError(error: Error, errorInfo?: React.ErrorInfo, componentName?: string): void {
const errorReport: ErrorReport = {
error: {
name: error.name,
message: error.message,
stack: error.stack,
} as Error,
errorInfo,
componentName,
timestamp: Date.now(),
userAgent: navigator.userAgent,
url: window.location.href,
}
// Add to local storage for debugging
this.errors.push(errorReport)
if (this.errors.length > this.maxErrors) {
this.errors.shift()
}
// Log to console for development
console.error(`Error in ${componentName || "component"}:`, error, errorInfo)
// TODO: In the future, this could send errors to Sentry or another service
// Example:
// if (window.Sentry) {
// window.Sentry.captureException(error, {
// tags: { component: componentName },
// extra: errorInfo
// })
// }
}
/**
* Get all stored error reports (useful for debugging)
*/
getErrors(): ErrorReport[] {
return [...this.errors]
}
/**
* Clear all stored errors
*/
clearErrors(): void {
this.errors = []
}
/**
* Get error statistics
*/
getErrorStats(): { total: number; byComponent: Record<string, number> } {
const byComponent: Record<string, number> = {}
this.errors.forEach((error) => {
const component = error.componentName || "unknown"
byComponent[component] = (byComponent[component] || 0) + 1
})
return {
total: this.errors.length,
byComponent,
}
}
}
// Export a singleton instance
export const errorReporter = new ErrorReporter()
// Make it available globally for debugging in development
if (typeof window !== "undefined" && process.env.NODE_ENV === "development") {
;(window as any).errorReporter = errorReporter
}

View file

@ -11,6 +11,9 @@ export default defineConfig({
environment: "jsdom",
include: ["src/**/*.spec.ts", "src/**/*.spec.tsx"],
},
define: {
"process.env.NODE_ENV": '"development"',
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),