diff --git a/frontend-sba/composables/useWebSocket.ts b/frontend-sba/composables/useWebSocket.ts index 187b2cc..3a9b349 100644 --- a/frontend-sba/composables/useWebSocket.ts +++ b/frontend-sba/composables/useWebSocket.ts @@ -31,18 +31,71 @@ const RECONNECTION_DELAY_BASE = 1000 // Start with 1 second const RECONNECTION_DELAY_MAX = 30000 // Max 30 seconds const MAX_RECONNECTION_ATTEMPTS = 10 -// Singleton socket instance -let socketInstance: Socket | null = null -let reconnectionAttempts = 0 -let reconnectionTimeout: NodeJS.Timeout | null = null -let connectionTimeoutId: NodeJS.Timeout | null = null // Timeout to prevent stuck "isConnecting" state +// ============================================================================ +// CLIENT-ONLY Singleton State +// These MUST only be accessed/modified on the client side to prevent SSR issues. +// SSR can corrupt module-level state that persists across hydration. +// +// CRITICAL: All module-level state is lazily initialized on first client-side +// access to prevent SSR from creating zombie state that persists into hydration. +// ============================================================================ -// Singleton reactive state (shared across all useWebSocket calls) +// Client-only state container - initialized only on client +interface ClientState { + socketInstance: Socket | null + reconnectionAttempts: number + reconnectionTimeout: ReturnType | null + connectionTimeoutId: ReturnType | null + heartbeatInterval: ReturnType | null + stuckStateCheckInterval: ReturnType | null + initialized: boolean +} + +// Lazily initialized on client only +let clientState: ClientState | null = null + +function getClientState(): ClientState { + // Only initialize on client + if (import.meta.client && !clientState) { + clientState = { + socketInstance: null, + reconnectionAttempts: 0, + reconnectionTimeout: null, + connectionTimeoutId: null, + heartbeatInterval: null, + stuckStateCheckInterval: null, + initialized: true, + } + console.log('[WebSocket] Client state initialized') + } + // Return empty state for SSR (won't be used, but prevents errors) + return clientState || { + socketInstance: null, + reconnectionAttempts: 0, + reconnectionTimeout: null, + connectionTimeoutId: null, + heartbeatInterval: null, + stuckStateCheckInterval: null, + initialized: false, + } +} + +// Reactive state - using refs that work with SSR but should only be mutated client-side const isConnected = ref(false) const isConnecting = ref(false) const connectionError = ref(null) const lastConnectionAttempt = ref(null) +// Reset reactive state on client hydration to ensure clean slate +if (import.meta.client) { + // Force reset on hydration - this runs once when module loads on client + isConnected.value = false + isConnecting.value = false + connectionError.value = null + lastConnectionAttempt.value = null + console.log('[WebSocket] Reactive state reset on client hydration') +} + export function useWebSocket() { // State is now module-level singleton (above) @@ -66,7 +119,9 @@ export function useWebSocket() { // ============================================================================ const socket = computed((): TypedSocket | null => { - return socketInstance as TypedSocket | null + if (!import.meta.client) return null + const state = getClientState() + return state.socketInstance as TypedSocket | null }) const canConnect = computed(() => { @@ -74,10 +129,12 @@ export function useWebSocket() { }) const shouldReconnect = computed(() => { + if (!import.meta.client) return false + const state = getClientState() return ( !isConnected.value && canConnect.value && - reconnectionAttempts < MAX_RECONNECTION_ATTEMPTS + state.reconnectionAttempts < MAX_RECONNECTION_ATTEMPTS ) }) @@ -92,6 +149,18 @@ export function useWebSocket() { * Connect to WebSocket server with JWT authentication */ function connect() { + // SSR guard - never attempt connection on server + if (!import.meta.client) { + console.log('[WebSocket] Skipping connect() - running on server') + return + } + + const state = getClientState() + if (!state.initialized) { + console.warn('[WebSocket] Client state not initialized, skipping connect') + return + } + // REMOVED: Auth guard - let backend authenticate via cookies // The auth store state may not sync properly from SSR to client, // but cookies ARE sent automatically. Backend will reject if invalid. @@ -109,10 +178,10 @@ export function useWebSocket() { // Set a timeout to reset isConnecting if no event fires // This prevents the "stuck connecting" state that can occur on Safari - if (connectionTimeoutId) { - clearTimeout(connectionTimeoutId) + if (state.connectionTimeoutId) { + clearTimeout(state.connectionTimeoutId) } - connectionTimeoutId = setTimeout(() => { + state.connectionTimeoutId = setTimeout(() => { if (isConnecting.value && !isConnected.value) { console.warn('[WebSocket] Connection timeout - resetting state') isConnecting.value = false @@ -124,7 +193,7 @@ export function useWebSocket() { try { // Create or reuse socket instance - if (!socketInstance) { + if (!state.socketInstance) { console.log('[WebSocket] Creating socket instance with URL:', wsUrl) if (!io) { @@ -134,27 +203,27 @@ export function useWebSocket() { return } - socketInstance = io(wsUrl, { + state.socketInstance = io(wsUrl, { withCredentials: true, // Send cookies automatically autoConnect: false, reconnection: false, // We handle reconnection manually for better control transports: ['websocket', 'polling'], }) - console.log('[WebSocket] Socket instance created:', !!socketInstance) + console.log('[WebSocket] Socket instance created:', !!state.socketInstance) setupEventListeners() } console.log('[WebSocket] Calling socketInstance.connect()') - socketInstance.connect() + state.socketInstance.connect() } catch (err) { console.error('[WebSocket] Connection error:', err) isConnecting.value = false connectionError.value = err instanceof Error ? err.message : String(err) - if (connectionTimeoutId) { - clearTimeout(connectionTimeoutId) - connectionTimeoutId = null + if (state.connectionTimeoutId) { + clearTimeout(state.connectionTimeoutId) + state.connectionTimeoutId = null } } } @@ -163,23 +232,27 @@ export function useWebSocket() { * Disconnect from WebSocket server */ function disconnect() { + // SSR guard + if (!import.meta.client) return + + const state = getClientState() console.log('[WebSocket] Disconnecting') // Clear reconnection timer - if (reconnectionTimeout) { - clearTimeout(reconnectionTimeout) - reconnectionTimeout = null + if (state.reconnectionTimeout) { + clearTimeout(state.reconnectionTimeout) + state.reconnectionTimeout = null } // Disconnect socket - if (socketInstance) { - socketInstance.disconnect() + if (state.socketInstance) { + state.socketInstance.disconnect() } isConnected.value = false isConnecting.value = false connectionError.value = null - reconnectionAttempts = 0 + state.reconnectionAttempts = 0 } /** @@ -187,26 +260,30 @@ export function useWebSocket() { * Use this when the connection is in a bad state and normal reconnection isn't working. */ function forceReconnect() { + // SSR guard + if (!import.meta.client) return + + const state = getClientState() console.log('[WebSocket] Force reconnecting - clearing singleton') // Clear reconnection timer - if (reconnectionTimeout) { - clearTimeout(reconnectionTimeout) - reconnectionTimeout = null + if (state.reconnectionTimeout) { + clearTimeout(state.reconnectionTimeout) + state.reconnectionTimeout = null } // Fully disconnect and destroy the socket instance - if (socketInstance) { - socketInstance.removeAllListeners() - socketInstance.disconnect() - socketInstance = null + if (state.socketInstance) { + state.socketInstance.removeAllListeners() + state.socketInstance.disconnect() + state.socketInstance = null } // Reset all state isConnected.value = false isConnecting.value = false connectionError.value = null - reconnectionAttempts = 0 + state.reconnectionAttempts = 0 // Reconnect after a short delay setTimeout(() => { @@ -218,6 +295,11 @@ export function useWebSocket() { * Reconnect with exponential backoff */ function scheduleReconnection() { + // SSR guard + if (!import.meta.client) return + + const state = getClientState() + if (!shouldReconnect.value) { console.log('[WebSocket] Reconnection not needed or max attempts reached') return @@ -225,16 +307,16 @@ export function useWebSocket() { // Calculate delay with exponential backoff const delay = Math.min( - RECONNECTION_DELAY_BASE * Math.pow(2, reconnectionAttempts), + RECONNECTION_DELAY_BASE * Math.pow(2, state.reconnectionAttempts), RECONNECTION_DELAY_MAX ) console.log( - `[WebSocket] Scheduling reconnection attempt ${reconnectionAttempts + 1}/${MAX_RECONNECTION_ATTEMPTS} in ${delay}ms` + `[WebSocket] Scheduling reconnection attempt ${state.reconnectionAttempts + 1}/${MAX_RECONNECTION_ATTEMPTS} in ${delay}ms` ) - reconnectionTimeout = setTimeout(() => { - reconnectionAttempts++ + state.reconnectionTimeout = setTimeout(() => { + state.reconnectionAttempts++ connect() }, delay) } @@ -247,23 +329,25 @@ export function useWebSocket() { * Set up all WebSocket event listeners */ function setupEventListeners() { - if (!socketInstance) return + const state = getClientState() + if (!state.socketInstance) return // ======================================== // Connection Events // ======================================== - socketInstance.on('connect', () => { + state.socketInstance.on('connect', () => { + const s = getClientState() console.log('[WebSocket] Connected successfully') isConnected.value = true isConnecting.value = false connectionError.value = null - reconnectionAttempts = 0 + s.reconnectionAttempts = 0 // Clear connection timeout - if (connectionTimeoutId) { - clearTimeout(connectionTimeoutId) - connectionTimeoutId = null + if (s.connectionTimeoutId) { + clearTimeout(s.connectionTimeoutId) + s.connectionTimeoutId = null } // Update global flag for plugin failsafe @@ -278,7 +362,7 @@ export function useWebSocket() { uiStore.showSuccess('Connected to game server') }) - socketInstance.on('disconnect', (reason) => { + state.socketInstance.on('disconnect', (reason) => { console.log('[WebSocket] Disconnected:', reason) isConnected.value = false isConnecting.value = false @@ -300,7 +384,7 @@ export function useWebSocket() { } }) - socketInstance.on('connect_error', (error) => { + state.socketInstance.on('connect_error', (error) => { console.error('[WebSocket] Connection error:', error) isConnected.value = false isConnecting.value = false @@ -316,16 +400,16 @@ export function useWebSocket() { scheduleReconnection() }) - socketInstance.on('connected', (data) => { + state.socketInstance.on('connected', (data) => { console.log('[WebSocket] Server confirmed connection for user:', data.user_id) }) - socketInstance.on('game_joined', (data: { game_id: string; role: string }) => { + state.socketInstance.on('game_joined', (data: { game_id: string; role: string }) => { console.log('[WebSocket] Successfully joined game:', data.game_id, 'as', data.role) uiStore.showSuccess(`Joined game as ${data.role}`) }) - socketInstance.on('heartbeat_ack', () => { + state.socketInstance.on('heartbeat_ack', () => { // Heartbeat acknowledged - connection is healthy // No action needed, just prevents timeout }) @@ -334,29 +418,29 @@ export function useWebSocket() { // Game State Events // ======================================== - socketInstance.on('game_state', (state) => { + state.socketInstance.on('game_state', (gameState) => { console.log('[WebSocket] Full game state received (from request_game_state)') - gameStore.setGameState(state) + gameStore.setGameState(gameState) }) - socketInstance.on('game_state_update', (state) => { + state.socketInstance.on('game_state_update', (gameState) => { console.log('[WebSocket] Game state update received') - gameStore.setGameState(state) + gameStore.setGameState(gameState) }) - socketInstance.on('game_state_sync', (data) => { + state.socketInstance.on('game_state_sync', (data) => { console.log('[WebSocket] Full game state sync received') gameStore.setGameState(data.state) // Replace play history with synced plays (O(1) - no iteration/dedup needed) gameStore.setPlayHistory(data.recent_plays) }) - socketInstance.on('inning_change', (data) => { + state.socketInstance.on('inning_change', (data) => { console.log(`[WebSocket] Inning change: ${data.half} ${data.inning}`) uiStore.showInfo(`${data.half === 'top' ? 'Top' : 'Bottom'} ${data.inning}`, 3000) }) - socketInstance.on('game_ended', (data) => { + state.socketInstance.on('game_ended', (data) => { console.log('[WebSocket] Game ended:', data.winner_team_id) uiStore.showSuccess( `Game Over! Final: ${data.final_score.away} - ${data.final_score.home}`, @@ -368,18 +452,19 @@ export function useWebSocket() { // Decision Events // ======================================== - socketInstance.on('decision_required', (prompt) => { + state.socketInstance.on('decision_required', (prompt) => { console.log('[WebSocket] Decision required:', prompt.phase) gameStore.setDecisionPrompt(prompt) }) - socketInstance.on('defensive_decision_submitted', (data) => { + state.socketInstance.on('defensive_decision_submitted', (data) => { + const s = getClientState() console.log('[WebSocket] Defensive decision submitted') gameStore.clearDecisionPrompt() // Request updated game state to get new decision_phase - if (socketInstance && gameStore.gameId) { - socketInstance.emit('request_game_state', { + if (s.socketInstance && gameStore.gameId) { + s.socketInstance.emit('request_game_state', { game_id: gameStore.gameId }) } @@ -391,13 +476,14 @@ export function useWebSocket() { } }) - socketInstance.on('offensive_decision_submitted', (data) => { + state.socketInstance.on('offensive_decision_submitted', (data) => { + const s = getClientState() console.log('[WebSocket] Offensive decision submitted') gameStore.clearDecisionPrompt() // Request updated game state to get new decision_phase - if (socketInstance && gameStore.gameId) { - socketInstance.emit('request_game_state', { + if (s.socketInstance && gameStore.gameId) { + s.socketInstance.emit('request_game_state', { game_id: gameStore.gameId }) } @@ -409,7 +495,7 @@ export function useWebSocket() { // Manual Workflow Events // ======================================== - socketInstance.on('dice_rolled', (data) => { + state.socketInstance.on('dice_rolled', (data) => { console.log('[WebSocket] Dice rolled:', data.roll_id) gameStore.setPendingRoll({ roll_id: data.roll_id, @@ -426,13 +512,13 @@ export function useWebSocket() { uiStore.showInfo(data.message, 5000) }) - socketInstance.on('outcome_accepted', (data) => { + state.socketInstance.on('outcome_accepted', (data) => { console.log('[WebSocket] Outcome accepted:', data.outcome) gameStore.clearPendingRoll() uiStore.showSuccess('Outcome submitted. Resolving play...', 3000) }) - socketInstance.on('play_resolved', (data) => { + state.socketInstance.on('play_resolved', (data) => { console.log('[WebSocket] Play resolved:', data.description) // Convert to PlayResult and add to game store @@ -465,19 +551,20 @@ export function useWebSocket() { // Substitution Events // ======================================== - socketInstance.on('player_substituted', (data) => { + state.socketInstance.on('player_substituted', (data) => { + const s = getClientState() console.log('[WebSocket] Player substituted:', data.type) uiStore.showInfo(data.message, 5000) // Request updated lineup - if (socketInstance) { - socketInstance.emit('get_lineup', { + if (s.socketInstance) { + s.socketInstance.emit('get_lineup', { game_id: gameStore.gameId!, team_id: data.team_id, }) } }) - socketInstance.on('substitution_confirmed', (data) => { + state.socketInstance.on('substitution_confirmed', (data) => { console.log('[WebSocket] Substitution confirmed') uiStore.showSuccess(data.message, 5000) }) @@ -486,12 +573,12 @@ export function useWebSocket() { // Data Response Events // ======================================== - socketInstance.on('lineup_data', (data) => { + state.socketInstance.on('lineup_data', (data) => { console.log('[WebSocket] Lineup data received for team:', data.team_id) gameStore.updateLineup(data.team_id, data.players) }) - socketInstance.on('box_score_data', (data) => { + state.socketInstance.on('box_score_data', (data) => { console.log('[WebSocket] Box score data received') // Box score will be handled by dedicated component // Just log for now @@ -501,28 +588,28 @@ export function useWebSocket() { // Error Events // ======================================== - socketInstance.on('error', (data) => { + state.socketInstance.on('error', (data) => { console.error('[WebSocket] Server error:', data.message) gameStore.setError(data.message) uiStore.showError(data.message, 7000) }) - socketInstance.on('outcome_rejected', (data) => { + state.socketInstance.on('outcome_rejected', (data) => { console.error('[WebSocket] Outcome rejected:', data.message) uiStore.showError(`Invalid outcome: ${data.message}`, 7000) }) - socketInstance.on('substitution_error', (data) => { + state.socketInstance.on('substitution_error', (data) => { console.error('[WebSocket] Substitution error:', data.message) uiStore.showError(`Substitution failed: ${data.message}`, 7000) }) - socketInstance.on('invalid_action', (data) => { + state.socketInstance.on('invalid_action', (data) => { console.error('[WebSocket] Invalid action:', data.reason) uiStore.showError(`Invalid action: ${data.reason}`, 7000) }) - socketInstance.on('connection_error', (data) => { + state.socketInstance.on('connection_error', (data) => { console.error('[WebSocket] Connection error:', data.error) connectionError.value = data.error uiStore.showError(`Connection error: ${data.error}`, 7000) @@ -536,22 +623,29 @@ export function useWebSocket() { /** * Send periodic heartbeat to keep connection alive */ - let heartbeatInterval: NodeJS.Timeout | null = null - function startHeartbeat() { - if (heartbeatInterval) return + // SSR guard + if (!import.meta.client) return - heartbeatInterval = setInterval(() => { - if (socketInstance?.connected) { - socketInstance.emit('heartbeat') + const state = getClientState() + if (state.heartbeatInterval) return + + state.heartbeatInterval = setInterval(() => { + const s = getClientState() + if (s.socketInstance?.connected) { + s.socketInstance.emit('heartbeat') } }, 30000) // Every 30 seconds } function stopHeartbeat() { - if (heartbeatInterval) { - clearInterval(heartbeatInterval) - heartbeatInterval = null + // SSR guard + if (!import.meta.client) return + + const state = getClientState() + if (state.heartbeatInterval) { + clearInterval(state.heartbeatInterval) + state.heartbeatInterval = null } } @@ -560,32 +654,37 @@ export function useWebSocket() { // ============================================================================ // Watch for auth changes (not immediate - onMounted handles initial state) - watch( - () => authStore.isAuthenticated, - (authenticated, oldValue) => { - console.log('[WebSocket] Auth changed:', oldValue, '->', authenticated) - if (authenticated && !isConnected.value && !isConnecting.value) { - console.log('[WebSocket] Auth became true, connecting...') - connect() - startHeartbeat() - } else if (!authenticated && isConnected.value) { - disconnect() - stopHeartbeat() + // Only set up watcher on client + if (import.meta.client) { + watch( + () => authStore.isAuthenticated, + (authenticated, oldValue) => { + console.log('[WebSocket] Auth changed:', oldValue, '->', authenticated) + if (authenticated && !isConnected.value && !isConnecting.value) { + console.log('[WebSocket] Auth became true, connecting...') + connect() + startHeartbeat() + } else if (!authenticated && isConnected.value) { + disconnect() + stopHeartbeat() + } } - } - ) + ) + } // ============================================================================ // Stuck State Detection // ============================================================================ // Detects when auth is true but connection hasn't happened (race condition fallback) - let stuckStateCheckInterval: NodeJS.Timeout | null = null - function startStuckStateDetection() { - if (stuckStateCheckInterval) return + // SSR guard + if (!import.meta.client) return - stuckStateCheckInterval = setInterval(() => { + const state = getClientState() + if (state.stuckStateCheckInterval) return + + state.stuckStateCheckInterval = setInterval(() => { // Try to connect if not connected - don't check auth state // Backend will authenticate via cookies if (!isConnected.value && !isConnecting.value) { @@ -597,9 +696,13 @@ export function useWebSocket() { } function stopStuckStateDetection() { - if (stuckStateCheckInterval) { - clearInterval(stuckStateCheckInterval) - stuckStateCheckInterval = null + // SSR guard + if (!import.meta.client) return + + const state = getClientState() + if (state.stuckStateCheckInterval) { + clearInterval(state.stuckStateCheckInterval) + state.stuckStateCheckInterval = null } }