From 37d4de05ec8b944ed5ba65abaf0e542a81aa1538 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 22:39:06 +0000 Subject: [PATCH 1/8] Fix iOS connection loss: track WebSocket state and show reconnecting overlay When iOS Safari backgrounds the app, the OS silently kills the WebSocket connection. Previously the UI stayed fully interactive while changes silently failed to sync. This adds real-time WebSocket connection state tracking via graphql-ws on.connected/on.closed callbacks, a visibilitychange handler to proactively detect dead sockets on iOS foreground return, and a "Reconnecting..." overlay on the queue control bar that blocks interaction until the connection recovers. https://claude.ai/code/session_016bdrJ2mAmjzSmZ2z6vWGN9 --- .../components/graphql-queue/QueueContext.tsx | 6 ++- .../graphql-queue/graphql-client.ts | 5 ++- .../persistent-session-context.tsx | 41 +++++++++++++++++++ .../queue-control/queue-bridge-context.tsx | 2 + .../queue-control-bar.module.css | 13 ++++++ .../queue-control/queue-control-bar.tsx | 18 +++++++- .../web/app/components/queue-control/types.ts | 1 + 7 files changed, 83 insertions(+), 3 deletions(-) diff --git a/packages/web/app/components/graphql-queue/QueueContext.tsx b/packages/web/app/components/graphql-queue/QueueContext.tsx index daab6ac5..d7fe0f99 100644 --- a/packages/web/app/components/graphql-queue/QueueContext.tsx +++ b/packages/web/app/components/graphql-queue/QueueContext.tsx @@ -414,6 +414,7 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children, bas const clientId = isPersistentSessionActive ? persistentSession.clientId : null; const isLeader = isPersistentSessionActive ? persistentSession.isLeader : false; const hasConnected = isPersistentSessionActive ? persistentSession.hasConnected : false; + const isReconnecting = isPersistentSessionActive ? persistentSession.isReconnecting : false; // Memoize users array to prevent unnecessary context value recreation // Note: persistentSession.users is already stable from the persistent session context const users = useMemo( @@ -614,8 +615,9 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children, bas if (!sessionId) return false; // No session = local mode, not view-only if (!backendUrl) return false; // No backend = no view-only mode if (!hasConnected) return true; // Still connecting = view-only + if (isReconnecting) return true; // WebSocket dropped, disable until reconnected return false; // Once connected, everyone can modify the queue - }, [sessionId, backendUrl, hasConnected]); + }, [sessionId, backendUrl, hasConnected, isReconnecting]); const contextValue: GraphQLQueueContextType = useMemo( () => ({ @@ -649,6 +651,7 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children, bas isLeader, isBackendMode: !!backendUrl, hasConnected, + isReconnecting, connectionError, disconnect: persistentSession.deactivateSession, @@ -843,6 +846,7 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children, bas isLeader, users, hasConnected, + isReconnecting, connectionError, backendUrl, persistentSession, diff --git a/packages/web/app/components/graphql-queue/graphql-client.ts b/packages/web/app/components/graphql-queue/graphql-client.ts index b1b4214e..9f82fa30 100644 --- a/packages/web/app/components/graphql-queue/graphql-client.ts +++ b/packages/web/app/components/graphql-queue/graphql-client.ts @@ -33,6 +33,7 @@ export interface GraphQLClientOptions { url: string; authToken?: string | null; onReconnect?: () => void; + onConnectionStateChange?: (connected: boolean) => void; } /** @@ -53,7 +54,7 @@ export function createGraphQLClient( ? { url: urlOrOptions, onReconnect } : urlOrOptions; - const { url, authToken, onReconnect: onReconnectCallback } = options; + const { url, authToken, onReconnect: onReconnectCallback, onConnectionStateChange } = options; const clientId = ++clientCounter; @@ -83,6 +84,7 @@ export function createGraphQLClient( on: { connected: () => { if (DEBUG) console.log(`[GraphQL] Client #${clientId} connected (first: ${!hasConnectedOnce})`); + onConnectionStateChange?.(true); if (hasConnectedOnce && onReconnectCallback) { if (DEBUG) console.log(`[GraphQL] Client #${clientId} reconnected, calling onReconnect`); onReconnectCallback(); @@ -91,6 +93,7 @@ export function createGraphQLClient( }, closed: (event) => { if (DEBUG) console.log(`[GraphQL] Client #${clientId} closed`, event); + onConnectionStateChange?.(false); }, error: (error) => { if (DEBUG) console.log(`[GraphQL] Client #${clientId} error`, error); diff --git a/packages/web/app/components/persistent-session/persistent-session-context.tsx b/packages/web/app/components/persistent-session/persistent-session-context.tsx index 4381a8d5..a22f117a 100644 --- a/packages/web/app/components/persistent-session/persistent-session-context.tsx +++ b/packages/web/app/components/persistent-session/persistent-session-context.tsx @@ -212,6 +212,9 @@ export interface PersistentSessionContextType { // Trigger a resync with the server (useful when corrupted data is detected) triggerResync: () => void; + // True when we had a working connection but the WebSocket dropped + isReconnecting: boolean; + // Session ending with summary (elevated from GraphQLQueueProvider) endSessionWithSummary: () => void; liveSessionStats: SessionLiveStats | null; @@ -252,6 +255,7 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> const [session, setSession] = useState(null); const [isConnecting, setIsConnecting] = useState(false); const [hasConnected, setHasConnected] = useState(false); + const [isWebSocketConnected, setIsWebSocketConnected] = useState(false); const [error, setError] = useState(null); // Queue state synced from backend @@ -793,6 +797,11 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> // Use ref for auth token - it's set once auth loading completes authToken: wsAuthTokenRef.current, onReconnect: handleReconnect, + onConnectionStateChange: (connected) => { + if (mountedRef.current && connectionGenerationRef.current === connectionGeneration) { + setIsWebSocketConnected(connected); + } + }, }); if (!mountedRef.current) { @@ -971,6 +980,7 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> setClient(null); setSession(null); setHasConnected(false); + setIsWebSocketConnected(false); setIsConnecting(false); if (retryConnectTimeout) { clearTimeout(retryConnectTimeout); @@ -979,6 +989,32 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> // Note: username, avatarUrl, wsAuthToken are accessed via refs to prevent reconnection on changes }, [activeSession, isAuthLoading, handleQueueEvent, handleSessionEvent]); + // Proactive reconnection detection on iOS foreground return + // When the browser comes back from background, trigger a resync to detect dead sockets + useEffect(() => { + if (!activeSession || !hasConnected) return; + + let debounceTimer: ReturnType | null = null; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + if (triggerResyncRef.current) { + if (DEBUG) console.log('[PersistentSession] Page became visible, triggering resync'); + triggerResyncRef.current(); + } + }, 300); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + if (debounceTimer) clearTimeout(debounceTimer); + }; + }, [activeSession, hasConnected]); + // Periodic state hash verification // Runs every 60 seconds to detect state drift and auto-resync if needed useEffect(() => { @@ -1272,6 +1308,9 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> } }, [activeSession, deactivateSession]); + // Derived: true when we previously had a working connection but the WebSocket dropped + const isReconnecting = hasConnected && !isWebSocketConnected; + const value = useMemo( () => ({ activeSession, @@ -1303,6 +1342,7 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> subscribeToQueueEvents, subscribeToSessionEvents, triggerResync, + isReconnecting, endSessionWithSummary, liveSessionStats, sessionSummary, @@ -1335,6 +1375,7 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> subscribeToQueueEvents, subscribeToSessionEvents, triggerResync, + isReconnecting, endSessionWithSummary, liveSessionStats, sessionSummary, diff --git a/packages/web/app/components/queue-control/queue-bridge-context.tsx b/packages/web/app/components/queue-control/queue-bridge-context.tsx index b300bc23..db56c177 100644 --- a/packages/web/app/components/queue-control/queue-bridge-context.tsx +++ b/packages/web/app/components/queue-control/queue-bridge-context.tsx @@ -227,6 +227,7 @@ function usePersistentSessionQueueAdapter(): { isLeader: ps.isLeader, isBackendMode: true, hasConnected: ps.hasConnected, + isReconnecting: isParty ? ps.isReconnecting : false, connectionError: ps.error, disconnect: ps.deactivateSession, @@ -248,6 +249,7 @@ function usePersistentSessionQueueAdapter(): { parsedParams, isParty, ps.hasConnected, + ps.isReconnecting, ps.activeSession?.sessionId, ps.deactivateSession, ps.session?.goal, diff --git a/packages/web/app/components/queue-control/queue-control-bar.module.css b/packages/web/app/components/queue-control/queue-control-bar.module.css index 4661eefa..7582bd9d 100644 --- a/packages/web/app/components/queue-control/queue-control-bar.module.css +++ b/packages/web/app/components/queue-control/queue-control-bar.module.css @@ -159,3 +159,16 @@ .thumbnailEnter { animation: thumbnailFadeIn 120ms ease-out; } + +.reconnectingOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + pointer-events: all; +} diff --git a/packages/web/app/components/queue-control/queue-control-bar.tsx b/packages/web/app/components/queue-control/queue-control-bar.tsx index e20cf1c4..a6afdfa1 100644 --- a/packages/web/app/components/queue-control/queue-control-bar.tsx +++ b/packages/web/app/components/queue-control/queue-control-bar.tsx @@ -6,6 +6,8 @@ import MuiCard from '@mui/material/Card'; import CardContent from '@mui/material/CardContent'; import Box from '@mui/material/Box'; import Stack from '@mui/material/Stack'; +import CircularProgress from '@mui/material/CircularProgress'; +import Typography from '@mui/material/Typography'; import SwipeableDrawer from '../swipeable-drawer/swipeable-drawer'; import SyncOutlined from '@mui/icons-material/SyncOutlined'; import DeleteOutlined from '@mui/icons-material/DeleteOutlined'; @@ -88,7 +90,7 @@ const QueueControlBar: React.FC = ({ boardDetails, angle } const isViewPage = pathname.includes('/view/'); const isListPage = pathname.includes('/list'); const isPlayPage = pathname.includes('/play/'); - const { currentClimb, mirrorClimb, queue, setQueue, getNextClimbQueueItem, getPreviousClimbQueueItem, setCurrentClimbQueueItem, viewOnlyMode } = useQueueContext(); + const { currentClimb, mirrorClimb, queue, setQueue, getNextClimbQueueItem, getPreviousClimbQueueItem, setCurrentClimbQueueItem, viewOnlyMode, isReconnecting } = useQueueContext(); const { mode } = useColorMode(); const isDark = mode === 'dark'; @@ -396,6 +398,20 @@ const QueueControlBar: React.FC = ({ boardDetails, angle } + {/* Reconnecting overlay — blocks interaction while WebSocket is down */} + {isReconnecting && ( + + + + + Reconnecting... + + + + )} diff --git a/packages/web/app/components/queue-control/types.ts b/packages/web/app/components/queue-control/types.ts index bb417377..671a6f68 100644 --- a/packages/web/app/components/queue-control/types.ts +++ b/packages/web/app/components/queue-control/types.ts @@ -78,6 +78,7 @@ export interface QueueContextType { isLeader?: boolean; isBackendMode?: boolean; hasConnected?: boolean; + isReconnecting?: boolean; connectionError?: Error | null; disconnect?: () => void; addToQueue: (climb: Climb) => void; From 5a07280d6f3bdfe64fb226a9be62f6fef757a155 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Mar 2026 12:24:18 +0000 Subject: [PATCH 2/8] Fix reconnect lock: defer isWebSocketConnected until resync completes The on.connected callback was setting isWebSocketConnected=true immediately, before handleReconnect could finish its async joinSession/delta-sync path. This caused isReconnecting to become false prematurely, re-enabling queue mutations while stale state was still being replayed. Fix: onConnectionStateChange now receives an isReconnect flag. On reconnections, the persistent session defers setting isWebSocketConnected=true until handleReconnect's finally block, keeping the reconnecting overlay and viewOnlyMode active throughout the entire resync. Also adds iOS background connection loss documentation to docs/websocket-implementation.md (section 7 under Failure States). https://claude.ai/code/session_016bdrJ2mAmjzSmZ2z6vWGN9 --- docs/websocket-implementation.md | 69 ++++++++++++++++++- .../graphql-queue/graphql-client.ts | 11 +-- .../persistent-session-context.tsx | 14 +++- 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/docs/websocket-implementation.md b/docs/websocket-implementation.md index 6d2464c1..28ebc8ef 100644 --- a/docs/websocket-implementation.md +++ b/docs/websocket-implementation.md @@ -796,7 +796,74 @@ sequenceDiagram - `isFilteringCorruptedItemsRef` prevents useEffect re-trigger loops - `lastCorruptionResyncRef` tracks cooldown timing -### 7. Subscription Error / Complete +### 7. iOS Background Connection Loss + +iOS Safari silently kills WebSocket connections when the browser is backgrounded. The `graphql-ws` library's keepalive (10s) eventually detects the dead socket, but during the detection delay the UI would remain interactive while mutations silently fail. This section describes the proactive detection and UI feedback mechanism. + +```mermaid +sequenceDiagram + participant U as User + participant UI as Queue Control Bar + participant PS as PersistentSession + participant GC as graphql-ws Client + participant WS as WebSocket + + Note over U,WS: User backgrounds iOS Safari + + WS--xGC: OS kills socket silently + GC->>PS: on.closed → onConnectionStateChange(false) + PS->>PS: setIsWebSocketConnected(false) + PS->>PS: isReconnecting = hasConnected && !isWebSocketConnected + PS->>UI: isReconnecting = true + UI->>U: Show "Reconnecting..." overlay, disable interactions + + Note over U,WS: User returns to app + + U->>PS: visibilitychange → "visible" + PS->>PS: Debounce 300ms + PS->>PS: triggerResync() + + Note over GC: graphql-ws detects dead socket, retries + + GC->>WS: Reconnect + WS-->>GC: Connected + GC->>PS: on.connected → onConnectionStateChange(true, isReconnect=true) + PS->>PS: Skip setIsWebSocketConnected(true) — defer to handleReconnect + GC->>PS: onReconnect → handleReconnect() + PS->>WS: joinSession + delta/full sync + WS-->>PS: Synced state + PS->>PS: setIsWebSocketConnected(true) + PS->>PS: isReconnecting = false + PS->>UI: isReconnecting = false + UI->>U: Remove overlay, re-enable interactions +``` + +**Key mechanisms:** + +1. **Real-time WebSocket state tracking**: The `graphql-ws` client reports `connected`/`closed` events via `onConnectionStateChange`. The `isWebSocketConnected` state tracks the transport layer status. + +2. **Reconnect lock**: On reconnection, `onConnectionStateChange(true, isReconnect=true)` is deferred — the persistent session does NOT set `isWebSocketConnected=true` until `handleReconnect` completes its async resync (joinSession + delta/full sync). This prevents the UI from re-enabling mutations while stale state is still being replayed. + +3. **`visibilitychange` handler**: When the page becomes visible during an active session, a debounced (300ms) resync is triggered. This proactively detects dead sockets by attempting to use the connection, rather than waiting for the next keepalive ping. + +4. **Reconnecting overlay**: An absolutely-positioned overlay inside the queue control bar's `swipeWrapper` (which has `position: relative; overflow: hidden`) blocks all touch/click interaction with `pointer-events: all`. Uses `--semantic-surface-overlay` for theme-aware semi-transparent background. + +**Derivation:** +```typescript +const isReconnecting = hasConnected && !isWebSocketConnected; +``` + +- `hasConnected` = "session was successfully joined at least once" +- `isWebSocketConnected` = "transport is currently connected AND resync is complete" +- During initial connection: `hasConnected=false` → `isReconnecting=false` (handled by existing `viewOnlyMode`) +- During reconnection resync: `isWebSocketConnected` stays `false` until `handleReconnect` finishes + +**Context threading:** +- `PersistentSessionContextType.isReconnecting` → `GraphQLQueueContextType.isReconnecting` → `QueueContextType.isReconnecting` +- Also incorporated into `viewOnlyMode`: `if (isReconnecting) return true` +- Bridge adapter passes `isReconnecting: isParty ? ps.isReconnecting : false` (local queue unaffected) + +### 8. Subscription Error / Complete ```mermaid sequenceDiagram diff --git a/packages/web/app/components/graphql-queue/graphql-client.ts b/packages/web/app/components/graphql-queue/graphql-client.ts index 9f82fa30..71166597 100644 --- a/packages/web/app/components/graphql-queue/graphql-client.ts +++ b/packages/web/app/components/graphql-queue/graphql-client.ts @@ -33,7 +33,7 @@ export interface GraphQLClientOptions { url: string; authToken?: string | null; onReconnect?: () => void; - onConnectionStateChange?: (connected: boolean) => void; + onConnectionStateChange?: (connected: boolean, isReconnect: boolean) => void; } /** @@ -83,9 +83,10 @@ export function createGraphQLClient( connectionParams: authToken ? { authToken } : undefined, on: { connected: () => { - if (DEBUG) console.log(`[GraphQL] Client #${clientId} connected (first: ${!hasConnectedOnce})`); - onConnectionStateChange?.(true); - if (hasConnectedOnce && onReconnectCallback) { + const isReconnect = hasConnectedOnce; + if (DEBUG) console.log(`[GraphQL] Client #${clientId} connected (first: ${!isReconnect})`); + onConnectionStateChange?.(true, isReconnect); + if (isReconnect && onReconnectCallback) { if (DEBUG) console.log(`[GraphQL] Client #${clientId} reconnected, calling onReconnect`); onReconnectCallback(); } @@ -93,7 +94,7 @@ export function createGraphQLClient( }, closed: (event) => { if (DEBUG) console.log(`[GraphQL] Client #${clientId} closed`, event); - onConnectionStateChange?.(false); + onConnectionStateChange?.(false, false); }, error: (error) => { if (DEBUG) console.log(`[GraphQL] Client #${clientId} error`, error); diff --git a/packages/web/app/components/persistent-session/persistent-session-context.tsx b/packages/web/app/components/persistent-session/persistent-session-context.tsx index a22f117a..d6ada7ad 100644 --- a/packages/web/app/components/persistent-session/persistent-session-context.tsx +++ b/packages/web/app/components/persistent-session/persistent-session-context.tsx @@ -760,6 +760,12 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> if (DEBUG) console.log('[PersistentSession] Reconnection complete, clientId:', sessionData.clientId); } finally { isReconnectingRef.current = false; + // Mark WebSocket as connected now that resync is complete (or failed). + // This was deferred from the on.connected callback to keep isReconnecting=true + // until state is fully recovered, preventing mutations on stale state. + if (mountedRef.current && connectionGenerationRef.current === connectionGeneration) { + setIsWebSocketConnected(true); + } } } @@ -797,8 +803,14 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> // Use ref for auth token - it's set once auth loading completes authToken: wsAuthTokenRef.current, onReconnect: handleReconnect, - onConnectionStateChange: (connected) => { + onConnectionStateChange: (connected, isReconnect) => { if (mountedRef.current && connectionGenerationRef.current === connectionGeneration) { + if (connected && isReconnect) { + // On reconnection, don't set isWebSocketConnected=true yet. + // handleReconnect will set it after the async resync completes, + // keeping isReconnecting=true until state is fully recovered. + return; + } setIsWebSocketConnected(connected); } }, From e2cc65a7d0d1ffc909e8b4fb401991d4f74f5437 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Mar 2026 20:00:03 +0000 Subject: [PATCH 3/8] Add tests for connection state tracking and reconnect lock - graphql-client.test.ts: Tests onConnectionStateChange callback fires with correct (connected, isReconnect) flags across initial connect, disconnect, and reconnection sequences. Verifies callback ordering (stateChange fires before onReconnect). - connection-state.test.ts: Tests isReconnecting derivation truth table, reconnect lock timing (isReconnecting stays true during resync), and visibilitychange debounce behavior (300ms delay, rapid-fire cancellation, hidden state ignored). https://claude.ai/code/session_016bdrJ2mAmjzSmZ2z6vWGN9 --- .../__tests__/graphql-client.test.ts | 147 +++++++++++++++ .../__tests__/connection-state.test.ts | 171 ++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 packages/web/app/components/graphql-queue/__tests__/graphql-client.test.ts create mode 100644 packages/web/app/components/persistent-session/__tests__/connection-state.test.ts diff --git a/packages/web/app/components/graphql-queue/__tests__/graphql-client.test.ts b/packages/web/app/components/graphql-queue/__tests__/graphql-client.test.ts new file mode 100644 index 00000000..c37195a4 --- /dev/null +++ b/packages/web/app/components/graphql-queue/__tests__/graphql-client.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Capture the `on` handlers passed to createClient so we can invoke them in tests +let capturedOnHandlers: { + connected?: () => void; + closed?: (event: unknown) => void; + error?: (error: unknown) => void; +} = {}; + +const mockDispose = vi.fn(); +const mockSubscribe = vi.fn(); + +vi.mock('graphql-ws', () => ({ + createClient: vi.fn((options: { on?: typeof capturedOnHandlers }) => { + capturedOnHandlers = options.on || {}; + return { + subscribe: mockSubscribe, + dispose: mockDispose, + }; + }), +})); + +import { createGraphQLClient } from '../graphql-client'; + +describe('createGraphQLClient', () => { + beforeEach(() => { + capturedOnHandlers = {}; + vi.clearAllMocks(); + }); + + describe('onConnectionStateChange callback', () => { + it('calls onConnectionStateChange(true, false) on first connection', () => { + const onConnectionStateChange = vi.fn(); + createGraphQLClient({ + url: 'ws://test', + onConnectionStateChange, + }); + + capturedOnHandlers.connected!(); + + expect(onConnectionStateChange).toHaveBeenCalledTimes(1); + expect(onConnectionStateChange).toHaveBeenCalledWith(true, false); + }); + + it('calls onConnectionStateChange(true, true) on reconnection', () => { + const onConnectionStateChange = vi.fn(); + createGraphQLClient({ + url: 'ws://test', + onConnectionStateChange, + }); + + // First connection + capturedOnHandlers.connected!(); + onConnectionStateChange.mockClear(); + + // Reconnection + capturedOnHandlers.connected!(); + + expect(onConnectionStateChange).toHaveBeenCalledTimes(1); + expect(onConnectionStateChange).toHaveBeenCalledWith(true, true); + }); + + it('calls onConnectionStateChange(false, false) on close', () => { + const onConnectionStateChange = vi.fn(); + createGraphQLClient({ + url: 'ws://test', + onConnectionStateChange, + }); + + capturedOnHandlers.closed!({ code: 1000 }); + + expect(onConnectionStateChange).toHaveBeenCalledTimes(1); + expect(onConnectionStateChange).toHaveBeenCalledWith(false, false); + }); + + it('does not call onReconnect on first connection', () => { + const onReconnect = vi.fn(); + createGraphQLClient({ + url: 'ws://test', + onReconnect, + }); + + capturedOnHandlers.connected!(); + + expect(onReconnect).not.toHaveBeenCalled(); + }); + + it('calls onReconnect on subsequent connections', () => { + const onReconnect = vi.fn(); + createGraphQLClient({ + url: 'ws://test', + onReconnect, + }); + + // First connection + capturedOnHandlers.connected!(); + expect(onReconnect).not.toHaveBeenCalled(); + + // Reconnection + capturedOnHandlers.connected!(); + expect(onReconnect).toHaveBeenCalledTimes(1); + }); + + it('fires onConnectionStateChange before onReconnect on reconnection', () => { + const callOrder: string[] = []; + const onConnectionStateChange = vi.fn(() => callOrder.push('stateChange')); + const onReconnect = vi.fn(() => callOrder.push('reconnect')); + + createGraphQLClient({ + url: 'ws://test', + onConnectionStateChange, + onReconnect, + }); + + // First connection + capturedOnHandlers.connected!(); + callOrder.length = 0; + + // Reconnection — stateChange should fire before reconnect + capturedOnHandlers.connected!(); + + expect(callOrder).toEqual(['stateChange', 'reconnect']); + }); + + it('reports correct sequence: connected → closed → reconnected', () => { + const calls: Array<[boolean, boolean]> = []; + const onConnectionStateChange = vi.fn( + (connected: boolean, isReconnect: boolean) => calls.push([connected, isReconnect]), + ); + + createGraphQLClient({ + url: 'ws://test', + onConnectionStateChange, + }); + + capturedOnHandlers.connected!(); // initial connect + capturedOnHandlers.closed!({}); // socket drops + capturedOnHandlers.connected!(); // reconnect + + expect(calls).toEqual([ + [true, false], // initial connect + [false, false], // socket drops + [true, true], // reconnect + ]); + }); + }); +}); diff --git a/packages/web/app/components/persistent-session/__tests__/connection-state.test.ts b/packages/web/app/components/persistent-session/__tests__/connection-state.test.ts new file mode 100644 index 00000000..31d26ea6 --- /dev/null +++ b/packages/web/app/components/persistent-session/__tests__/connection-state.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** + * Tests for the isReconnecting derivation logic and visibilitychange handler. + * + * The actual derivation lives in PersistentSessionProvider as: + * const isReconnecting = hasConnected && !isWebSocketConnected; + * + * These tests verify the derivation truth table and the visibilitychange + * debounce behavior as isolated logic (no full provider rendering needed). + */ + +describe('isReconnecting derivation', () => { + // Pure logic extracted from the provider — tests the truth table + function deriveIsReconnecting(hasConnected: boolean, isWebSocketConnected: boolean): boolean { + return hasConnected && !isWebSocketConnected; + } + + it('is false during initial connection (hasConnected=false)', () => { + // Before joinSession succeeds, hasConnected is false + expect(deriveIsReconnecting(false, false)).toBe(false); + }); + + it('is false when first connected (hasConnected=true, ws=true)', () => { + // After successful initial connection + expect(deriveIsReconnecting(true, true)).toBe(false); + }); + + it('is true when socket drops after established connection', () => { + // hasConnected=true (we were connected), isWebSocketConnected=false (socket dropped) + expect(deriveIsReconnecting(true, false)).toBe(true); + }); + + it('is false when socket reconnects and resync completes', () => { + // Both true — connection is healthy again + expect(deriveIsReconnecting(true, true)).toBe(false); + }); + + it('is false during initial auth loading (neither connected)', () => { + expect(deriveIsReconnecting(false, true)).toBe(false); + }); +}); + +describe('reconnect lock timing', () => { + /** + * Simulates the state transitions during reconnection to verify + * that isReconnecting stays true until handleReconnect completes. + * + * The key invariant: on.connected fires onConnectionStateChange(true, isReconnect=true) + * but the persistent session SKIPS setting isWebSocketConnected=true. Only + * handleReconnect's finally block sets it after resync. + */ + + it('keeps isReconnecting=true during reconnection resync', () => { + let isWebSocketConnected = true; + let hasConnected = true; + const derive = () => hasConnected && !isWebSocketConnected; + + // Initial state: connected + expect(derive()).toBe(false); + + // Socket drops: on.closed fires + isWebSocketConnected = false; + expect(derive()).toBe(true); + + // Socket reconnects: on.connected fires with isReconnect=true + // The callback SKIPS setting isWebSocketConnected=true (deferred) + // isWebSocketConnected stays false during resync + expect(derive()).toBe(true); + + // handleReconnect completes resync: finally block sets isWebSocketConnected=true + isWebSocketConnected = true; + expect(derive()).toBe(false); + }); + + it('sets isWebSocketConnected=true on initial connect (not deferred)', () => { + let isWebSocketConnected = false; + let hasConnected = false; + const derive = () => hasConnected && !isWebSocketConnected; + + // on.connected fires with isReconnect=false — sets immediately + isWebSocketConnected = true; + expect(derive()).toBe(false); + + // joinSession succeeds + hasConnected = true; + expect(derive()).toBe(false); + }); +}); + +describe('visibilitychange handler', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('debounces resync trigger with 300ms delay', () => { + const triggerResync = vi.fn(); + let debounceTimer: ReturnType | null = null; + + // Simulate the handler logic from persistent-session-context.tsx + const handleVisibilityChange = (visibilityState: string) => { + if (visibilityState === 'visible') { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + triggerResync(); + }, 300); + } + }; + + handleVisibilityChange('visible'); + + // Before debounce expires + expect(triggerResync).not.toHaveBeenCalled(); + + // After 300ms + vi.advanceTimersByTime(300); + expect(triggerResync).toHaveBeenCalledTimes(1); + }); + + it('cancels previous debounce on rapid visibility changes', () => { + const triggerResync = vi.fn(); + let debounceTimer: ReturnType | null = null; + + const handleVisibilityChange = (visibilityState: string) => { + if (visibilityState === 'visible') { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + triggerResync(); + }, 300); + } + }; + + // Rapid background/foreground cycling + handleVisibilityChange('visible'); + vi.advanceTimersByTime(100); + handleVisibilityChange('visible'); + vi.advanceTimersByTime(100); + handleVisibilityChange('visible'); + + // Only 200ms since last call — no trigger yet + vi.advanceTimersByTime(200); + expect(triggerResync).not.toHaveBeenCalled(); + + // 300ms since last call — triggers once + vi.advanceTimersByTime(100); + expect(triggerResync).toHaveBeenCalledTimes(1); + }); + + it('does not trigger on hidden visibility state', () => { + const triggerResync = vi.fn(); + let debounceTimer: ReturnType | null = null; + + const handleVisibilityChange = (visibilityState: string) => { + if (visibilityState === 'visible') { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + triggerResync(); + }, 300); + } + }; + + handleVisibilityChange('hidden'); + vi.advanceTimersByTime(500); + + expect(triggerResync).not.toHaveBeenCalled(); + }); +}); From 91f9c7a836e441a2bd7de74e7247c4231f22c578 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Mar 2026 20:04:41 +0000 Subject: [PATCH 4/8] Replace blocking reconnecting overlay with non-blocking MUI Snackbar The full-page overlay blocked all user interaction. Replace it with a bottom-center Snackbar/Alert that shows "Reconnecting..." with a spinner and automatically disappears when the connection is re-established. Queue mutations are still prevented by viewOnlyMode during reconnection. https://claude.ai/code/session_016bdrJ2mAmjzSmZ2z6vWGN9 --- docs/websocket-implementation.md | 6 ++-- .../queue-control-bar.module.css | 12 ------- .../queue-control/queue-control-bar.tsx | 31 ++++++++++--------- 3 files changed, 19 insertions(+), 30 deletions(-) diff --git a/docs/websocket-implementation.md b/docs/websocket-implementation.md index 28ebc8ef..6819f7ca 100644 --- a/docs/websocket-implementation.md +++ b/docs/websocket-implementation.md @@ -815,7 +815,7 @@ sequenceDiagram PS->>PS: setIsWebSocketConnected(false) PS->>PS: isReconnecting = hasConnected && !isWebSocketConnected PS->>UI: isReconnecting = true - UI->>U: Show "Reconnecting..." overlay, disable interactions + UI->>U: Show "Reconnecting..." Snackbar notification Note over U,WS: User returns to app @@ -835,7 +835,7 @@ sequenceDiagram PS->>PS: setIsWebSocketConnected(true) PS->>PS: isReconnecting = false PS->>UI: isReconnecting = false - UI->>U: Remove overlay, re-enable interactions + UI->>U: Snackbar auto-dismisses, mutations re-enabled ``` **Key mechanisms:** @@ -846,7 +846,7 @@ sequenceDiagram 3. **`visibilitychange` handler**: When the page becomes visible during an active session, a debounced (300ms) resync is triggered. This proactively detects dead sockets by attempting to use the connection, rather than waiting for the next keepalive ping. -4. **Reconnecting overlay**: An absolutely-positioned overlay inside the queue control bar's `swipeWrapper` (which has `position: relative; overflow: hidden`) blocks all touch/click interaction with `pointer-events: all`. Uses `--semantic-surface-overlay` for theme-aware semi-transparent background. +4. **Reconnecting notification**: A MUI `Snackbar` with an `Alert` (severity `info`) renders at the bottom-center of the screen while `isReconnecting` is true. The notification is non-blocking — users can still interact with the UI, but queue mutations are prevented by `viewOnlyMode`. The Snackbar automatically disappears when the connection is re-established (`isReconnecting` becomes false). **Derivation:** ```typescript diff --git a/packages/web/app/components/queue-control/queue-control-bar.module.css b/packages/web/app/components/queue-control/queue-control-bar.module.css index 7582bd9d..4fa9d5d3 100644 --- a/packages/web/app/components/queue-control/queue-control-bar.module.css +++ b/packages/web/app/components/queue-control/queue-control-bar.module.css @@ -160,15 +160,3 @@ animation: thumbnailFadeIn 120ms ease-out; } -.reconnectingOverlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: center; - z-index: 1; - pointer-events: all; -} diff --git a/packages/web/app/components/queue-control/queue-control-bar.tsx b/packages/web/app/components/queue-control/queue-control-bar.tsx index a6afdfa1..3705d456 100644 --- a/packages/web/app/components/queue-control/queue-control-bar.tsx +++ b/packages/web/app/components/queue-control/queue-control-bar.tsx @@ -6,8 +6,9 @@ import MuiCard from '@mui/material/Card'; import CardContent from '@mui/material/CardContent'; import Box from '@mui/material/Box'; import Stack from '@mui/material/Stack'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; import CircularProgress from '@mui/material/CircularProgress'; -import Typography from '@mui/material/Typography'; import SwipeableDrawer from '../swipeable-drawer/swipeable-drawer'; import SyncOutlined from '@mui/icons-material/SyncOutlined'; import DeleteOutlined from '@mui/icons-material/DeleteOutlined'; @@ -398,20 +399,6 @@ const QueueControlBar: React.FC = ({ boardDetails, angle } - {/* Reconnecting overlay — blocks interaction while WebSocket is down */} - {isReconnecting && ( - - - - - Reconnecting... - - - - )} @@ -449,6 +436,20 @@ const QueueControlBar: React.FC = ({ boardDetails, angle } boardDetails={boardDetails} angle={angle} /> + + {/* Non-blocking notification while WebSocket reconnects */} + + } + > + Reconnecting... + + ); }; From c39331293afefbd9ee43f120288cf6957b66a884 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Mar 2026 20:05:17 +0000 Subject: [PATCH 5/8] Update package-lock.json after npm install https://claude.ai/code/session_016bdrJ2mAmjzSmZ2z6vWGN9 --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index dc9ac208..771d0b84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5178,7 +5178,7 @@ "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.58.2" @@ -8627,7 +8627,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -11748,7 +11748,7 @@ "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.58.2" @@ -11767,7 +11767,7 @@ "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" From dd0b4f673ad3f1da9f45d280c524edac8aef61d5 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 4 Mar 2026 10:42:45 +0100 Subject: [PATCH 6/8] Fix infinite spinner when joining sessions Break the BoardSessionBridge reactivation loop that caused infinite connect/disconnect cycles after join failures. Track failed session IDs so the bridge doesn't re-activate a session that was just cleared. Also harden getSessionMembers() against partial Redis data (missing username would violate String! schema constraint) and add production-safe logging to the joinSession resolver. Co-Authored-By: Claude Opus 4.6 --- .../graphql/resolvers/sessions/mutations.ts | 135 +++++++++--------- .../backend/src/services/distributed-state.ts | 24 +++- packages/backend/src/websocket/setup.ts | 7 +- .../board-session-bridge.tsx | 29 ++++ 4 files changed, 122 insertions(+), 73 deletions(-) diff --git a/packages/backend/src/graphql/resolvers/sessions/mutations.ts b/packages/backend/src/graphql/resolvers/sessions/mutations.ts index 40a146fd..c85b01c4 100644 --- a/packages/backend/src/graphql/resolvers/sessions/mutations.ts +++ b/packages/backend/src/graphql/resolvers/sessions/mutations.ts @@ -65,76 +65,81 @@ export const sessionMutations = { }, ctx: ConnectionContext ) => { - if (DEBUG) console.log(`[joinSession] START - connectionId: ${ctx.connectionId}, sessionId: ${sessionId}, username: ${username}, sessionName: ${sessionName}, initialQueueLength: ${initialQueue?.length || 0}`); + console.log(`[joinSession] connectionId: ${ctx.connectionId.slice(0, 8)}, sessionId: ${sessionId.slice(0, 8)}, authenticated: ${ctx.isAuthenticated}`); - await applyRateLimit(ctx, 10); // Limit session joins to prevent abuse + try { + await applyRateLimit(ctx, 10); // Limit session joins to prevent abuse - // Validate inputs - validateInput(SessionIdSchema, sessionId, 'sessionId'); - validateInput(BoardPathSchema, boardPath, 'boardPath'); - if (username) validateInput(UsernameSchema, username, 'username'); - if (avatarUrl) validateInput(AvatarUrlSchema, avatarUrl, 'avatarUrl'); - if (sessionName) validateInput(SessionNameSchema, sessionName, 'sessionName'); - if (initialQueue) validateInput(QueueArraySchema, initialQueue, 'initialQueue'); - if (initialCurrentClimb) validateInput(ClimbQueueItemSchema, initialCurrentClimb, 'initialCurrentClimb'); - - const result = await roomManager.joinSession( - ctx.connectionId, - sessionId, - boardPath, - username || undefined, - avatarUrl || undefined, - initialQueue, - initialCurrentClimb || null, - sessionName || undefined - ); - if (DEBUG) console.log(`[joinSession] roomManager.joinSession completed - clientId: ${result.clientId}, isLeader: ${result.isLeader}`); - - // Update context with session info - if (DEBUG) console.log(`[joinSession] Before updateContext - ctx.sessionId: ${ctx.sessionId}`); - updateContext(ctx.connectionId, { sessionId, userId: result.clientId }); - if (DEBUG) console.log(`[joinSession] After updateContext - ctx.sessionId: ${ctx.sessionId}`); - - // Auto-authorize user's ESP32 controllers for this session (if authenticated) - if (ctx.isAuthenticated && ctx.userId) { - authorizeUserControllersForSession(ctx.userId, sessionId); - } + // Validate inputs + validateInput(SessionIdSchema, sessionId, 'sessionId'); + validateInput(BoardPathSchema, boardPath, 'boardPath'); + if (username) validateInput(UsernameSchema, username, 'username'); + if (avatarUrl) validateInput(AvatarUrlSchema, avatarUrl, 'avatarUrl'); + if (sessionName) validateInput(SessionNameSchema, sessionName, 'sessionName'); + if (initialQueue) validateInput(QueueArraySchema, initialQueue, 'initialQueue'); + if (initialCurrentClimb) validateInput(ClimbQueueItemSchema, initialCurrentClimb, 'initialCurrentClimb'); - // Notify session about new user - const userJoinedEvent: SessionEvent = { - __typename: 'UserJoined', - user: { - id: result.clientId, - username: username || `User-${result.clientId.substring(0, 6)}`, - isLeader: result.isLeader, - avatarUrl: avatarUrl, - }, - }; - pubsub.publishSessionEvent(sessionId, userJoinedEvent); + const result = await roomManager.joinSession( + ctx.connectionId, + sessionId, + boardPath, + username || undefined, + avatarUrl || undefined, + initialQueue, + initialCurrentClimb || null, + sessionName || undefined + ); + if (DEBUG) console.log(`[joinSession] roomManager.joinSession completed - clientId: ${result.clientId}, isLeader: ${result.isLeader}`); - // Fetch session data for new fields - const sessionData = await roomManager.getSessionById(sessionId); + // Update context with session info + updateContext(ctx.connectionId, { sessionId, userId: result.clientId }); - return { - id: sessionId, - name: result.sessionName || null, - boardPath, - users: result.users, - queueState: { - sequence: result.sequence, - stateHash: result.stateHash, - queue: result.queue, - currentClimbQueueItem: result.currentClimbQueueItem, - }, - isLeader: result.isLeader, - clientId: result.clientId, - goal: sessionData?.goal || null, - isPublic: sessionData?.isPublic ?? true, - startedAt: sessionData?.startedAt?.toISOString() || null, - endedAt: sessionData?.endedAt?.toISOString() || null, - isPermanent: sessionData?.isPermanent ?? false, - color: sessionData?.color || null, - }; + // Auto-authorize user's ESP32 controllers for this session (if authenticated) + if (ctx.isAuthenticated && ctx.userId) { + authorizeUserControllersForSession(ctx.userId, sessionId); + } + + // Notify session about new user + const userJoinedEvent: SessionEvent = { + __typename: 'UserJoined', + user: { + id: result.clientId, + username: username || `User-${result.clientId.substring(0, 6)}`, + isLeader: result.isLeader, + avatarUrl: avatarUrl, + }, + }; + pubsub.publishSessionEvent(sessionId, userJoinedEvent); + + // Fetch session data for new fields + const sessionData = await roomManager.getSessionById(sessionId); + + console.log(`[joinSession] completed - connectionId: ${ctx.connectionId.slice(0, 8)}, clientId: ${result.clientId.slice(0, 8)}, users: ${result.users.length}`); + + return { + id: sessionId, + name: result.sessionName || null, + boardPath, + users: result.users, + queueState: { + sequence: result.sequence, + stateHash: result.stateHash, + queue: result.queue, + currentClimbQueueItem: result.currentClimbQueueItem, + }, + isLeader: result.isLeader, + clientId: result.clientId, + goal: sessionData?.goal || null, + isPublic: sessionData?.isPublic ?? true, + startedAt: sessionData?.startedAt?.toISOString() || null, + endedAt: sessionData?.endedAt?.toISOString() || null, + isPermanent: sessionData?.isPermanent ?? false, + color: sessionData?.color || null, + }; + } catch (err) { + console.error(`[joinSession] FAILED - connectionId: ${ctx.connectionId.slice(0, 8)}, sessionId: ${sessionId.slice(0, 8)}:`, err); + throw err; + } }, /** diff --git a/packages/backend/src/services/distributed-state.ts b/packages/backend/src/services/distributed-state.ts index 589ca280..d93d377e 100644 --- a/packages/backend/src/services/distributed-state.ts +++ b/packages/backend/src/services/distributed-state.ts @@ -861,12 +861,24 @@ export class DistributedStateManager { const [err, data] = results[i] as [Error | null, Record]; if (!err && data && data.connectionId) { const connection = this.hashToConnection(data); - users.push({ - id: connection.connectionId, - username: connection.username, - isLeader: connection.isLeader, - avatarUrl: connection.avatarUrl || undefined, - }); + // Validate required fields — username is String! in GraphQL schema. + // A missing username would cause null propagation and break the entire response. + if (connection.username) { + users.push({ + id: connection.connectionId, + username: connection.username, + isLeader: connection.isLeader, + avatarUrl: connection.avatarUrl || undefined, + }); + } else { + console.warn( + `[DistributedState] Skipping member ${connection.connectionId.slice(0, 8)} with missing username in session ${sessionId}` + ); + this.redis.srem(KEYS.sessionMembers(sessionId), memberIds[i]).catch(() => {}); + } + } else if (!err) { + // Connection hash expired — clean up stale member set entry + this.redis.srem(KEYS.sessionMembers(sessionId), memberIds[i]).catch(() => {}); } } } diff --git a/packages/backend/src/websocket/setup.ts b/packages/backend/src/websocket/setup.ts index 2fecac64..2a3da23c 100644 --- a/packages/backend/src/websocket/setup.ts +++ b/packages/backend/src/websocket/setup.ts @@ -185,8 +185,11 @@ export function setupWebSocketServer(httpServer: HttpServer): WebSocketServer { } } }, - onError: (_ctx: ServerContext, _id: string, _payload, errors) => { - console.error('GraphQL error:', errors); + onError: (ctx: ServerContext, _id: string, payload, errors) => { + const context = (ctx.extra as CustomExtra)?.context; + const connectionId = context?.connectionId?.slice(0, 8) || 'unknown'; + const operationName = payload?.operationName || 'anonymous'; + console.error(`[GraphQL] Error in operation "${operationName}" for connection ${connectionId}:`, errors); }, onComplete: (_ctx: ServerContext, _id: string, payload) => { if (DEBUG) { diff --git a/packages/web/app/components/persistent-session/board-session-bridge.tsx b/packages/web/app/components/persistent-session/board-session-bridge.tsx index 76174457..a43bcab6 100644 --- a/packages/web/app/components/persistent-session/board-session-bridge.tsx +++ b/packages/web/app/components/persistent-session/board-session-bridge.tsx @@ -38,6 +38,30 @@ const BoardSessionBridge: React.FC = ({ boardDetailsRef.current = boardDetails; parsedParamsRef.current = parsedParams; + // Track session IDs that failed to connect (exhausted retries or definitive error). + // Prevents infinite reactivation loop: when PersistentSessionContext clears activeSession + // after exhausting retries, the bridge would otherwise see the URL still has ?session=xxx + // and call activateSession() again, restarting the entire failing cycle. + const failedSessionIdsRef = React.useRef>(new Set()); + const prevSessionIdFromUrlRef = React.useRef(null); + + // Clear failed tracking when URL session param changes (user navigated to a different session) + useEffect(() => { + if (sessionIdFromUrl !== prevSessionIdFromUrlRef.current) { + if (prevSessionIdFromUrlRef.current) { + failedSessionIdsRef.current.delete(prevSessionIdFromUrlRef.current); + } + prevSessionIdFromUrlRef.current = sessionIdFromUrl; + } + }, [sessionIdFromUrl]); + + // Detect when activeSession is cleared while URL still has session param — mark as failed + useEffect(() => { + if (!activeSession && sessionIdFromUrl) { + failedSessionIdsRef.current.add(sessionIdFromUrl); + } + }, [activeSession, sessionIdFromUrl]); + // Activate or update session when we have a session param and board details // This effect handles: // 1. Initial session activation when joining via shared link @@ -45,6 +69,11 @@ const BoardSessionBridge: React.FC = ({ // Note: Navigation within the same board (e.g., swiping between climbs) should NOT trigger reconnection useEffect(() => { if (sessionIdFromUrl && boardDetailsRef.current) { + // Don't reactivate a session that just failed — prevents infinite retry loop + if (failedSessionIdsRef.current.has(sessionIdFromUrl)) { + return; + } + // Activate session when URL has session param and either: // - Session ID changed // - Board configuration path changed (e.g., navigating to different board/layout/size/sets) From 6f3181fd89dd3916c6dc8f42fc48c7b4e576a7e8 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 4 Mar 2026 11:20:36 +0100 Subject: [PATCH 7/8] Fix session connection broken by premature failed-session marking The "detect failed session" effect in BoardSessionBridge was marking sessions as failed on initial mount when activeSession is null (not yet set), preventing the activation effect from ever running. Now only marks a session as failed after it was previously active and then cleared. Co-Authored-By: Claude Opus 4.6 --- .../persistent-session/board-session-bridge.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/web/app/components/persistent-session/board-session-bridge.tsx b/packages/web/app/components/persistent-session/board-session-bridge.tsx index a43bcab6..9adbc242 100644 --- a/packages/web/app/components/persistent-session/board-session-bridge.tsx +++ b/packages/web/app/components/persistent-session/board-session-bridge.tsx @@ -44,6 +44,7 @@ const BoardSessionBridge: React.FC = ({ // and call activateSession() again, restarting the entire failing cycle. const failedSessionIdsRef = React.useRef>(new Set()); const prevSessionIdFromUrlRef = React.useRef(null); + const hadActiveSessionRef = React.useRef(false); // Clear failed tracking when URL session param changes (user navigated to a different session) useEffect(() => { @@ -55,10 +56,19 @@ const BoardSessionBridge: React.FC = ({ } }, [sessionIdFromUrl]); - // Detect when activeSession is cleared while URL still has session param — mark as failed + // Track when we've had an active session useEffect(() => { - if (!activeSession && sessionIdFromUrl) { + if (activeSession) { + hadActiveSessionRef.current = true; + } + }, [activeSession]); + + // Detect when activeSession is cleared while URL still has session param — mark as failed. + // Only triggers after activeSession was previously set (not on initial mount when it starts null). + useEffect(() => { + if (!activeSession && sessionIdFromUrl && hadActiveSessionRef.current) { failedSessionIdsRef.current.add(sessionIdFromUrl); + hadActiveSessionRef.current = false; } }, [activeSession, sessionIdFromUrl]); From f3922bbb5b943ae54abbf664fd3b7ba28b2d28eb Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 4 Mar 2026 11:32:11 +0100 Subject: [PATCH 8/8] Fix null addedItem in QueueItemAdded during delta sync replay The EVENTS_REPLAY query was using GraphQL field aliases (addedItem: item, currentItem: item), so the response already had aliased field names. But transformToSubscriptionEvent was reading event.item (the un-aliased name), which was undefined at runtime. Remove the aliases so the response matches the QueueEvent type, and the transform function correctly remaps the fields. Co-Authored-By: Claude Opus 4.6 --- packages/shared-schema/src/operations.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared-schema/src/operations.ts b/packages/shared-schema/src/operations.ts index 19508809..7bc76b1a 100644 --- a/packages/shared-schema/src/operations.ts +++ b/packages/shared-schema/src/operations.ts @@ -292,7 +292,7 @@ export const EVENTS_REPLAY = ` } ... on QueueItemAdded { sequence - addedItem: item { + item { ${QUEUE_ITEM_FIELDS} } position @@ -309,7 +309,7 @@ export const EVENTS_REPLAY = ` } ... on CurrentClimbChanged { sequence - currentItem: item { + item { ${QUEUE_ITEM_FIELDS} } clientId