diff --git a/package-lock.json b/package-lock.json index 28458eda..b3c19e7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4243,7 +4243,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4803,7 +4803,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": { @@ -5736,7 +5736,7 @@ "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -6536,7 +6536,7 @@ "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "node-gyp-build": "bin.js", @@ -7141,7 +7141,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7161,7 +7160,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", - "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -7264,7 +7262,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -7382,7 +7380,6 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "dev": true, "license": "MIT" }, "node_modules/scroll-into-view-if-needed": { @@ -7875,7 +7872,7 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.27.0", @@ -7898,12 +7895,12 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -7915,12 +7912,12 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -7932,12 +7929,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -7949,12 +7946,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -7966,12 +7963,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -7983,12 +7980,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -8000,12 +7997,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8017,12 +8014,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8034,12 +8031,12 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8051,12 +8048,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8068,12 +8065,12 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8085,12 +8082,12 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8102,12 +8099,12 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8119,12 +8116,12 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8136,12 +8133,12 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8153,12 +8150,12 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8170,12 +8167,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8187,12 +8184,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8204,12 +8201,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8221,12 +8218,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8238,12 +8235,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8255,12 +8252,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -8272,12 +8269,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -8289,12 +8286,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -8306,12 +8303,12 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -8323,12 +8320,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -8337,7 +8334,7 @@ "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { diff --git a/packages/web/app/components/persistent-session/persistent-session-context.test.tsx b/packages/web/app/components/persistent-session/persistent-session-context.test.tsx new file mode 100644 index 00000000..8e94eb52 --- /dev/null +++ b/packages/web/app/components/persistent-session/persistent-session-context.test.tsx @@ -0,0 +1,530 @@ +import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import React from 'react'; +import { QueueState } from '@boardsesh/shared-schema'; + +// Mock the persistent-session-context module to override the backend URL check +// We need to do this because process.env.NEXT_PUBLIC_WS_URL is read at module load time +vi.mock('./persistent-session-context', async (importOriginal) => { + // Set the env var before importing the original module + process.env.NEXT_PUBLIC_WS_URL = 'ws://localhost:4000'; + const original = await importOriginal(); + return original; +}); + +import { PersistentSessionProvider, usePersistentSession, Session } from './persistent-session-context'; +import * as graphqlClientModule from '../graphql-queue/graphql-client'; + +// Mock the graphql-client module +vi.mock('../graphql-queue/graphql-client', () => ({ + createGraphQLClient: vi.fn(), + execute: vi.fn(), + subscribe: vi.fn(), +})); + +// Mock useWsAuthToken hook +vi.mock('@/app/hooks/use-ws-auth-token', () => ({ + useWsAuthToken: () => ({ token: 'mock-token', isLoading: false }), +})); + +// Mock usePartyProfile hook +vi.mock('../party-manager/party-profile-context', () => ({ + usePartyProfile: () => ({ username: 'TestUser', avatarUrl: null }), +})); + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + usePathname: () => '/kilter/1/2/3/45', +})); + +describe('PersistentSessionContext', () => { + let mockClient: { + dispose: Mock; + }; + let capturedOnReconnect: (() => void) | undefined; + let mockQueueUnsubscribe: Mock; + let mockSessionUnsubscribe: Mock; + + const mockSession: Session = { + id: 'test-session', + boardPath: '/kilter/1/2/3/45', + users: [{ id: 'user-1', username: 'TestUser', isLeader: true }], + queueState: { + queue: [], + currentClimbQueueItem: null, + }, + isLeader: true, + clientId: 'user-1', + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Setup mock client + mockClient = { + dispose: vi.fn(), + }; + + mockQueueUnsubscribe = vi.fn(); + mockSessionUnsubscribe = vi.fn(); + capturedOnReconnect = undefined; + + // Mock createGraphQLClient to capture onReconnect callback + vi.mocked(graphqlClientModule.createGraphQLClient).mockImplementation((options: any) => { + capturedOnReconnect = options.onReconnect; + return mockClient as any; + }); + + // Mock execute to return successful joinSession + vi.mocked(graphqlClientModule.execute).mockResolvedValue({ + joinSession: mockSession, + }); + + // Mock subscribe to return unsubscribe functions and track calls + let subscribeCallCount = 0; + vi.mocked(graphqlClientModule.subscribe).mockImplementation(() => { + subscribeCallCount++; + if (subscribeCallCount % 2 === 1) { + return mockQueueUnsubscribe; + } + return mockSessionUnsubscribe; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + describe('Initial connection', () => { + it('should setup graphql client with onReconnect callback when session is activated', async () => { + const { result } = renderHook(() => usePersistentSession(), { wrapper }); + + // Activate session + act(() => { + result.current.activateSession({ + sessionId: 'test-session', + boardPath: '/kilter/1/2/3/45', + boardDetails: {} as any, + parsedParams: {} as any, + }); + }); + + // Wait for connection to be established + await waitFor(() => { + expect(graphqlClientModule.createGraphQLClient).toHaveBeenCalled(); + }); + + // Verify onReconnect was passed to client + expect(capturedOnReconnect).toBeDefined(); + expect(typeof capturedOnReconnect).toBe('function'); + }); + + it('should call execute with joinSession mutation on connect', async () => { + const { result } = renderHook(() => usePersistentSession(), { wrapper }); + + // Activate session + act(() => { + result.current.activateSession({ + sessionId: 'test-session', + boardPath: '/kilter/1/2/3/45', + boardDetails: {} as any, + parsedParams: {} as any, + }); + }); + + await waitFor(() => { + expect(graphqlClientModule.execute).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + variables: expect.objectContaining({ + sessionId: 'test-session', + boardPath: '/kilter/1/2/3/45', + }), + }) + ); + }); + }); + + it('should setup queue and session subscriptions after joining', async () => { + const { result } = renderHook(() => usePersistentSession(), { wrapper }); + + // Activate session + act(() => { + result.current.activateSession({ + sessionId: 'test-session', + boardPath: '/kilter/1/2/3/45', + boardDetails: {} as any, + parsedParams: {} as any, + }); + }); + + await waitFor(() => { + // Should have 2 subscriptions: queue and session + expect(graphqlClientModule.subscribe).toHaveBeenCalledTimes(2); + }); + }); + + it('should set hasConnected to true after successful connection', async () => { + const { result } = renderHook(() => usePersistentSession(), { wrapper }); + + expect(result.current.hasConnected).toBe(false); + + // Activate session + act(() => { + result.current.activateSession({ + sessionId: 'test-session', + boardPath: '/kilter/1/2/3/45', + boardDetails: {} as any, + parsedParams: {} as any, + }); + }); + + await waitFor(() => { + expect(result.current.hasConnected).toBe(true); + }); + }); + + it('should populate queue state from joinSession response', async () => { + const mockQueueState: QueueState = { + queue: [ + { + uuid: 'item-1', + climb: { + uuid: 'climb-1', + name: 'Test Climb', + setter_username: 'setter', + description: '', + frames: '', + angle: 45, + ascensionist_count: 0, + difficulty: 'V5', + quality_average: 3, + stars: 3, + difficulty_error: 0, + litUpHoldsMap: {}, + mirrored: false, + }, + addedBy: 'user-1', + }, + ] as any, + currentClimbQueueItem: null, + }; + + vi.mocked(graphqlClientModule.execute).mockResolvedValue({ + joinSession: { + ...mockSession, + queueState: mockQueueState, + }, + }); + + const { result } = renderHook(() => usePersistentSession(), { wrapper }); + + act(() => { + result.current.activateSession({ + sessionId: 'test-session', + boardPath: '/kilter/1/2/3/45', + boardDetails: {} as any, + parsedParams: {} as any, + }); + }); + + await waitFor(() => { + expect(result.current.queue).toHaveLength(1); + expect(result.current.queue[0].uuid).toBe('item-1'); + }); + }); + }); + + describe('Reconnection handling', () => { + it('should call joinSession again when onReconnect is triggered', async () => { + const { result } = renderHook(() => usePersistentSession(), { wrapper }); + + // Activate session and wait for initial connection + act(() => { + result.current.activateSession({ + sessionId: 'test-session', + boardPath: '/kilter/1/2/3/45', + boardDetails: {} as any, + parsedParams: {} as any, + }); + }); + + await waitFor(() => { + expect(result.current.hasConnected).toBe(true); + }); + + // Clear mocks to track reconnection calls + vi.mocked(graphqlClientModule.execute).mockClear(); + vi.mocked(graphqlClientModule.subscribe).mockClear(); + + // Trigger reconnection + await act(async () => { + capturedOnReconnect?.(); + // Give time for async operations + await new Promise((r) => setTimeout(r, 50)); + }); + + // Should call execute (joinSession) again + expect(graphqlClientModule.execute).toHaveBeenCalled(); + }); + + it('should re-establish subscriptions on reconnect', async () => { + const { result } = renderHook(() => usePersistentSession(), { wrapper }); + + // Activate session and wait for initial connection + act(() => { + result.current.activateSession({ + sessionId: 'test-session', + boardPath: '/kilter/1/2/3/45', + boardDetails: {} as any, + parsedParams: {} as any, + }); + }); + + await waitFor(() => { + expect(result.current.hasConnected).toBe(true); + }); + + // Initial setup should have 2 subscriptions + expect(graphqlClientModule.subscribe).toHaveBeenCalledTimes(2); + + // Clear mocks to track reconnection calls + vi.mocked(graphqlClientModule.subscribe).mockClear(); + + // Trigger reconnection + await act(async () => { + capturedOnReconnect?.(); + await new Promise((r) => setTimeout(r, 50)); + }); + + // Should set up subscriptions again (2 more calls) + await waitFor(() => { + expect(graphqlClientModule.subscribe).toHaveBeenCalledTimes(2); + }); + }); + + it('should clean up old subscriptions before setting up new ones', async () => { + const { result } = renderHook(() => usePersistentSession(), { wrapper }); + + // Activate session and wait for initial connection + act(() => { + result.current.activateSession({ + sessionId: 'test-session', + boardPath: '/kilter/1/2/3/45', + boardDetails: {} as any, + parsedParams: {} as any, + }); + }); + + await waitFor(() => { + expect(result.current.hasConnected).toBe(true); + }); + + // Trigger reconnection + await act(async () => { + capturedOnReconnect?.(); + await new Promise((r) => setTimeout(r, 50)); + }); + + // Old subscriptions should have been unsubscribed + expect(mockQueueUnsubscribe).toHaveBeenCalled(); + expect(mockSessionUnsubscribe).toHaveBeenCalled(); + }); + + it('should update queue state from reconnection joinSession response', async () => { + const { result } = renderHook(() => usePersistentSession(), { wrapper }); + + // Activate session and wait for initial connection + act(() => { + result.current.activateSession({ + sessionId: 'test-session', + boardPath: '/kilter/1/2/3/45', + boardDetails: {} as any, + parsedParams: {} as any, + }); + }); + + await waitFor(() => { + expect(result.current.hasConnected).toBe(true); + }); + + // Initial queue should be empty + expect(result.current.queue).toHaveLength(0); + + // Mock execute to return updated queue on reconnect + const updatedQueueState: QueueState = { + queue: [ + { + uuid: 'new-item', + climb: { + uuid: 'new-climb', + name: 'New Climb', + setter_username: 'setter', + description: '', + frames: '', + angle: 45, + ascensionist_count: 0, + difficulty: 'V5', + quality_average: 3, + stars: 3, + difficulty_error: 0, + litUpHoldsMap: {}, + mirrored: false, + }, + addedBy: 'user-1', + }, + ] as any, + currentClimbQueueItem: null, + }; + + vi.mocked(graphqlClientModule.execute).mockResolvedValue({ + joinSession: { + ...mockSession, + queueState: updatedQueueState, + }, + }); + + // Trigger reconnection + await act(async () => { + capturedOnReconnect?.(); + await new Promise((r) => setTimeout(r, 50)); + }); + + // Queue should be updated with new data + await waitFor(() => { + expect(result.current.queue).toHaveLength(1); + expect(result.current.queue[0].uuid).toBe('new-item'); + }); + }); + }); + + describe('Visibility change handling', () => { + it('should resync when tab becomes visible after being hidden for threshold duration', async () => { + // Mock Date.now for consistent timing + const originalNow = Date.now; + let currentTime = 1000000; + vi.spyOn(Date, 'now').mockImplementation(() => currentTime); + + const { result } = renderHook(() => usePersistentSession(), { wrapper }); + + // Activate session and wait for initial connection + act(() => { + result.current.activateSession({ + sessionId: 'test-session', + boardPath: '/kilter/1/2/3/45', + boardDetails: {} as any, + parsedParams: {} as any, + }); + }); + + await waitFor(() => { + expect(result.current.hasConnected).toBe(true); + }); + + // Clear mocks to track visibility resync + vi.mocked(graphqlClientModule.execute).mockClear(); + + // Simulate tab becoming hidden + Object.defineProperty(document, 'hidden', { value: true, writable: true, configurable: true }); + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + + // Advance time beyond threshold (30 seconds) + currentTime += 35000; + + // Simulate tab becoming visible + Object.defineProperty(document, 'hidden', { value: false, writable: true, configurable: true }); + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')); + // Give time for async operations + await new Promise((r) => setTimeout(r, 50)); + }); + + // Should have triggered resync (joinSession call) + expect(graphqlClientModule.execute).toHaveBeenCalled(); + + // Restore Date.now + vi.spyOn(Date, 'now').mockRestore(); + }); + + it('should NOT resync when tab was hidden for less than threshold duration', async () => { + // Mock Date.now for consistent timing + let currentTime = 1000000; + vi.spyOn(Date, 'now').mockImplementation(() => currentTime); + + const { result } = renderHook(() => usePersistentSession(), { wrapper }); + + // Activate session and wait for initial connection + act(() => { + result.current.activateSession({ + sessionId: 'test-session', + boardPath: '/kilter/1/2/3/45', + boardDetails: {} as any, + parsedParams: {} as any, + }); + }); + + await waitFor(() => { + expect(result.current.hasConnected).toBe(true); + }); + + // Clear mocks + vi.mocked(graphqlClientModule.execute).mockClear(); + + // Simulate tab becoming hidden + Object.defineProperty(document, 'hidden', { value: true, writable: true, configurable: true }); + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + + // Advance time by only 10 seconds (less than 30 second threshold) + currentTime += 10000; + + // Simulate tab becoming visible + Object.defineProperty(document, 'hidden', { value: false, writable: true, configurable: true }); + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + + // Should NOT have triggered resync + expect(graphqlClientModule.execute).not.toHaveBeenCalled(); + + // Restore Date.now + vi.spyOn(Date, 'now').mockRestore(); + }); + }); + + describe('Session deactivation', () => { + it('should clean up resources when session is deactivated', async () => { + const { result } = renderHook(() => usePersistentSession(), { wrapper }); + + // Activate session + act(() => { + result.current.activateSession({ + sessionId: 'test-session', + boardPath: '/kilter/1/2/3/45', + boardDetails: {} as any, + parsedParams: {} as any, + }); + }); + + await waitFor(() => { + expect(result.current.hasConnected).toBe(true); + }); + + // Deactivate session + act(() => { + result.current.deactivateSession(); + }); + + // Queue should be cleared + expect(result.current.queue).toHaveLength(0); + expect(result.current.currentClimbQueueItem).toBeNull(); + }); + }); +}); 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 03a4a686..bf060f06 100644 --- a/packages/web/app/components/persistent-session/persistent-session-context.tsx +++ b/packages/web/app/components/persistent-session/persistent-session-context.tsx @@ -31,6 +31,9 @@ const DEFAULT_BACKEND_URL = process.env.NEXT_PUBLIC_WS_URL || null; // Board names to check if we're on a board route const BOARD_NAMES = ['kilter', 'tension', 'decoy']; +// Threshold for forcing a resync when tab becomes visible (30 seconds) +const VISIBILITY_RESYNC_THRESHOLD_MS = 30_000; + // Session type matching the GraphQL response export interface Session { id: string; @@ -165,6 +168,10 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> const isReconnectingRef = useRef(false); const activeSessionRef = useRef(null); const mountedRef = useRef(false); + const clientRef = useRef(null); + + // Track when tab was last hidden for visibility-based resync + const hiddenAtRef = useRef(null); // Event subscribers const queueEventSubscribersRef = useRef void>>(new Set()); @@ -179,6 +186,10 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> activeSessionRef.current = activeSession; }, [activeSession]); + useEffect(() => { + clientRef.current = client; + }, [client]); + // Notify queue event subscribers const notifyQueueSubscribers = useCallback((event: ClientQueueEvent) => { queueEventSubscribersRef.current.forEach((callback) => callback(event)); @@ -276,6 +287,73 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> notifySessionSubscribers(event); }, [notifySessionSubscribers]); + // Handle visibility changes to resync when tab becomes visible after being hidden + // This catches scenarios where WebSocket might have been throttled or events missed + useEffect(() => { + function handleVisibilityChange() { + if (document.hidden) { + // Tab is now hidden - record the time + hiddenAtRef.current = Date.now(); + if (DEBUG) console.log('[PersistentSession] Tab hidden'); + } else { + // Tab is now visible - check if we need to resync + const hiddenAt = hiddenAtRef.current; + hiddenAtRef.current = null; + + if (DEBUG) console.log('[PersistentSession] Tab visible'); + + // If we have an active session and were hidden for long enough, force a resync + if ( + hiddenAt && + Date.now() - hiddenAt > VISIBILITY_RESYNC_THRESHOLD_MS && + activeSessionRef.current && + clientRef.current && + !isReconnectingRef.current + ) { + if (DEBUG) console.log('[PersistentSession] Tab was hidden for', Date.now() - hiddenAt, 'ms, forcing resync'); + + // Trigger a resync by rejoining the session and applying FullSync. + // Note: We only resync state, not re-establish subscriptions, because the WebSocket + // connection is still active - the browser just throttled the tab. Subscriptions + // remain valid and will continue receiving events after we resync. + isReconnectingRef.current = true; + const { sessionId, boardPath } = activeSessionRef.current; + + execute<{ joinSession: Session }>(clientRef.current, { + query: JOIN_SESSION, + variables: { sessionId, boardPath, username: usernameRef.current, avatarUrl: avatarUrlRef.current }, + }) + .then((response) => { + if (response?.joinSession && mountedRef.current) { + setSession(response.joinSession); + if (response.joinSession.queueState) { + handleQueueEvent({ + __typename: 'FullSync', + state: response.joinSession.queueState, + }); + } + if (DEBUG) console.log('[PersistentSession] Visibility resync completed'); + } + }) + .catch((err) => { + console.error('[PersistentSession] Visibility resync failed:', err); + if (mountedRef.current) { + setError(err instanceof Error ? err : new Error(String(err))); + } + }) + .finally(() => { + isReconnectingRef.current = false; + }); + } + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [handleQueueEvent]); + // Connect to session when activeSession changes useEffect(() => { if (!activeSession) { @@ -317,6 +395,74 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> } } + // Function to set up subscriptions - reusable for initial connect and reconnect + function setupSubscriptions(clientToUse: Client) { + if (DEBUG) console.log('[PersistentSession] Setting up subscriptions for session:', sessionId); + + // Clean up any existing subscriptions first + if (queueUnsubscribeRef.current) { + if (DEBUG) console.log('[PersistentSession] Cleaning up existing queue subscription'); + queueUnsubscribeRef.current(); + queueUnsubscribeRef.current = null; + } + if (sessionUnsubscribeRef.current) { + if (DEBUG) console.log('[PersistentSession] Cleaning up existing session subscription'); + sessionUnsubscribeRef.current(); + sessionUnsubscribeRef.current = null; + } + + // Subscribe to queue updates + queueUnsubscribeRef.current = subscribe<{ queueUpdates: ClientQueueEvent }>( + clientToUse, + { query: QUEUE_UPDATES, variables: { sessionId } }, + { + next: (data) => { + if (data.queueUpdates) { + handleQueueEvent(data.queueUpdates); + } + }, + error: (err) => { + console.error('[PersistentSession] Queue subscription error:', err); + // Clean up ref on error + queueUnsubscribeRef.current = null; + if (mountedRef.current) { + setError(err instanceof Error ? err : new Error(String(err))); + } + }, + complete: () => { + if (DEBUG) console.log('[PersistentSession] Queue subscription completed'); + // Clean up ref on complete + queueUnsubscribeRef.current = null; + }, + }, + ); + + // Subscribe to session updates + sessionUnsubscribeRef.current = subscribe<{ sessionUpdates: SessionEvent }>( + clientToUse, + { query: SESSION_UPDATES, variables: { sessionId } }, + { + next: (data) => { + if (data.sessionUpdates) { + handleSessionEvent(data.sessionUpdates); + } + }, + error: (err) => { + console.error('[PersistentSession] Session subscription error:', err); + // Clean up ref on error + sessionUnsubscribeRef.current = null; + }, + complete: () => { + if (DEBUG) console.log('[PersistentSession] Session subscription completed'); + // Clean up ref on complete + sessionUnsubscribeRef.current = null; + }, + }, + ); + + if (DEBUG) console.log('[PersistentSession] Subscriptions set up successfully'); + } + async function handleReconnect() { // Use ref to safely check if component is still mounted if (!mountedRef.current || !graphqlClient) return; @@ -333,6 +479,19 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> if (sessionData && mountedRef.current) { setSession(sessionData); if (DEBUG) console.log('[PersistentSession] Reconnected, clientId:', sessionData.clientId); + + // Apply FullSync from joinSession response to ensure state is current + // This catches any events that were missed during disconnection + if (sessionData.queueState) { + if (DEBUG) console.log('[PersistentSession] Applying FullSync from reconnection'); + handleQueueEvent({ + __typename: 'FullSync', + state: sessionData.queueState, + }); + } + + // Re-establish subscriptions since they complete/error on socket close + setupSubscriptions(graphqlClient); } } finally { isReconnectingRef.current = false; @@ -384,54 +543,8 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> }); } - // Subscribe to queue updates - queueUnsubscribeRef.current = subscribe<{ queueUpdates: ClientQueueEvent }>( - graphqlClient, - { query: QUEUE_UPDATES, variables: { sessionId } }, - { - next: (data) => { - if (data.queueUpdates) { - handleQueueEvent(data.queueUpdates); - } - }, - error: (err) => { - console.error('[PersistentSession] Queue subscription error:', err); - // Clean up ref on error - queueUnsubscribeRef.current = null; - if (mountedRef.current) { - setError(err instanceof Error ? err : new Error(String(err))); - } - }, - complete: () => { - if (DEBUG) console.log('[PersistentSession] Queue subscription completed'); - // Clean up ref on complete - queueUnsubscribeRef.current = null; - }, - }, - ); - - // Subscribe to session updates - sessionUnsubscribeRef.current = subscribe<{ sessionUpdates: SessionEvent }>( - graphqlClient, - { query: SESSION_UPDATES, variables: { sessionId } }, - { - next: (data) => { - if (data.sessionUpdates) { - handleSessionEvent(data.sessionUpdates); - } - }, - error: (err) => { - console.error('[PersistentSession] Session subscription error:', err); - // Clean up ref on error - sessionUnsubscribeRef.current = null; - }, - complete: () => { - if (DEBUG) console.log('[PersistentSession] Session subscription completed'); - // Clean up ref on complete - sessionUnsubscribeRef.current = null; - }, - }, - ); + // Set up subscriptions for queue and session updates + setupSubscriptions(graphqlClient); } catch (err) { console.error('[PersistentSession] Connection failed:', err); if (mountedRef.current) {