/** * ErrorBoundary - React Error Boundary for catching unhandled errors * * Catches JavaScript errors anywhere in child component tree, * logs them, and displays a fallback UI. */ import React, { Component, ErrorInfo, ReactNode } from 'react'; import { View, Text, TouchableOpacity, StyleSheet, ScrollView, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; interface ErrorBoundaryProps { children: ReactNode; /** Custom fallback component */ fallback?: ReactNode; /** Called when an error is caught */ onError?: (error: Error, errorInfo: ErrorInfo) => void; /** Reset key - when this changes, the error state is reset */ resetKey?: string | number; /** Show error details in development mode */ showDetails?: boolean; /** Custom retry handler */ onRetry?: () => void; } interface ErrorBoundaryState { hasError: boolean; error: Error | null; errorInfo: ErrorInfo | null; } /** * ErrorBoundary component - catches React errors and displays fallback UI * * @example * } * onError={(error, info) => logToService(error, info)} * > * * */ export class ErrorBoundary extends Component { constructor(props: ErrorBoundaryProps) { super(props); this.state = { hasError: false, error: null, errorInfo: null, }; } static getDerivedStateFromError(error: Error): Partial { return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: ErrorInfo): void { // Log to console in development if (__DEV__) { console.error('ErrorBoundary caught an error:', error); console.error('Component stack:', errorInfo.componentStack); } // Update state with error info this.setState({ errorInfo }); // Call onError callback if provided this.props.onError?.(error, errorInfo); // TODO: Log to error reporting service (Sentry, Bugsnag, etc.) // errorReportingService.captureException(error, { extra: errorInfo }); } componentDidUpdate(prevProps: ErrorBoundaryProps): void { // Reset error state when resetKey changes if ( this.state.hasError && prevProps.resetKey !== this.props.resetKey ) { this.resetError(); } } resetError = (): void => { this.setState({ hasError: false, error: null, errorInfo: null, }); }; handleRetry = (): void => { this.resetError(); this.props.onRetry?.(); }; render(): ReactNode { if (this.state.hasError) { // Render custom fallback if provided if (this.props.fallback) { return this.props.fallback; } // Render default error UI return ( ); } return this.props.children; } } // ==================== Default Fallback Component ==================== interface DefaultErrorFallbackProps { error: Error | null; errorInfo: ErrorInfo | null; onRetry?: () => void; showDetails?: boolean; } function DefaultErrorFallback({ error, errorInfo, onRetry, showDetails = false, }: DefaultErrorFallbackProps) { const [detailsExpanded, setDetailsExpanded] = React.useState(false); return ( {/* Icon */} {/* Title */} Oops! Something went wrong {/* Message */} The app encountered an unexpected error. We apologize for the inconvenience. {/* Retry Button */} {onRetry && ( Try Again )} {/* Error Details (Development Only) */} {showDetails && error && ( setDetailsExpanded(!detailsExpanded)} activeOpacity={0.7} > {detailsExpanded ? 'Hide' : 'Show'} Error Details {detailsExpanded && ( {error.name} {error.message} {errorInfo?.componentStack && ( <> Component Stack: {errorInfo.componentStack} > )} {error.stack && ( <> Error Stack: {error.stack} > )} )} )} ); } // ==================== Wrapper HOC ==================== /** * Higher-order component to wrap any component with ErrorBoundary * * @example * const SafeScreen = withErrorBoundary(MyScreen, { * fallback: , * onError: logError, * }); */ export function withErrorBoundary( WrappedComponent: React.ComponentType, errorBoundaryProps?: Omit ) { const displayName = WrappedComponent.displayName || WrappedComponent.name || 'Component'; const ComponentWithErrorBoundary = (props: P) => ( ); ComponentWithErrorBoundary.displayName = `withErrorBoundary(${displayName})`; return ComponentWithErrorBoundary; } // ==================== Styles ==================== const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#fff', padding: 24, }, content: { alignItems: 'center', maxWidth: 320, width: '100%', }, iconContainer: { width: 120, height: 120, borderRadius: 60, backgroundColor: '#FEE2E2', justifyContent: 'center', alignItems: 'center', marginBottom: 24, }, title: { fontSize: 20, fontWeight: '700', color: '#1F2937', textAlign: 'center', marginBottom: 8, }, message: { fontSize: 15, color: '#6B7280', textAlign: 'center', lineHeight: 22, marginBottom: 24, }, retryButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', backgroundColor: '#3B82F6', paddingVertical: 14, paddingHorizontal: 24, borderRadius: 12, width: '100%', marginBottom: 24, }, buttonIcon: { marginRight: 8, }, retryButtonText: { color: '#fff', fontSize: 16, fontWeight: '600', }, detailsContainer: { width: '100%', borderTopWidth: 1, borderTopColor: '#E5E7EB', paddingTop: 16, }, detailsHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 8, }, detailsHeaderText: { fontSize: 13, color: '#6B7280', marginRight: 4, }, detailsContent: { maxHeight: 300, backgroundColor: '#F9FAFB', borderRadius: 8, padding: 12, marginTop: 12, }, errorName: { fontSize: 14, fontWeight: '600', color: '#EF4444', marginBottom: 4, }, errorMessage: { fontSize: 13, color: '#374151', marginBottom: 12, }, stackTitle: { fontSize: 12, fontWeight: '600', color: '#6B7280', marginTop: 8, marginBottom: 4, }, stackTrace: { fontSize: 10, color: '#9CA3AF', fontFamily: 'monospace', lineHeight: 14, }, }); export default ErrorBoundary;
( WrappedComponent: React.ComponentType
, errorBoundaryProps?: Omit ) { const displayName = WrappedComponent.displayName || WrappedComponent.name || 'Component'; const ComponentWithErrorBoundary = (props: P) => ( ); ComponentWithErrorBoundary.displayName = `withErrorBoundary(${displayName})`; return ComponentWithErrorBoundary; } // ==================== Styles ==================== const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#fff', padding: 24, }, content: { alignItems: 'center', maxWidth: 320, width: '100%', }, iconContainer: { width: 120, height: 120, borderRadius: 60, backgroundColor: '#FEE2E2', justifyContent: 'center', alignItems: 'center', marginBottom: 24, }, title: { fontSize: 20, fontWeight: '700', color: '#1F2937', textAlign: 'center', marginBottom: 8, }, message: { fontSize: 15, color: '#6B7280', textAlign: 'center', lineHeight: 22, marginBottom: 24, }, retryButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', backgroundColor: '#3B82F6', paddingVertical: 14, paddingHorizontal: 24, borderRadius: 12, width: '100%', marginBottom: 24, }, buttonIcon: { marginRight: 8, }, retryButtonText: { color: '#fff', fontSize: 16, fontWeight: '600', }, detailsContainer: { width: '100%', borderTopWidth: 1, borderTopColor: '#E5E7EB', paddingTop: 16, }, detailsHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 8, }, detailsHeaderText: { fontSize: 13, color: '#6B7280', marginRight: 4, }, detailsContent: { maxHeight: 300, backgroundColor: '#F9FAFB', borderRadius: 8, padding: 12, marginTop: 12, }, errorName: { fontSize: 14, fontWeight: '600', color: '#EF4444', marginBottom: 4, }, errorMessage: { fontSize: 13, color: '#374151', marginBottom: 12, }, stackTitle: { fontSize: 12, fontWeight: '600', color: '#6B7280', marginTop: 8, marginBottom: 4, }, stackTrace: { fontSize: 10, color: '#9CA3AF', fontFamily: 'monospace', lineHeight: 14, }, }); export default ErrorBoundary;