feat: Add comprehensive error boundaries for graceful error handling
Implemented React error boundaries to catch and handle errors gracefully: **Core Error Handling Components:** - Created ErrorBoundary class component with error catching and logging - Created specialized fallback UIs (MinimalErrorFallback, DataErrorFallback, ComponentErrorFallback, FormErrorFallback, ChartErrorFallback, ImageErrorFallback) - Added withErrorBoundary HOC for easy component wrapping - Created errorLogger service with Sentry integration placeholder **Error Logging Service (errorLogger.ts):** - Centralized error logging with severity levels (FATAL, ERROR, WARNING, INFO, DEBUG) - Context enrichment (URL, userAgent, timestamp, environment) - Local storage of last 10 errors in sessionStorage for debugging - User context management (setUser, clearUser) - Breadcrumb support for debugging trails **App Integration:** - Wrapped root layout with top-level ErrorBoundary for catastrophic errors - Added NetworkStatusIndicator to main page for offline sync visibility - Wrapped daily summary section with isolated DataErrorFallback - Added error boundary to AI assistant page with ComponentErrorFallback - Wrapped feeding tracking form with FormErrorFallback using withErrorBoundary HOC - Protected analytics charts with isolated ChartErrorFallback boundaries **Error Recovery Features:** - Isolated error boundaries prevent cascade failures - Retry buttons on all fallback UIs - Error count tracking with user warnings - Development-mode error details display - Automatic error logging to service (when Sentry integrated) Next: Integration with Sentry for production error tracking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
209
maternal-web/components/common/ErrorBoundary.tsx
Normal file
209
maternal-web/components/common/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
import { Box, Button, Typography, Paper, Alert } from '@mui/material';
|
||||
import { ErrorOutline, Refresh, Home } from '@mui/icons-material';
|
||||
import errorLogger, { ErrorSeverity } from '@/lib/services/errorLogger';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
isolate?: boolean; // If true, only this section fails, not the whole app
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: React.ErrorInfo | null;
|
||||
errorCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Boundary component that catches JavaScript errors in child components
|
||||
* Displays fallback UI and logs errors for monitoring
|
||||
*/
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
errorCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
// Log error to console in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
// Update state with error details
|
||||
this.setState((prevState) => ({
|
||||
errorInfo,
|
||||
errorCount: prevState.errorCount + 1,
|
||||
}));
|
||||
|
||||
// Call custom error handler if provided
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo);
|
||||
}
|
||||
|
||||
// Log to error tracking service (e.g., Sentry)
|
||||
this.logErrorToService(error, errorInfo);
|
||||
}
|
||||
|
||||
logErrorToService = (error: Error, errorInfo: React.ErrorInfo) => {
|
||||
// Log to error tracking service
|
||||
errorLogger.logError(
|
||||
error,
|
||||
{
|
||||
componentStack: errorInfo.componentStack,
|
||||
errorBoundary: this.props.isolate ? 'isolated' : 'global',
|
||||
},
|
||||
ErrorSeverity.ERROR
|
||||
);
|
||||
};
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
});
|
||||
};
|
||||
|
||||
handleGoHome = () => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Custom fallback UI if provided
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// Default fallback UI
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: this.props.isolate ? '200px' : '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 3,
|
||||
bgcolor: this.props.isolate ? 'transparent' : 'background.default',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
maxWidth: 600,
|
||||
width: '100%',
|
||||
p: 4,
|
||||
borderRadius: 3,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<ErrorOutline
|
||||
sx={{
|
||||
fontSize: 64,
|
||||
color: 'error.main',
|
||||
mb: 2,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography variant="h5" fontWeight="600" gutterBottom>
|
||||
Oops! Something went wrong
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{this.props.isolate
|
||||
? 'This section encountered an error. The rest of the app should still work.'
|
||||
: "We're sorry for the inconvenience. The error has been logged and we'll look into it."}
|
||||
</Typography>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<Alert severity="error" sx={{ mb: 3, textAlign: 'left' }}>
|
||||
<Typography variant="body2" fontWeight="600" gutterBottom>
|
||||
{this.state.error.message}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="pre"
|
||||
sx={{
|
||||
mt: 1,
|
||||
fontSize: '0.7rem',
|
||||
overflow: 'auto',
|
||||
maxHeight: 200,
|
||||
}}
|
||||
>
|
||||
{this.state.error.stack}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Refresh />}
|
||||
onClick={this.handleReset}
|
||||
size="large"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
|
||||
{!this.props.isolate && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Home />}
|
||||
onClick={this.handleGoHome}
|
||||
size="large"
|
||||
>
|
||||
Go Home
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{this.state.errorCount > 1 && (
|
||||
<Alert severity="warning" sx={{ mt: 3 }}>
|
||||
This error has occurred {this.state.errorCount} times. You may want to refresh the
|
||||
entire page or contact support.
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook-based error boundary (for functional components)
|
||||
* Note: This is a workaround since React doesn't have hook-based error boundaries yet
|
||||
*/
|
||||
export function useErrorHandler(error?: Error) {
|
||||
const [, setError] = React.useState();
|
||||
|
||||
return React.useCallback(
|
||||
(error: Error) => {
|
||||
setError(() => {
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
[setError]
|
||||
);
|
||||
}
|
||||
236
maternal-web/components/common/ErrorFallbacks.tsx
Normal file
236
maternal-web/components/common/ErrorFallbacks.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Button, Typography, Paper } from '@mui/material';
|
||||
import { BrokenImage, CloudOff, BugReport, DataObject } from '@mui/icons-material';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface FallbackProps {
|
||||
error?: Error;
|
||||
resetError?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic minimal error fallback
|
||||
*/
|
||||
export function MinimalErrorFallback({ error, resetError }: FallbackProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
bgcolor: 'error.light',
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'error.main',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="error.dark" gutterBottom>
|
||||
Something went wrong
|
||||
</Typography>
|
||||
{resetError && (
|
||||
<Button size="small" onClick={resetError} variant="outlined" color="error">
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for image loading errors
|
||||
*/
|
||||
export function ImageErrorFallback() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'grey.100',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<BrokenImage sx={{ fontSize: 48, color: 'grey.400' }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for API/data fetching errors
|
||||
*/
|
||||
export function DataErrorFallback({ error, resetError }: FallbackProps) {
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
textAlign: 'center',
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<CloudOff sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Failed to Load Data
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{error?.message || 'Unable to fetch the requested data. Please try again.'}
|
||||
</Typography>
|
||||
{resetError && (
|
||||
<Button variant="contained" onClick={resetError}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for rendering errors in components
|
||||
*/
|
||||
export function ComponentErrorFallback({ error, resetError }: FallbackProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
border: '2px dashed',
|
||||
borderColor: 'warning.main',
|
||||
borderRadius: 2,
|
||||
bgcolor: 'warning.light',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<BugReport sx={{ color: 'warning.dark' }} />
|
||||
<Typography variant="subtitle1" fontWeight="600" color="warning.dark">
|
||||
Component Error
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
This component failed to render. The rest of the page should still work.
|
||||
</Typography>
|
||||
{process.env.NODE_ENV === 'development' && error && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="pre"
|
||||
sx={{
|
||||
p: 1,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 1,
|
||||
overflow: 'auto',
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
{error.message}
|
||||
</Typography>
|
||||
)}
|
||||
{resetError && (
|
||||
<Button size="small" variant="outlined" onClick={resetError} sx={{ mt: 1 }}>
|
||||
Reload Component
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for form errors
|
||||
*/
|
||||
export function FormErrorFallback({ error, resetError }: FallbackProps) {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: 'error.light',
|
||||
border: '1px solid',
|
||||
borderColor: 'error.main',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" color="error.dark" gutterBottom>
|
||||
Form Submission Error
|
||||
</Typography>
|
||||
<Typography variant="body2" color="error.dark" sx={{ mb: 2 }}>
|
||||
{error?.message || 'Unable to process your form. Please try again.'}
|
||||
</Typography>
|
||||
{resetError && (
|
||||
<Button size="small" variant="contained" color="error" onClick={resetError}>
|
||||
Try Again
|
||||
</Button>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for data visualization/chart errors
|
||||
*/
|
||||
export function ChartErrorFallback({ resetError }: FallbackProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: 300,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'grey.50',
|
||||
borderRadius: 2,
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<DataObject sx={{ fontSize: 48, color: 'text.disabled', mb: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Unable to display chart
|
||||
</Typography>
|
||||
{resetError && (
|
||||
<Button size="small" onClick={resetError} sx={{ mt: 1 }}>
|
||||
Reload
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper component to easily apply error boundary with custom fallback
|
||||
*/
|
||||
interface ErrorBoundaryWrapperProps {
|
||||
children: ReactNode;
|
||||
fallbackType?: 'minimal' | 'component' | 'data' | 'form' | 'chart';
|
||||
customFallback?: ReactNode;
|
||||
}
|
||||
|
||||
export function withErrorBoundary<P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
fallbackType: ErrorBoundaryWrapperProps['fallbackType'] = 'component'
|
||||
) {
|
||||
return function WithErrorBoundaryWrapper(props: P) {
|
||||
const { ErrorBoundary } = require('./ErrorBoundary');
|
||||
|
||||
const getFallback = (error: Error, resetError: () => void) => {
|
||||
switch (fallbackType) {
|
||||
case 'minimal':
|
||||
return <MinimalErrorFallback error={error} resetError={resetError} />;
|
||||
case 'data':
|
||||
return <DataErrorFallback error={error} resetError={resetError} />;
|
||||
case 'form':
|
||||
return <FormErrorFallback error={error} resetError={resetError} />;
|
||||
case 'chart':
|
||||
return <ChartErrorFallback resetError={resetError} />;
|
||||
case 'component':
|
||||
default:
|
||||
return <ComponentErrorFallback error={error} resetError={resetError} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary isolate fallback={(error: Error, reset: () => void) => getFallback(error, reset)}>
|
||||
<Component {...props} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user