From e8c52561a2b881cb6bece0d806720e06cd148c99 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 20 Nov 2025 07:31:34 -0600 Subject: [PATCH] feat: add comprehensive error handling for arcade games MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add user-facing error notifications and boundaries to prevent silent failures in arcade games. **Problem:** - Errors only logged to console (e.g., "Failed to fetch session") - Users saw nothing when errors occurred - Buttons stopped working with no feedback - No way to recover from errors **Solution: 4-Part Error Handling System** 1. **ErrorToast Component** - User-facing error notifications - Prominent red toast in bottom-right corner - Auto-dismisses after 10 seconds - Collapsible technical details - Mobile-responsive 2. **ArcadeErrorBoundary** - React error boundary - Catches component render errors - Shows user-friendly fallback UI - Provides "Try Again" and "Return to Lobby" buttons - Collapsible stack trace for debugging 3. **ArcadeErrorContext** - Global error management - Manages error state across the app - Renders error toasts - Auto-cleans up old errors 4. **Enhanced useArcadeSocket** - Automatic socket error handling - Connection errors: "Failed to connect to server" - Disconnections: "Connection lost, attempting to reconnect" - Session errors: "Failed to load/update session" - Move rejections: "Your move was not accepted" - No active session: "No game session found" - Can suppress toasts with `suppressErrorToasts: true` **Files Created:** - src/components/ErrorToast.tsx - src/components/ArcadeErrorBoundary.tsx - src/contexts/ArcadeErrorContext.tsx - .claude/ERROR_HANDLING.md (integration guide) **Files Modified:** - src/hooks/useArcadeSocket.ts (automatic error toasts) **Next Steps (TODO):** - Wrap arcade game pages with error providers - Test all error scenarios - Add error recovery strategies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/web/.claude/ERROR_HANDLING.md | 188 +++++++++++++++ apps/web/.claude/settings.local.json | 3 +- .../src/components/ArcadeErrorBoundary.tsx | 223 ++++++++++++++++++ apps/web/src/components/ErrorToast.tsx | 187 +++++++++++++++ apps/web/src/contexts/ArcadeErrorContext.tsx | 82 +++++++ apps/web/src/hooks/useArcadeSocket.ts | 49 ++++ 6 files changed, 731 insertions(+), 1 deletion(-) create mode 100644 apps/web/.claude/ERROR_HANDLING.md create mode 100644 apps/web/src/components/ArcadeErrorBoundary.tsx create mode 100644 apps/web/src/components/ErrorToast.tsx create mode 100644 apps/web/src/contexts/ArcadeErrorContext.tsx diff --git a/apps/web/.claude/ERROR_HANDLING.md b/apps/web/.claude/ERROR_HANDLING.md new file mode 100644 index 00000000..3afcecb9 --- /dev/null +++ b/apps/web/.claude/ERROR_HANDLING.md @@ -0,0 +1,188 @@ +# Arcade Error Handling System + +## Overview + +Comprehensive error handling system for arcade games to ensure users always see meaningful error messages instead of silent failures. + +## Components + +### 1. ErrorToast (`src/components/ErrorToast.tsx`) + +User-facing error notification component: +- Prominent red toast in bottom-right corner +- Auto-dismisses after 10 seconds +- Collapsible technical details +- Mobile-responsive + +**Usage:** +```typescript + clearError(errorId)} +/> +``` + +### 2. ArcadeErrorBoundary (`src/components/ArcadeErrorBoundary.tsx`) + +React error boundary for catching React errors: +- Catches component render errors +- Shows user-friendly fallback UI +- Provides "Try Again" and "Return to Lobby" buttons +- Collapsible stack trace for debugging + +**Usage:** +```typescript + + + +``` + +### 3. ArcadeErrorContext (`src/contexts/ArcadeErrorContext.tsx`) + +Global error management context: +- Manages error state across the app +- Renders error toasts +- Auto-cleans up old errors + +**Usage:** +```typescript +// Wrap your app/page + + {children} + + +// Use in components +const { addError, clearError } = useArcadeError() +addError('Something went wrong', 'Technical details...') +``` + +### 4. Enhanced useArcadeSocket Hook + +Socket hook now automatically shows error toasts for: +- **Connection errors**: Failed to connect to server +- **Disconnections**: Connection lost +- **Session errors**: Failed to load/update session +- **Move rejections**: Invalid moves (non-version-conflict) +- **No active session**: Session not found + +Can suppress toasts with `suppressErrorToasts: true` option. + +## Error Categories + +### Network/Connection Errors +- **Connection error**: Failed to connect to game server +- **Disconnection**: Connection lost, attempting to reconnect + +### Session Errors +- **Session error**: Failed to load or update game session +- **No active session**: No game session found + +### Game State Errors +- **Move rejected**: Invalid move submitted +- **Version conflict**: Concurrent update detected (silent, not shown to user) + +### React Errors +- **Component errors**: Caught by ErrorBoundary, shows fallback UI + +## Integration Guide + +### For New Arcade Games + +1. **Wrap your game page with error providers:** +```typescript +// src/app/arcade/your-game/page.tsx +import { ArcadeErrorProvider } from '@/contexts/ArcadeErrorContext' +import { ArcadeErrorBoundary } from '@/components/ArcadeErrorBoundary' + +export default function YourGamePage() { + return ( + + + + + + + + ) +} +``` + +2. **Use error context in your components:** +```typescript +import { useArcadeError } from '@/contexts/ArcadeErrorContext' + +function YourComponent() { + const { addError } = useArcadeError() + + try { + // Your code + } catch (error) { + addError( + 'User-friendly message', + `Technical details: ${error.message}` + ) + } +} +``` + +3. **Socket hook is automatic:** +The `useArcadeSocket` hook already shows errors by default. No changes needed unless you want to suppress them. + +## Best Practices + +### DO: +- ✅ Use `addError()` for runtime errors +- ✅ Provide user-friendly primary messages +- ✅ Include technical details in the `details` parameter +- ✅ Wrap arcade pages with both ErrorProvider and ErrorBoundary +- ✅ Let socket errors show automatically (they're handled) + +### DON'T: +- ❌ Don't just log errors to console +- ❌ Don't show raw error messages to users +- ❌ Don't swallow errors silently +- ❌ Don't use `alert()` for errors +- ❌ Don't forget to wrap new arcade games + +## TODO + +- [ ] Wrap all arcade game pages with error providers +- [ ] Add error recovery strategies (retry buttons) +- [ ] Add error reporting/telemetry +- [ ] Test all error scenarios +- [ ] Document error codes/types + +## Testing Errors + +To test error handling: + +1. **Connection errors**: Disconnect network, try to join game +2. **Session errors**: Use invalid room ID +3. **Move rejection**: Submit invalid move +4. **React errors**: Throw error in component render + +## Example: Know Your World + +The know-your-world game had a "Failed to fetch session" error that was only logged to console. With the new system: + +**Before:** +- Error logged to console +- User sees nothing, buttons don't work +- No way to know what's wrong + +**After:** +- Error toast appears: "Game session error" +- Technical details available (collapsible) +- User can refresh or return to lobby +- Clear actionable feedback + +## Migration + +Existing arcade games need to be updated to wrap with error providers. Priority order: + +1. ✅ useArcadeSocket hook (done) +2. ✅ Error components created (done) +3. ⏳ Wrap arcade game pages +4. ⏳ Test error scenarios +5. ⏳ Add recovery strategies diff --git a/apps/web/.claude/settings.local.json b/apps/web/.claude/settings.local.json index 75923598..bff8698d 100644 --- a/apps/web/.claude/settings.local.json +++ b/apps/web/.claude/settings.local.json @@ -58,7 +58,8 @@ "Bash(gh run view:*)", "Bash(pnpm install:*)", "Bash(git checkout:*)", - "Bash(node server.js:*)" + "Bash(node server.js:*)", + "Bash(git fetch:*)" ], "deny": [], "ask": [] diff --git a/apps/web/src/components/ArcadeErrorBoundary.tsx b/apps/web/src/components/ArcadeErrorBoundary.tsx new file mode 100644 index 00000000..ebe4ba4b --- /dev/null +++ b/apps/web/src/components/ArcadeErrorBoundary.tsx @@ -0,0 +1,223 @@ +'use client' + +import React, { Component, type ReactNode } from 'react' +import { css } from '../../styled-system/css' + +interface Props { + children: ReactNode + fallback?: (error: Error, resetError: () => void) => ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +/** + * Error boundary for arcade games + * Catches React errors and displays a user-friendly error UI + */ +export class ArcadeErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('[ArcadeErrorBoundary] Caught error:', error, errorInfo) + } + + resetError = () => { + this.setState({ hasError: false, error: null }) + } + + render() { + if (this.state.hasError && this.state.error) { + if (this.props.fallback) { + return this.props.fallback(this.state.error, this.resetError) + } + + return + } + + return this.props.children + } +} + +/** + * Default error fallback UI + */ +function DefaultErrorFallback({ error, resetError }: { error: Error; resetError: () => void }) { + const [showDetails, setShowDetails] = React.useState(false) + + return ( +
+ {/* Error icon */} +
+ ⚠️ +
+ + {/* Error title */} +

