- 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>
351 lines
8.6 KiB
TypeScript
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;
|