WellNuo/components/errors/ErrorBoundary.tsx
Sergei 3260119ece Add comprehensive network error handling system
- Add networkErrorRecovery utility with:
  - Request timeout handling via AbortController
  - Circuit breaker pattern to prevent cascading failures
  - Request deduplication for concurrent identical requests
  - Enhanced fetch with timeout, circuit breaker, and retry support

- Add useApiWithErrorHandling hooks:
  - useApiCall for single API calls with auto error display
  - useMutation for mutations with optimistic update support
  - useMultipleApiCalls for parallel API execution

- Add ErrorBoundary component:
  - Catches React errors in component tree
  - Displays fallback UI with retry option
  - Supports custom fallback components
  - withErrorBoundary HOC for easy wrapping

- Add comprehensive tests (64 passing tests):
  - Circuit breaker state transitions
  - Request deduplication
  - Timeout detection
  - Error type classification
  - Hook behavior and error handling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-01 09:29:19 -08:00

351 lines
8.6 KiB
TypeScript

/**
* 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
* <ErrorBoundary
* fallback={<CustomErrorScreen />}
* onError={(error, info) => logToService(error, info)}
* >
* <App />
* </ErrorBoundary>
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
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 (
<DefaultErrorFallback
error={this.state.error}
errorInfo={this.state.errorInfo}
onRetry={this.handleRetry}
showDetails={this.props.showDetails ?? __DEV__}
/>
);
}
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 (
<View style={styles.container}>
<View style={styles.content}>
{/* Icon */}
<View style={styles.iconContainer}>
<Ionicons name="bug" size={64} color="#EF4444" />
</View>
{/* Title */}
<Text style={styles.title}>Oops! Something went wrong</Text>
{/* Message */}
<Text style={styles.message}>
The app encountered an unexpected error. We apologize for the inconvenience.
</Text>
{/* Retry Button */}
{onRetry && (
<TouchableOpacity
style={styles.retryButton}
onPress={onRetry}
activeOpacity={0.8}
>
<Ionicons name="refresh" size={20} color="#fff" style={styles.buttonIcon} />
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
)}
{/* Error Details (Development Only) */}
{showDetails && error && (
<View style={styles.detailsContainer}>
<TouchableOpacity
style={styles.detailsHeader}
onPress={() => setDetailsExpanded(!detailsExpanded)}
activeOpacity={0.7}
>
<Text style={styles.detailsHeaderText}>
{detailsExpanded ? 'Hide' : 'Show'} Error Details
</Text>
<Ionicons
name={detailsExpanded ? 'chevron-up' : 'chevron-down'}
size={16}
color="#6B7280"
/>
</TouchableOpacity>
{detailsExpanded && (
<ScrollView style={styles.detailsContent}>
<Text style={styles.errorName}>{error.name}</Text>
<Text style={styles.errorMessage}>{error.message}</Text>
{errorInfo?.componentStack && (
<>
<Text style={styles.stackTitle}>Component Stack:</Text>
<Text style={styles.stackTrace}>
{errorInfo.componentStack}
</Text>
</>
)}
{error.stack && (
<>
<Text style={styles.stackTitle}>Error Stack:</Text>
<Text style={styles.stackTrace}>{error.stack}</Text>
</>
)}
</ScrollView>
)}
</View>
)}
</View>
</View>
);
}
// ==================== Wrapper HOC ====================
/**
* Higher-order component to wrap any component with ErrorBoundary
*
* @example
* const SafeScreen = withErrorBoundary(MyScreen, {
* fallback: <CustomError />,
* onError: logError,
* });
*/
export function withErrorBoundary<P extends object>(
WrappedComponent: React.ComponentType<P>,
errorBoundaryProps?: Omit<ErrorBoundaryProps, 'children'>
) {
const displayName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
const ComponentWithErrorBoundary = (props: P) => (
<ErrorBoundary {...errorBoundaryProps}>
<WrappedComponent {...props} />
</ErrorBoundary>
);
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;