+ Something Went Wrong +

+ + {/* Error message */} +

+ The game encountered an unexpected error. Please try refreshing the page or returning to the arcade + lobby. +

+ + {/* Action buttons */} +
+ + + +
+ + {/* Technical details (collapsible) */} +
+ + + {showDetails && ( +
+
+ Error: {error.message} +
+ + {error.stack && ( +
+                {error.stack}
+              
+ )} +
+ )} +
+
+ ) +} diff --git a/apps/web/src/components/ErrorToast.tsx b/apps/web/src/components/ErrorToast.tsx new file mode 100644 index 00000000..de7c0468 --- /dev/null +++ b/apps/web/src/components/ErrorToast.tsx @@ -0,0 +1,187 @@ +'use client' + +import { useEffect, useState } from 'react' +import { css } from '../../styled-system/css' + +export interface ErrorToastProps { + message: string + details?: string + onDismiss: () => void + autoHideDuration?: number // milliseconds, default 10000 (10s) +} + +/** + * Error toast notification component + * Shows prominent error messages to users with optional details + */ +export function ErrorToast({ + message, + details, + onDismiss, + autoHideDuration = 10000, +}: ErrorToastProps) { + const [isVisible, setIsVisible] = useState(true) + const [showDetails, setShowDetails] = useState(false) + + useEffect(() => { + if (autoHideDuration > 0) { + const timer = setTimeout(() => { + setIsVisible(false) + setTimeout(onDismiss, 300) // Wait for fade-out animation + }, autoHideDuration) + + return () => clearTimeout(timer) + } + }, [autoHideDuration, onDismiss]) + + if (!isVisible) return null + + return ( +
+ {/* Header with dismiss button */} +
+
+ + ⚠️ + + Error +
+ + +
+ + {/* Error message */} +
+ {message} +
+ + {/* Optional details */} + {details && ( +
+ + + {showDetails && ( +
+              {details}
+            
+ )} +
+ )} +
+ ) +} + +/** + * Error toast container that manages multiple toasts + */ +export function ErrorToastContainer({ errors }: { errors: Array<{ id: string; message: string; details?: string }> }) { + const [visibleErrors, setVisibleErrors] = useState(errors) + + useEffect(() => { + setVisibleErrors(errors) + }, [errors]) + + return ( + <> + {visibleErrors.map((error, index) => ( + { + setVisibleErrors((prev) => prev.filter((e) => e.id !== error.id)) + }} + /> + ))} + + ) +} diff --git a/apps/web/src/contexts/ArcadeErrorContext.tsx b/apps/web/src/contexts/ArcadeErrorContext.tsx new file mode 100644 index 00000000..c4b7e66b --- /dev/null +++ b/apps/web/src/contexts/ArcadeErrorContext.tsx @@ -0,0 +1,82 @@ +'use client' + +import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react' +import { ErrorToast } from '@/components/ErrorToast' + +interface ArcadeError { + id: string + message: string + details?: string + timestamp: number +} + +interface ArcadeErrorContextValue { + errors: ArcadeError[] + addError: (message: string, details?: string) => void + clearError: (id: string) => void + clearAllErrors: () => void +} + +const ArcadeErrorContext = createContext(null) + +/** + * Provider for arcade error management + * Manages error toast notifications across arcade games + */ +export function ArcadeErrorProvider({ children }: { children: ReactNode }) { + const [errors, setErrors] = useState([]) + + const addError = useCallback((message: string, details?: string) => { + const error: ArcadeError = { + id: `error-${Date.now()}-${Math.random()}`, + message, + details, + timestamp: Date.now(), + } + + setErrors((prev) => [...prev, error]) + + // Auto-remove after 15 seconds as fallback + setTimeout(() => { + setErrors((prev) => prev.filter((e) => e.id !== error.id)) + }, 15000) + }, []) + + const clearError = useCallback((id: string) => { + setErrors((prev) => prev.filter((e) => e.id !== id)) + }, []) + + const clearAllErrors = useCallback(() => { + setErrors([]) + }, []) + + return ( + + {children} + + {/* Render error toasts */} +
+ {errors.map((error) => ( + clearError(error.id)} + autoHideDuration={10000} + /> + ))} +
+
+ ) +} + +/** + * Hook to access arcade error context + */ +export function useArcadeError() { + const context = useContext(ArcadeErrorContext) + if (!context) { + throw new Error('useArcadeError must be used within ArcadeErrorProvider') + } + return context +} diff --git a/apps/web/src/hooks/useArcadeSocket.ts b/apps/web/src/hooks/useArcadeSocket.ts index fd06e716..53c0e3b6 100644 --- a/apps/web/src/hooks/useArcadeSocket.ts +++ b/apps/web/src/hooks/useArcadeSocket.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { io, type Socket } from 'socket.io-client' import type { GameMove } from '@/lib/arcade/validation' +import { useArcadeError } from '@/contexts/ArcadeErrorContext' export interface ArcadeSocketEvents { onSessionState?: (data: { @@ -15,6 +16,8 @@ export interface ArcadeSocketEvents { onSessionEnded?: () => void onNoActiveSession?: () => void onError?: (error: { error: string }) => void + /** If true, errors will NOT show toasts (for cases where game handles errors directly) */ + suppressErrorToasts?: boolean } export interface UseArcadeSocketReturn { @@ -36,6 +39,7 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke const [socket, setSocket] = useState(null) const [connected, setConnected] = useState(false) const eventsRef = useRef(events) + const { addError } = useArcadeError() // Update events ref when they change useEffect(() => { @@ -59,6 +63,25 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke socketInstance.on('disconnect', () => { console.log('[ArcadeSocket] Disconnected') setConnected(false) + + // Show error toast unless suppressed + if (!eventsRef.current.suppressErrorToasts) { + addError( + 'Connection lost', + 'The connection to the game server was lost. Attempting to reconnect...' + ) + } + }) + + socketInstance.on('connect_error', (error) => { + console.error('[ArcadeSocket] Connection error', error) + + if (!eventsRef.current.suppressErrorToasts) { + addError( + 'Connection error', + `Failed to connect to the game server: ${error.message}\n\nPlease check your internet connection and try refreshing the page.` + ) + } }) socketInstance.on('session-state', (data) => { @@ -66,6 +89,14 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke }) socketInstance.on('no-active-session', () => { + // Show error toast unless suppressed + if (!eventsRef.current.suppressErrorToasts) { + addError( + 'No active session', + 'No game session was found. Please start a new game or join an existing room.' + ) + } + eventsRef.current.onNoActiveSession?.() }) @@ -75,6 +106,15 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke socketInstance.on('move-rejected', (data) => { console.log(`[ArcadeSocket] Move rejected: ${data.error}`) + + // Show error toast for move rejections unless suppressed or it's a version conflict + if (!eventsRef.current.suppressErrorToasts && !data.versionConflict) { + addError( + 'Move rejected', + `Your move was not accepted: ${data.error}\n\nMove type: ${data.move.type}` + ) + } + eventsRef.current.onMoveRejected?.(data) }) @@ -85,6 +125,15 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke socketInstance.on('session-error', (data) => { console.error('[ArcadeSocket] Session error', data) + + // Show error toast unless suppressed + if (!eventsRef.current.suppressErrorToasts) { + addError( + 'Game session error', + `Error: ${data.error}\n\nThis usually means there was a problem loading or updating the game session. Please try refreshing the page or returning to the lobby.` + ) + } + eventsRef.current.onError?.(data) })