Widget Error Boundaries: React Error Handling & Graceful Degradation
When a ChatGPT widget crashes, it shouldn't take down your entire application. Error boundaries are React's safety net—components that catch JavaScript errors anywhere in their component tree, log those errors, and display a fallback UI instead of crashing the entire widget. For ChatGPT apps that handle real-time user interactions within the conversational interface, implementing robust error boundaries is the difference between a minor hiccup and a broken user experience.
Unlike traditional try/catch blocks that handle imperative code, error boundaries are declarative React components designed specifically for the component tree. They catch errors during rendering, in lifecycle methods, and in constructors of the whole tree below them. However, they won't catch errors in event handlers, asynchronous code, server-side rendering, or errors thrown in the error boundary itself—which is why a comprehensive error handling strategy combines error boundaries with logging, monitoring, and graceful degradation patterns.
In this guide, we'll implement production-ready error boundaries for ChatGPT widgets, integrate advanced error logging with Sentry, design graceful degradation strategies, and create comprehensive test coverage for error scenarios. By the end, you'll have bulletproof widgets that recover gracefully from failures and maintain user trust even when things go wrong.
Understanding React Error Boundaries
Error boundaries are React class components that implement either componentDidCatch(error, errorInfo) or static getDerivedStateFromError(error) (or both). These lifecycle methods transform a regular component into an error boundary capable of catching and handling errors in child components.
Key Concepts:
- getDerivedStateFromError: Called during the render phase when an error is thrown. Returns new state to trigger fallback UI rendering. Cannot perform side effects.
- componentDidCatch: Called during the commit phase after an error is thrown. Ideal for logging errors, sending telemetry, and performing side effects.
- Error Isolation: Error boundaries only catch errors in components below them in the tree. They don't catch errors in themselves, event handlers, or asynchronous code.
For ChatGPT widgets that render in the window.openai API runtime, error boundaries prevent widget crashes from breaking the entire ChatGPT conversation experience.
Error Boundary Implementation
Here's a production-ready error boundary implementation specifically designed for ChatGPT widgets:
import React from 'react';
import * as Sentry from '@sentry/react';
class WidgetErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
retryCount: 0,
lastErrorTimestamp: null
};
}
static getDerivedStateFromError(error) {
// Update state so the next render shows fallback UI
return {
hasError: true,
lastErrorTimestamp: Date.now()
};
}
componentDidCatch(error, errorInfo) {
// Log error details to error reporting service
console.error('Widget Error Boundary caught:', error, errorInfo);
// Capture error context for debugging
Sentry.withScope((scope) => {
scope.setContext('widget', {
widgetId: this.props.widgetId,
userId: this.props.userId,
displayMode: this.props.displayMode, // inline, fullscreen, pip
retryCount: this.state.retryCount,
timestamp: new Date().toISOString()
});
scope.setContext('chatgpt', {
conversationId: this.props.conversationId,
toolCallId: this.props.toolCallId,
widgetState: this.props.widgetState
});
scope.setExtra('errorInfo', errorInfo);
scope.setExtra('componentStack', errorInfo.componentStack);
Sentry.captureException(error);
});
this.setState({
error,
errorInfo
});
// Notify parent component of error
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
}
handleRetry = () => {
const { retryCount } = this.state;
const maxRetries = this.props.maxRetries || 3;
if (retryCount >= maxRetries) {
console.error('Max retry attempts reached');
Sentry.captureMessage(`Widget retry limit exceeded: ${this.props.widgetId}`, 'warning');
return;
}
// Track retry attempt
Sentry.addBreadcrumb({
category: 'widget',
message: `User initiated retry (attempt ${retryCount + 1})`,
level: 'info'
});
this.setState((prevState) => ({
hasError: false,
error: null,
errorInfo: null,
retryCount: prevState.retryCount + 1
}));
};
handleReset = () => {
// Full reset including retry count
this.setState({
hasError: false,
error: null,
errorInfo: null,
retryCount: 0,
lastErrorTimestamp: null
});
};
render() {
const { hasError, error, errorInfo, retryCount } = this.state;
const { children, fallback, maxRetries = 3 } = this.props;
if (hasError) {
// Custom fallback component provided
if (fallback) {
return typeof fallback === 'function'
? fallback({ error, errorInfo, resetError: this.handleRetry, retryCount })
: fallback;
}
// Default fallback UI
return (
<div className="widget-error-fallback" role="alert" aria-live="assertive">
<div className="error-icon" aria-hidden="true">⚠️</div>
<h3>Something went wrong</h3>
<p>We're having trouble loading this widget.</p>
{retryCount < maxRetries ? (
<div className="error-actions">
<button
onClick={this.handleRetry}
className="retry-button"
aria-label={`Retry loading widget (attempt ${retryCount + 1} of ${maxRetries})`}
>
Try Again
</button>
<button
onClick={() => window.openai?.closePIP?.()}
className="close-button"
aria-label="Close widget"
>
Close
</button>
</div>
) : (
<div className="error-max-retries">
<p>Please refresh the conversation or contact support.</p>
<button onClick={this.handleReset} className="reset-button">
Reset Widget
</button>
</div>
)}
{process.env.NODE_ENV === 'development' && (
<details className="error-details">
<summary>Error Details (Dev Only)</summary>
<div className="error-stack">
<h4>Error Message:</h4>
<pre>{error?.toString()}</pre>
<h4>Component Stack:</h4>
<pre>{errorInfo?.componentStack}</pre>
</div>
</details>
)}
</div>
);
}
return children;
}
}
export default WidgetErrorBoundary;
Key Features:
- Sentry Integration: Automatic error reporting with widget-specific context
- Retry Mechanism: User-initiated retry with attempt tracking
- Configurable Fallback: Custom fallback UI via props
- Development Mode: Detailed error information in development
- Accessibility: ARIA labels and live regions for screen readers
- Parent Notification: Optional onError callback for parent components
For comprehensive widget architecture patterns, see our complete guide to ChatGPT widget development.
Fallback UI Patterns
Fallback UIs should be contextual, actionable, and match your widget's design system. Here's a comprehensive fallback UI library:
import React from 'react';
// Minimal fallback for non-critical sections
export function MinimalErrorFallback() {
return (
<div className="error-minimal">
<span className="error-icon">⚠️</span>
<span className="error-text">Unable to load this section</span>
</div>
);
}
// Inline card fallback
export function InlineCardErrorFallback({ resetError, componentName }) {
return (
<div className="inline-card-error">
<div className="error-header">
<span className="error-icon">⚠️</span>
<h4>Unable to Load {componentName}</h4>
</div>
<p className="error-message">
We encountered a problem loading this card.
</p>
<button onClick={resetError} className="retry-inline">
Retry
</button>
</div>
);
}
// Fullscreen widget fallback
export function FullscreenErrorFallback({
error,
resetError,
retryCount,
maxRetries = 3
}) {
return (
<div className="fullscreen-error">
<div className="error-content">
<div className="error-icon-large">⚠️</div>
<h2>Widget Temporarily Unavailable</h2>
<p>
We're experiencing technical difficulties. This usually resolves
itself automatically.
</p>
{retryCount < maxRetries ? (
<div className="error-actions-fullscreen">
<button onClick={resetError} className="btn-primary">
Try Again
</button>
<button
onClick={() => window.openai?.closePIP?.()}
className="btn-secondary"
>
Close Widget
</button>
</div>
) : (
<div className="error-support">
<p>Still having trouble? Contact our support team.</p>
<a
href="https://makeaihq.com/support"
className="btn-primary"
target="_blank"
rel="noopener noreferrer"
>
Get Support
</a>
</div>
)}
<div className="error-meta">
<small>Error ID: {error?.message?.slice(0, 8)}</small>
</div>
</div>
</div>
);
}
// PiP widget fallback
export function PiPErrorFallback({ resetError }) {
return (
<div className="pip-error">
<div className="pip-error-content">
<span className="error-icon">⚠️</span>
<p>Widget Error</p>
<div className="pip-actions">
<button onClick={resetError} className="pip-retry">
Retry
</button>
<button onClick={() => window.openai?.closePIP?.()} className="pip-close">
✕
</button>
</div>
</div>
</div>
);
}
// Network error specific fallback
export function NetworkErrorFallback({ resetError, retryCount }) {
return (
<div className="network-error">
<div className="error-icon">🌐</div>
<h3>Connection Problem</h3>
<p>
We're having trouble connecting to our servers.
Please check your internet connection.
</p>
<button onClick={resetError} className="retry-button">
{retryCount > 0 ? 'Try Again' : 'Reconnect'}
</button>
<small className="retry-info">
Retry attempt {retryCount + 1}
</small>
</div>
);
}
// Permission error fallback
export function PermissionErrorFallback() {
return (
<div className="permission-error">
<div className="error-icon">🔒</div>
<h3>Access Denied</h3>
<p>
You don't have permission to access this widget.
Please contact your administrator.
</p>
<a href="https://makeaihq.com/support" className="support-link">
Contact Support
</a>
</div>
);
}
// Timeout error fallback
export function TimeoutErrorFallback({ resetError }) {
return (
<div className="timeout-error">
<div className="error-icon">⏱️</div>
<h3>Request Timed Out</h3>
<p>
This is taking longer than expected. The server might be busy.
</p>
<button onClick={resetError} className="retry-button">
Try Again
</button>
</div>
);
}
// Export fallback selector utility
export function selectFallback(error) {
if (error?.message?.includes('network')) {
return NetworkErrorFallback;
}
if (error?.message?.includes('permission') || error?.message?.includes('forbidden')) {
return PermissionErrorFallback;
}
if (error?.message?.includes('timeout')) {
return TimeoutErrorFallback;
}
return FullscreenErrorFallback;
}
Usage Example:
import { selectFallback } from './FallbackLibrary';
<WidgetErrorBoundary
widgetId="booking-widget"
fallback={({ error, resetError, retryCount }) => {
const FallbackComponent = selectFallback(error);
return <FallbackComponent error={error} resetError={resetError} retryCount={retryCount} />;
}}
>
<BookingWidget />
</WidgetErrorBoundary>
For state persistence patterns that complement error recovery, see widget state persistence patterns.
Error Recovery
Error recovery goes beyond displaying fallback UIs—it involves automatic retry logic, state preservation, and intelligent error handling.
Automatic Retry with Exponential Backoff
class RetryableErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
retryCount: 0,
isRetrying: false,
autoRetryEnabled: props.autoRetry !== false
};
this.retryTimeout = null;
}
componentDidCatch(error, errorInfo) {
this.setState({ hasError: true });
Sentry.captureException(error, {
contexts: {
widget: {
retryCount: this.state.retryCount,
autoRetryEnabled: this.state.autoRetryEnabled
}
}
});
// Automatic retry for transient errors
if (this.shouldAutoRetry(error)) {
this.scheduleRetry();
}
}
shouldAutoRetry(error) {
const { retryCount, autoRetryEnabled } = this.state;
const { maxRetries = 3 } = this.props;
// Don't retry if disabled or max retries reached
if (!autoRetryEnabled || retryCount >= maxRetries) {
return false;
}
// Only retry for transient errors
const transientErrors = [
'network error',
'timeout',
'connection refused',
'ECONNRESET',
'502',
'503',
'504'
];
return transientErrors.some(pattern =>
error?.message?.toLowerCase().includes(pattern)
);
}
scheduleRetry() {
const { retryCount } = this.state;
// Exponential backoff: 1s, 2s, 4s, 8s
const delay = Math.pow(2, retryCount) * 1000;
const maxDelay = 10000; // Cap at 10 seconds
const actualDelay = Math.min(delay, maxDelay);
console.log(`Scheduling automatic retry in ${actualDelay}ms (attempt ${retryCount + 1})`);
this.setState({ isRetrying: true });
this.retryTimeout = setTimeout(() => {
this.performRetry();
}, actualDelay);
}
performRetry() {
Sentry.addBreadcrumb({
category: 'widget',
message: `Automatic retry attempt ${this.state.retryCount + 1}`,
level: 'info'
});
this.setState((prevState) => ({
hasError: false,
retryCount: prevState.retryCount + 1,
isRetrying: false
}));
}
handleManualRetry = () => {
if (this.retryTimeout) {
clearTimeout(this.retryTimeout);
}
this.performRetry();
};
componentWillUnmount() {
if (this.retryTimeout) {
clearTimeout(this.retryTimeout);
}
}
render() {
const { hasError, isRetrying, retryCount } = this.state;
const { children, maxRetries = 3 } = this.props;
if (hasError) {
if (isRetrying) {
return (
<div className="widget-retrying">
<div className="spinner" aria-label="Retrying...">⟳</div>
<p>Retrying... (attempt {retryCount + 1})</p>
</div>
);
}
return (
<div className="widget-error">
<h3>Error occurred</h3>
<p>Retry attempt {retryCount} of {maxRetries}</p>
<button onClick={this.handleManualRetry}>
Retry Now
</button>
</div>
);
}
return children;
}
}
State Reset Service
Create a service to manage error recovery state:
// errorRecoveryService.js
class ErrorRecoveryService {
constructor() {
this.errorLog = [];
this.recoveryStrategies = new Map();
}
// Register recovery strategy for error type
registerStrategy(errorType, strategy) {
this.recoveryStrategies.set(errorType, strategy);
}
// Log error for analysis
logError(error, context) {
this.errorLog.push({
error,
context,
timestamp: Date.now(),
stackTrace: error.stack
});
// Keep only last 100 errors
if (this.errorLog.length > 100) {
this.errorLog.shift();
}
}
// Attempt recovery
async recover(error, context) {
const errorType = this.classifyError(error);
const strategy = this.recoveryStrategies.get(errorType);
if (strategy) {
try {
await strategy.recover(error, context);
return { success: true, strategy: errorType };
} catch (recoveryError) {
console.error('Recovery failed:', recoveryError);
return { success: false, error: recoveryError };
}
}
return { success: false, reason: 'No recovery strategy found' };
}
// Classify error type
classifyError(error) {
if (error.message.includes('network')) return 'NETWORK_ERROR';
if (error.message.includes('timeout')) return 'TIMEOUT_ERROR';
if (error.message.includes('permission')) return 'PERMISSION_ERROR';
if (error.message.includes('quota')) return 'QUOTA_ERROR';
return 'UNKNOWN_ERROR';
}
// Get error statistics
getStats() {
const errorCounts = this.errorLog.reduce((acc, { error }) => {
const type = this.classifyError(error);
acc[type] = (acc[type] || 0) + 1;
return acc;
}, {});
return {
total: this.errorLog.length,
byType: errorCounts,
recentErrors: this.errorLog.slice(-10)
};
}
// Clear error log
clearLog() {
this.errorLog = [];
}
}
export const errorRecovery = new ErrorRecoveryService();
// Register default strategies
errorRecovery.registerStrategy('NETWORK_ERROR', {
async recover(error, context) {
// Wait for network reconnection
await new Promise(resolve => setTimeout(resolve, 2000));
// Retry the failed operation
if (context.retry) {
return context.retry();
}
}
});
errorRecovery.registerStrategy('TIMEOUT_ERROR', {
async recover(error, context) {
// Increase timeout and retry
if (context.retry) {
context.timeout = (context.timeout || 5000) * 2;
return context.retry();
}
}
});
For server-side error handling patterns, explore our guide on MCP server error recovery patterns.
Error Logging
Comprehensive error logging is essential for debugging production issues. Integrate Sentry for real-time error tracking:
Sentry Integration
// sentryConfig.js
import * as Sentry from '@sentry/react';
import { BrowserTracing } from '@sentry/tracing';
export function initializeSentry() {
Sentry.init({
dsn: process.env.SENTRY_DSN,
// Integrations
integrations: [
new BrowserTracing({
tracePropagationTargets: ['localhost', 'makeaihq.com', /^\//],
}),
new Sentry.Replay({
maskAllText: false,
blockAllMedia: false,
maskAllInputs: true // Mask sensitive input fields
})
],
// Performance monitoring
tracesSampleRate: 0.1, // 10% of transactions
// Session replay
replaysSessionSampleRate: 0.1, // 10% of sessions
replaysOnErrorSampleRate: 1.0, // 100% of sessions with errors
// Environment and release tracking
environment: process.env.NODE_ENV,
release: `chatgpt-widget@${process.env.APP_VERSION}`,
// Ignore certain errors
ignoreErrors: [
'ResizeObserver loop limit exceeded',
'Non-Error promise rejection captured',
/^Network request failed$/
],
// Custom filtering
beforeSend(event, hint) {
// Don't send errors from development
if (process.env.NODE_ENV === 'development') {
console.log('Sentry event (dev only):', event);
return null;
}
// Scrub sensitive data
if (event.request) {
delete event.request.cookies;
if (event.request.headers) {
delete event.request.headers.Authorization;
}
}
// Add custom context
event.tags = {
...event.tags,
widget_version: process.env.APP_VERSION,
chatgpt_integration: true
};
return event;
},
// Custom breadcrumb filtering
beforeBreadcrumb(breadcrumb, hint) {
// Filter out noisy breadcrumbs
if (breadcrumb.category === 'console' && breadcrumb.level === 'log') {
return null;
}
return breadcrumb;
}
});
}
// Custom error reporting utility
export function reportError(error, context = {}) {
Sentry.withScope((scope) => {
// Add widget context
scope.setContext('widget', {
id: context.widgetId,
displayMode: context.displayMode,
state: context.widgetState,
toolCalls: context.toolCallCount
});
// Add user context
if (context.userId) {
scope.setUser({
id: context.userId,
conversationId: context.conversationId
});
}
// Add custom tags
Object.entries(context.tags || {}).forEach(([key, value]) => {
scope.setTag(key, value);
});
// Add breadcrumbs
if (context.breadcrumbs) {
context.breadcrumbs.forEach(breadcrumb => {
scope.addBreadcrumb(breadcrumb);
});
}
Sentry.captureException(error);
});
}
Error Logging Middleware
Create middleware to intercept and log all errors:
// errorLoggingMiddleware.js
import { reportError } from './sentryConfig';
export class ErrorLoggingMiddleware {
constructor(config = {}) {
this.config = {
logToConsole: config.logToConsole !== false,
sendToSentry: config.sendToSentry !== false,
includeStackTrace: config.includeStackTrace !== false,
...config
};
}
logError(error, context) {
const errorData = {
message: error.message,
name: error.name,
timestamp: new Date().toISOString(),
...context
};
if (this.config.includeStackTrace) {
errorData.stack = error.stack;
}
// Console logging
if (this.config.logToConsole) {
console.group(`🔴 Error: ${error.message}`);
console.error('Error object:', error);
console.log('Context:', context);
if (error.stack) {
console.log('Stack trace:', error.stack);
}
console.groupEnd();
}
// Sentry reporting
if (this.config.sendToSentry) {
reportError(error, context);
}
// Custom logging endpoint (optional)
if (this.config.logEndpoint) {
this.sendToEndpoint(errorData);
}
return errorData;
}
async sendToEndpoint(errorData) {
try {
await fetch(this.config.logEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(errorData)
});
} catch (err) {
console.error('Failed to send error to logging endpoint:', err);
}
}
// Create error boundary wrapper with logging
withLogging(ErrorBoundaryComponent) {
const middleware = this;
return class LoggingErrorBoundary extends ErrorBoundaryComponent {
componentDidCatch(error, errorInfo) {
middleware.logError(error, {
componentStack: errorInfo.componentStack,
widgetId: this.props.widgetId,
userId: this.props.userId
});
// Call parent componentDidCatch
super.componentDidCatch(error, errorInfo);
}
};
}
}
export const errorLogger = new ErrorLoggingMiddleware({
logToConsole: process.env.NODE_ENV === 'development',
sendToSentry: process.env.NODE_ENV === 'production',
includeStackTrace: true
});
For analytics and monitoring beyond error tracking, see our ChatGPT app analytics guide.
Testing Error Boundaries
Comprehensive testing ensures error boundaries work correctly in all scenarios.
Error Simulator Component
// ErrorSimulator.jsx
import React, { useState } from 'react';
export function ErrorSimulator({ errorType = 'render', delay = 0 }) {
const [shouldThrow, setShouldThrow] = useState(false);
// Render error
if (shouldThrow && errorType === 'render') {
throw new Error('Simulated render error');
}
// Lifecycle error
React.useEffect(() => {
if (shouldThrow && errorType === 'effect') {
throw new Error('Simulated effect error');
}
}, [shouldThrow, errorType]);
// Async error (won't be caught by error boundary)
React.useEffect(() => {
if (shouldThrow && errorType === 'async') {
setTimeout(() => {
throw new Error('Simulated async error');
}, delay);
}
}, [shouldThrow, errorType, delay]);
// Event handler error (won't be caught by error boundary)
const handleClick = () => {
if (errorType === 'event') {
throw new Error('Simulated event handler error');
}
};
return (
<div className="error-simulator">
<h4>Error Simulator</h4>
<p>Error Type: {errorType}</p>
<button onClick={() => setShouldThrow(true)}>
Trigger {errorType} Error
</button>
{errorType === 'event' && (
<button onClick={handleClick}>
Trigger Event Error
</button>
)}
</div>
);
}
// Development tool for testing error boundaries
export function ErrorBoundaryTester() {
const [selectedError, setSelectedError] = useState('render');
return (
<div className="error-boundary-tester">
<h3>Error Boundary Test Suite</h3>
<select
value={selectedError}
onChange={(e) => setSelectedError(e.target.value)}
>
<option value="render">Render Error</option>
<option value="effect">Effect Error</option>
<option value="async">Async Error</option>
<option value="event">Event Handler Error</option>
</select>
<WidgetErrorBoundary widgetId="test-widget">
<ErrorSimulator errorType={selectedError} />
</WidgetErrorBoundary>
</div>
);
}
Error Boundary Unit Tests
// WidgetErrorBoundary.test.jsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import WidgetErrorBoundary from './WidgetErrorBoundary';
// Suppress error console output in tests
beforeAll(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterAll(() => {
console.error.mockRestore();
});
describe('WidgetErrorBoundary', () => {
it('renders children when no error occurs', () => {
render(
<WidgetErrorBoundary widgetId="test">
<div>Child component</div>
</WidgetErrorBoundary>
);
expect(screen.getByText('Child component')).toBeInTheDocument();
});
it('catches and displays errors from child components', () => {
const ThrowError = () => {
throw new Error('Test error');
};
render(
<WidgetErrorBoundary widgetId="test">
<ThrowError />
</WidgetErrorBoundary>
);
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
});
it('displays retry button when error occurs', () => {
const ThrowError = () => {
throw new Error('Test error');
};
render(
<WidgetErrorBoundary widgetId="test" maxRetries={3}>
<ThrowError />
</WidgetErrorBoundary>
);
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
});
it('allows retry after error', async () => {
let shouldThrow = true;
const MaybeThrow = () => {
if (shouldThrow) throw new Error('Test error');
return <div>Success</div>;
};
const { rerender } = render(
<WidgetErrorBoundary widgetId="test">
<MaybeThrow />
</WidgetErrorBoundary>
);
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
// Fix the error
shouldThrow = false;
// Click retry
fireEvent.click(screen.getByRole('button', { name: /try again/i }));
// Should render successfully
await waitFor(() => {
expect(screen.getByText('Success')).toBeInTheDocument();
});
});
it('tracks retry count correctly', () => {
const ThrowError = () => {
throw new Error('Test error');
};
render(
<WidgetErrorBoundary widgetId="test" maxRetries={3}>
<ThrowError />
</WidgetErrorBoundary>
);
const retryButton = screen.getByRole('button', { name: /try again/i });
fireEvent.click(retryButton);
fireEvent.click(retryButton);
fireEvent.click(retryButton);
// After max retries, button should be disabled or message changes
expect(screen.getByText(/refresh the conversation/i)).toBeInTheDocument();
});
it('calls onError callback when error occurs', () => {
const onError = jest.fn();
const ThrowError = () => {
throw new Error('Test error');
};
render(
<WidgetErrorBoundary widgetId="test" onError={onError}>
<ThrowError />
</WidgetErrorBoundary>
);
expect(onError).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ componentStack: expect.any(String) })
);
});
it('renders custom fallback component', () => {
const ThrowError = () => {
throw new Error('Test error');
};
const CustomFallback = ({ error }) => (
<div>Custom error: {error.message}</div>
);
render(
<WidgetErrorBoundary widgetId="test" fallback={CustomFallback}>
<ThrowError />
</WidgetErrorBoundary>
);
expect(screen.getByText(/custom error: test error/i)).toBeInTheDocument();
});
it('shows error details in development mode', () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
const ThrowError = () => {
throw new Error('Test error with stack');
};
render(
<WidgetErrorBoundary widgetId="test">
<ThrowError />
</WidgetErrorBoundary>
);
const details = screen.getByText(/error details/i);
expect(details).toBeInTheDocument();
process.env.NODE_ENV = originalEnv;
});
});
For comprehensive testing strategies beyond error scenarios, see our ChatGPT app testing and QA guide.
Production Deployment Checklist
Before deploying widgets with error boundaries to production:
Error Boundary Coverage
- Wrap all widget entry points with top-level error boundaries
- Nest error boundaries for critical sections (header, content, actions)
- Implement display-mode-specific fallbacks (inline, fullscreen, PiP)
- Add error boundaries around async operations (data fetching, API calls)
Logging and Monitoring
- Configure Sentry with correct DSN and environment
- Set up user context (user ID, conversation ID, widget ID)
- Enable session replay for errors (100% sample rate on errors)
- Configure error filtering to ignore noisy errors
- Add custom breadcrumbs for widget-specific events
Recovery Mechanisms
- Implement retry logic with exponential backoff
- Set max retry limits (recommend 3 attempts)
- Add manual retry buttons in fallback UIs
- Preserve widget state across retries where possible
Testing Coverage
- Unit test error boundaries with render, effect, and async errors
- Integration test recovery flows with real API calls
- Test max retry scenarios to ensure proper messaging
- Validate custom fallback UIs render correctly
- Test Sentry integration in staging environment
User Experience
- Design fallback UIs that match widget aesthetics
- Add ARIA labels for accessibility (role="alert", aria-live)
- Provide actionable error messages (specific, not generic)
- Include support contact for persistent errors
- Test on mobile devices to ensure fallback UIs are responsive
Performance
- Minimize fallback UI bundle size (inline critical CSS)
- Lazy load Sentry SDK to reduce initial bundle
- Set appropriate sample rates (10% traces, 100% errors)
- Avoid expensive operations in componentDidCatch
For loading state patterns that complement error handling, see widget loading states and skeleton screens.
Advanced Error Handling Patterns
Nested Error Boundaries with Isolation
function ChatGPTWidget() {
return (
<WidgetErrorBoundary
widgetId="main-widget"
fallback={FullscreenErrorFallback}
>
{/* Header section - critical */}
<WidgetErrorBoundary
widgetId="header-section"
fallback={MinimalErrorFallback}
>
<WidgetHeader />
</WidgetErrorBoundary>
{/* Content section - critical */}
<WidgetErrorBoundary
widgetId="content-section"
fallback={InlineCardErrorFallback}
>
<WidgetContent />
</WidgetErrorBoundary>
{/* Analytics section - optional (silent failure) */}
<WidgetErrorBoundary
widgetId="analytics-section"
fallback={null}
>
<AnalyticsChart />
</WidgetErrorBoundary>
{/* Actions section - critical */}
<WidgetErrorBoundary
widgetId="actions-section"
fallback={InlineCardErrorFallback}
>
<WidgetActions />
</WidgetErrorBoundary>
</WidgetErrorBoundary>
);
}
Error Boundary Hook (Functional Component Alternative)
While error boundaries must be class components, you can create a hook for functional components:
import { useState, useEffect } from 'react';
export function useErrorHandler(onError) {
const [error, setError] = useState(null);
useEffect(() => {
if (error) {
onError(error);
}
}, [error, onError]);
const throwError = (err) => {
setError(err);
throw err; // Let error boundary catch it
};
return { error, throwError, setError };
}
// Usage in functional component
function MyWidget() {
const { throwError } = useErrorHandler((error) => {
console.error('Widget error:', error);
});
const handleClick = async () => {
try {
await riskyOperation();
} catch (err) {
throwError(err);
}
};
return <button onClick={handleClick}>Do Something</button>;
}
For performance profiling that helps identify error-prone components, see widget performance profiling with Chrome DevTools.
Conclusion
Error boundaries transform catastrophic widget crashes into recoverable moments. By implementing React error boundaries with robust logging, graceful degradation, and comprehensive testing, you create ChatGPT widgets that maintain user trust even when things go wrong.
Key Takeaways:
- Use nested error boundaries for granular error isolation
- Integrate Sentry for production error tracking and session replay
- Design contextual fallback UIs with retry mechanisms
- Implement automatic retry with exponential backoff for transient errors
- Test error scenarios comprehensively in development
- Monitor error rates and set up alerts for production issues
Error handling isn't just about preventing crashes—it's about building resilient ChatGPT widgets that gracefully recover and maintain functionality under all conditions.
Related Resources
- ChatGPT Widget Development Complete Guide - Widget architecture and best practices
- window.openai API Reference - Complete window.openai API documentation
- Widget State Persistence Patterns - State management across errors
- Widget Loading States and Skeleton Screens - Loading UX patterns
- MCP Server Error Recovery Patterns - Server-side error handling
- ChatGPT App Testing and QA Complete Guide - Comprehensive testing strategies
- ChatGPT App Performance Optimization Guide - Performance best practices
- React Error Boundaries Documentation - Official React docs
- Sentry React Integration - Error monitoring setup
- LogRocket React Integration - Session replay configuration
Ready to build bulletproof ChatGPT widgets? Start building with MakeAIHQ and deploy production-ready apps with enterprise-grade error handling in 48 hours. No coding required.