CLAUDE: Fix SSR/hydration issues in WebSocket composable

Refactored useWebSocket.ts to prevent SSR-related connection issues:

- Created ClientState interface to encapsulate all client-only state
- Added getClientState() function with lazy initialization on client only
- Added SSR guards (import.meta.client) to all connection functions
- Reset reactive state on client hydration to ensure clean slate
- Moved heartbeatInterval and stuckStateCheckInterval into ClientState
- Changed NodeJS.Timeout to ReturnType<typeof setTimeout/setInterval>
- Wrapped auth watcher in import.meta.client guard

Root cause: Module-level singleton state was being initialized during
SSR, then persisting in a potentially corrupted state during hydration.
This caused intermittent "Socket exists: no" issues where the browser
wouldn't even attempt WebSocket connections.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-11-29 15:29:24 -06:00
parent b68e3ceacf
commit 751dcaf972

View File

@ -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<ServerToClientEvents, ClientToServerEvents> | 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<ServerToClientEvents, ClientToServerEvents> | null
reconnectionAttempts: number
reconnectionTimeout: ReturnType<typeof setTimeout> | null
connectionTimeoutId: ReturnType<typeof setTimeout> | null
heartbeatInterval: ReturnType<typeof setInterval> | null
stuckStateCheckInterval: ReturnType<typeof setInterval> | 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<string | null>(null)
const lastConnectionAttempt = ref<number | null>(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,6 +654,8 @@ export function useWebSocket() {
// ============================================================================
// Watch for auth changes (not immediate - onMounted handles initial state)
// Only set up watcher on client
if (import.meta.client) {
watch(
() => authStore.isAuthenticated,
(authenticated, oldValue) => {
@ -574,18 +670,21 @@ export function useWebSocket() {
}
}
)
}
// ============================================================================
// 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
}
}