CLAUDE: Fix WebSocket SSR hydration and add connection debugging

Problem: WebSocket wouldn't connect on page load because Pinia auth
state doesn't automatically transfer from SSR to client hydration.
The auth store showed isAuthenticated=false on client mount.

Solution:
- useWebSocket.onMounted now proactively calls checkAuth() if not authenticated
- This ensures auth state is initialized before attempting WebSocket connection
- Added forceReconnect() function to clear stale singleton connections

Debug UI (temporary):
- Added connection status debug info to loading overlay and banner
- Shows Auth/Connected/Connecting/Error states
- Retry button triggers auth check + reconnect

🤖 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-27 23:33:11 -06:00
parent b12905a71b
commit ee12f6210e
2 changed files with 109 additions and 15 deletions

View File

@ -139,6 +139,38 @@ export function useWebSocket() {
reconnectionAttempts = 0 reconnectionAttempts = 0
} }
/**
* Force reconnect - clears the singleton and creates a fresh connection.
* Use this when the connection is in a bad state and normal reconnection isn't working.
*/
function forceReconnect() {
console.log('[WebSocket] Force reconnecting - clearing singleton')
// Clear reconnection timer
if (reconnectionTimeout) {
clearTimeout(reconnectionTimeout)
reconnectionTimeout = null
}
// Fully disconnect and destroy the socket instance
if (socketInstance) {
socketInstance.removeAllListeners()
socketInstance.disconnect()
socketInstance = null
}
// Reset all state
isConnected.value = false
isConnecting.value = false
connectionError.value = null
reconnectionAttempts = 0
// Reconnect after a short delay
setTimeout(() => {
connect()
}, 100)
}
/** /**
* Reconnect with exponential backoff * Reconnect with exponential backoff
*/ */
@ -473,8 +505,10 @@ export function useWebSocket() {
// Watch for auth changes (not immediate - onMounted handles initial state) // Watch for auth changes (not immediate - onMounted handles initial state)
watch( watch(
() => authStore.isAuthenticated, () => authStore.isAuthenticated,
(authenticated) => { (authenticated, oldValue) => {
console.log('[WebSocket] Auth changed:', oldValue, '->', authenticated)
if (authenticated && !isConnected.value && !isConnecting.value) { if (authenticated && !isConnected.value && !isConnecting.value) {
console.log('[WebSocket] Auth became true, connecting...')
connect() connect()
startHeartbeat() startHeartbeat()
} else if (!authenticated && isConnected.value) { } else if (!authenticated && isConnected.value) {
@ -489,9 +523,18 @@ export function useWebSocket() {
// ============================================================================ // ============================================================================
// Initial connection on client-side mount (handles SSR hydration case) // Initial connection on client-side mount (handles SSR hydration case)
onMounted(() => { onMounted(async () => {
console.log('[WebSocket] onMounted - isAuthenticated:', authStore.isAuthenticated)
// If not authenticated, try to check auth first (handles SSR hydration case)
if (!authStore.isAuthenticated) {
console.log('[WebSocket] Not authenticated on mount, checking auth...')
await authStore.checkAuth()
console.log('[WebSocket] After checkAuth - isAuthenticated:', authStore.isAuthenticated)
}
if (authStore.isAuthenticated && !isConnected.value && !isConnecting.value) { if (authStore.isAuthenticated && !isConnected.value && !isConnecting.value) {
console.log('[WebSocket] Auto-connecting on mount (already authenticated)') console.log('[WebSocket] Auto-connecting on mount (authenticated)')
connect() connect()
startHeartbeat() startHeartbeat()
} }
@ -519,5 +562,6 @@ export function useWebSocket() {
// Actions // Actions
connect, connect,
disconnect, disconnect,
forceReconnect,
} }
} }

View File

@ -19,18 +19,32 @@
v-if="!isConnected" v-if="!isConnected"
class="mb-4 bg-yellow-50 border-l-4 border-yellow-400 p-4 rounded-lg" class="mb-4 bg-yellow-50 border-l-4 border-yellow-400 p-4 rounded-lg"
> >
<div class="flex items-center"> <div class="flex items-center justify-between">
<div class="flex-shrink-0"> <div class="flex items-center">
<svg class="h-5 w-5 text-yellow-400 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <div class="flex-shrink-0">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/> <svg class="h-5 w-5 text-yellow-400 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
</svg> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</div> </svg>
<div class="ml-3"> </div>
<p class="text-sm text-yellow-700"> <div class="ml-3">
{{ connectionStatus === 'connecting' ? 'Connecting to game server...' : 'Disconnected from server. Attempting to reconnect...' }} <p class="text-sm text-yellow-700">
</p> {{ connectionStatus === 'connecting' ? 'Connecting to game server...' : 'Disconnected from server. Attempting to reconnect...' }}
</p>
<!-- Debug info -->
<p class="text-xs text-yellow-600 mt-1">
Auth: {{ authStore.isAuthenticated ? 'Yes' : 'No' }} |
Connecting: {{ isConnecting ? 'Yes' : 'No' }} |
Error: {{ connectionError || 'None' }}
</p>
</div>
</div> </div>
<button
@click="forceReconnect"
class="ml-4 px-3 py-1 text-sm font-medium text-yellow-700 bg-yellow-100 hover:bg-yellow-200 rounded-md transition"
>
Retry
</button>
</div> </div>
</div> </div>
@ -169,6 +183,21 @@
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-2xl text-center"> <div class="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-2xl text-center">
<div class="w-16 h-16 mx-auto mb-4 border-4 border-primary border-t-transparent rounded-full animate-spin"/> <div class="w-16 h-16 mx-auto mb-4 border-4 border-primary border-t-transparent rounded-full animate-spin"/>
<p class="text-gray-900 dark:text-white font-semibold">Loading game...</p> <p class="text-gray-900 dark:text-white font-semibold">Loading game...</p>
<!-- Debug info -->
<p class="text-xs text-gray-500 mt-4">
Auth: {{ authStore.isAuthenticated ? 'Yes' : 'No' }} |
Connected: {{ isConnected ? 'Yes' : 'No' }} |
Connecting: {{ isConnecting ? 'Yes' : 'No' }}
</p>
<p class="text-xs text-gray-500 mt-1">
Error: {{ connectionError || 'None' }}
</p>
<button
@click="retryWithAuth"
class="mt-4 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition"
>
Retry Connection
</button>
</div> </div>
</div> </div>
@ -298,7 +327,7 @@ const uiStore = useUiStore()
const gameId = computed(() => route.params.id as string) const gameId = computed(() => route.params.id as string)
// WebSocket connection // WebSocket connection
const { socket, isConnected, connectionError, connect } = useWebSocket() const { socket, isConnected, isConnecting, connectionError, connect, forceReconnect } = useWebSocket()
// Pass the raw string value from route params, not computed value // Pass the raw string value from route params, not computed value
// useGameActions will create its own computed internally if needed // useGameActions will create its own computed internally if needed
@ -571,6 +600,27 @@ const handleUndoLastPlay = () => {
undoLastPlay(1) undoLastPlay(1)
} }
// Retry connection with auth check
const retryWithAuth = async () => {
console.log('[Game Page] Retry with auth check')
isLoading.value = true
connectionStatus.value = 'connecting'
// First check auth
const isAuthed = await authStore.checkAuth()
console.log('[Game Page] Auth result:', isAuthed, 'isAuthenticated:', authStore.isAuthenticated)
if (!isAuthed) {
console.error('[Game Page] Auth failed')
isLoading.value = false
connectionStatus.value = 'disconnected'
return
}
// Force reconnect
forceReconnect()
}
// Measure ScoreBoard height dynamically // Measure ScoreBoard height dynamically
const updateScoreBoardHeight = () => { const updateScoreBoardHeight = () => {
if (scoreBoardRef.value) { if (scoreBoardRef.value) {