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:
parent
b68e3ceacf
commit
751dcaf972
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user