feat: Add comprehensive error boundaries for graceful error handling
Some checks failed
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled

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:
2025-10-01 19:37:04 +00:00
parent 7cb2ff97de
commit 68e33712f1
8 changed files with 797 additions and 69 deletions

View 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]
);
}

View 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>
);
};
}