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


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.