feat: Phase 5 - lazy load recharts and add chart error boundaries
High priority performance and reliability improvements:
1. Lazy Load Recharts Library
- Wrapped all chart components (PlaytimeChart, TopGamesChart, GamesDistributionChart) with React.lazy()
- Charts now code-split into separate bundle (380 kB / 112 kB gzipped)
- Charts only load when Dashboard view is accessed, not on initial page load
- Added Suspense boundaries with ChartSkeleton fallback
- Significantly reduces initial bundle size for better performance
2. Chart Error Boundaries
- Created ChartErrorBoundary component to catch rendering errors
- Prevents chart failures from crashing the entire page
- Displays user-friendly fallback UI with error details
- Each chart wrapped independently for isolated error handling
- Fallback shows descriptive titles and recovery suggestions
3. Loading States
- Charts already have internal loading states for data fetching
- Added Suspense fallback skeletons for component code loading
- Comprehensive loading UX: Suspense → Data Fetch → Render
Architecture:
- ChartErrorBoundary (catches rendering errors)
- Suspense (handles code splitting/lazy loading)
- Chart Component (internal data fetch loading states)
Benefits:
- Faster initial page load (charts not in main bundle)
- Better error resilience (isolated chart failures)
- Improved user experience (clear loading and error states)
All changes verified with TypeScript check and production build.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { Component, ReactNode } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallbackTitle?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary component for chart components
|
||||
* Catches rendering errors in recharts and displays fallback UI
|
||||
*/
|
||||
export class ChartErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Chart rendering error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="bg-card rounded-lg p-6 border border-border shadow-md">
|
||||
<div className="h-96 flex flex-col items-center justify-center gap-4">
|
||||
<div className="p-4 bg-destructive/10 rounded-full">
|
||||
<AlertTriangle size={32} className="text-destructive" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{this.props.fallbackTitle || 'Chart Error'}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Unable to render chart. Please try refreshing the page.
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<p className="text-xs text-muted-foreground mt-2 font-mono">
|
||||
{this.state.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,24 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Clock, GamepadIcon, TrendingUp, BarChart3 } from 'lucide-react';
|
||||
import { useUserStats } from '../../hooks/usePlayTracking';
|
||||
import { PlaytimeChart } from './PlaytimeChart';
|
||||
import { TopGamesChart } from './TopGamesChart';
|
||||
import { GamesDistributionChart } from './GamesDistributionChart';
|
||||
import { useDateTimeFormat } from '../../hooks/useDateTimeFormat';
|
||||
import { ChartErrorBoundary } from './ChartErrorBoundary';
|
||||
|
||||
// Lazy load chart components to reduce initial bundle size
|
||||
const PlaytimeChart = lazy(() => import('./PlaytimeChart').then(m => ({ default: m.PlaytimeChart })));
|
||||
const TopGamesChart = lazy(() => import('./TopGamesChart').then(m => ({ default: m.TopGamesChart })));
|
||||
const GamesDistributionChart = lazy(() => import('./GamesDistributionChart').then(m => ({ default: m.GamesDistributionChart })));
|
||||
|
||||
// Skeleton loader for chart components
|
||||
function ChartSkeleton({ height = 'h-80' }: { height?: string }) {
|
||||
return (
|
||||
<div className="bg-card rounded-lg p-6 border border-border shadow-md">
|
||||
<div className={`${height} flex items-center justify-center`}>
|
||||
<div className="animate-pulse text-muted-foreground">Loading chart...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPlaytime(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
@@ -121,15 +136,27 @@ export function UserStatsPanel() {
|
||||
</div>
|
||||
|
||||
{/* Play Activity Over Time Chart */}
|
||||
<PlaytimeChart />
|
||||
<ChartErrorBoundary fallbackTitle="Play Activity Chart Error">
|
||||
<Suspense fallback={<ChartSkeleton height="h-96" />}>
|
||||
<PlaytimeChart />
|
||||
</Suspense>
|
||||
</ChartErrorBoundary>
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top Games Bar Chart */}
|
||||
<TopGamesChart />
|
||||
<ChartErrorBoundary fallbackTitle="Top Games Chart Error">
|
||||
<Suspense fallback={<ChartSkeleton height="h-96" />}>
|
||||
<TopGamesChart />
|
||||
</Suspense>
|
||||
</ChartErrorBoundary>
|
||||
|
||||
{/* Games Distribution Pie Chart */}
|
||||
<GamesDistributionChart />
|
||||
<ChartErrorBoundary fallbackTitle="Distribution Chart Error">
|
||||
<Suspense fallback={<ChartSkeleton height="h-96" />}>
|
||||
<GamesDistributionChart />
|
||||
</Suspense>
|
||||
</ChartErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user