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