diff --git a/src/hooks/__tests__/useRoomSocket.test.ts b/src/hooks/__tests__/useRoomSocket.test.ts new file mode 100644 index 0000000..e27cfa6 --- /dev/null +++ b/src/hooks/__tests__/useRoomSocket.test.ts @@ -0,0 +1,544 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { useRoomSocket } from '@/hooks/useRoomSocket' +import { getSocketManager } from '@/infrastructure/communication/socket-client' +import { RoleDetectionService } from '@/app/room/utils/role-detection' +import { ParticipantRoleEnum } from '@/domain/room/value-objects/participant-attributes' + +// Mock the dependencies +vi.mock('@/infrastructure/communication/socket-client') +vi.mock('@/app/room/utils/role-detection', () => ({ + RoleDetectionService: { + getCurrentRole: vi.fn(), + isOrganizer: vi.fn(), + setAsOrganizer: vi.fn(), + setAsGuest: vi.fn(), + clearRoles: vi.fn(), + }, +})) + +// Mock socket manager +const mockSocketManager = { + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn(), + getStatus: vi.fn(), + onConnection: vi.fn(), + onError: vi.fn(), + onRoomStateUpdate: vi.fn(), + off: vi.fn(), + removeAllListeners: vi.fn(), + emitRoomStateUpdate: vi.fn(), + emitParticipantUpdate: vi.fn(), + emitWheelSpin: vi.fn(), + emitTimerUpdate: vi.fn(), + emitRoomMessage: vi.fn(), +} + +beforeEach(() => { + vi.mocked(getSocketManager).mockReturnValue(mockSocketManager as never) + + // Reset all mocks + vi.clearAllMocks() + + // Default mock implementations + mockSocketManager.isConnected.mockReturnValue(false) + mockSocketManager.getStatus.mockReturnValue('disconnected') + vi.mocked(RoleDetectionService.getCurrentRole).mockReturnValue(ParticipantRoleEnum.GUEST) + + // Mock successful connection by default + mockSocketManager.connect.mockImplementation(async () => { + // Simulate successful connection + mockSocketManager.isConnected.mockReturnValue(true) + mockSocketManager.getStatus.mockReturnValue('connected') + }) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('useRoomSocket', () => { + const validRoomId = '550e8400-e29b-41d4-a716-446655440000' + const invalidRoomId = 'invalid-uuid' + + describe('1) Connect to room on mount', () => { + it('should transition status connecting → connected and provide namespace and socketId', async () => { + const { result } = renderHook(() => + useRoomSocket({ + roomId: validRoomId, + autoConnect: false, // Start without auto-connect to control the flow + }) + ) + + // Initially should be disconnected + expect(result.current.status).toBe('disconnected') + expect(result.current.isConnected).toBe(false) + expect(result.current.socketId).toBe(null) + + // Manually connect + act(() => { + result.current.connect() + }) + + // Wait for connect call + await waitFor(() => { + expect(mockSocketManager.connect).toHaveBeenCalledWith({ + url: 'http://localhost:3000', + roomId: validRoomId, + userId: undefined, + userName: undefined, + role: ParticipantRoleEnum.GUEST, + }) + }) + + // Simulate connection success + act(() => { + const onConnectionCallback = mockSocketManager.onConnection.mock.calls[0][0] + onConnectionCallback({ + roomId: validRoomId, + timestamp: new Date().toISOString(), + message: 'Connected', + socketId: 'socket-123', + }) + }) + + expect(result.current.status).toBe('connected') + expect(result.current.isConnected).toBe(true) + expect(result.current.socketId).toBe('socket-123') + }) + + it('should use correct namespace format room:{id}', async () => { + renderHook(() => + useRoomSocket({ + roomId: validRoomId, + autoConnect: true, + }) + ) + + await waitFor(() => { + expect(mockSocketManager.connect).toHaveBeenCalledWith( + expect.objectContaining({ + roomId: validRoomId, + }) + ) + }) + }) + }) + + describe('2) Cleanup on unmount', () => { + it('should remove all listeners and disconnect on unmount', async () => { + const { unmount } = renderHook(() => + useRoomSocket({ + roomId: validRoomId, + autoConnect: true, + }) + ) + + // Simulate connected state + mockSocketManager.isConnected.mockReturnValue(true) + + unmount() + + expect(mockSocketManager.removeAllListeners).toHaveBeenCalled() + expect(mockSocketManager.disconnect).toHaveBeenCalled() + }) + }) + + describe('3) Switch namespace when roomId changes', () => { + it('should disconnect from previous room and connect to new room', async () => { + const newRoomId = '661f8500-e29b-41d4-a716-446655440001' + + const { rerender } = renderHook( + ({ roomId }) => useRoomSocket({ roomId, autoConnect: true }), + { initialProps: { roomId: validRoomId } } + ) + + // Wait for initial connection + await waitFor(() => { + expect(mockSocketManager.connect).toHaveBeenCalledWith( + expect.objectContaining({ roomId: validRoomId }) + ) + }) + + // Simulate connected state + mockSocketManager.isConnected.mockReturnValue(true) + + // Change roomId + rerender({ roomId: newRoomId }) + + await waitFor(() => { + expect(mockSocketManager.disconnect).toHaveBeenCalled() + }) + + await waitFor(() => { + expect(mockSocketManager.connect).toHaveBeenCalledWith( + expect.objectContaining({ roomId: newRoomId }) + ) + }) + }) + }) + + describe('4) Auto-reconnect on brief network loss', () => { + it('should set up visibility change listener for auto-reconnect', () => { + // Mock document.hidden and visibility API + Object.defineProperty(document, 'hidden', { + writable: true, + value: false, + }) + + const addEventListenerSpy = vi.spyOn(document, 'addEventListener') + const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener') + + const { unmount } = renderHook(() => + useRoomSocket({ + roomId: validRoomId, + autoConnect: false, + }) + ) + + // Should set up visibility change listener + expect(addEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function)) + + // Should clean up listener on unmount + unmount() + expect(removeEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function)) + + addEventListenerSpy.mockRestore() + removeEventListenerSpy.mockRestore() + }) + }) + + describe('5) Role restoration from cookie', () => { + it('should restore role from cookie on connect', async () => { + vi.mocked(RoleDetectionService.getCurrentRole).mockReturnValue(ParticipantRoleEnum.ORGANIZER) + + const { result } = renderHook(() => + useRoomSocket({ + roomId: validRoomId, + autoConnect: false, + }) + ) + + act(() => { + result.current.connect() + }) + + await waitFor(() => { + expect(mockSocketManager.connect).toHaveBeenCalledWith( + expect.objectContaining({ + role: ParticipantRoleEnum.ORGANIZER, + }) + ) + }) + }) + }) + + describe("6) Emits during reconnect don't break UI", () => { + it('should queue emits when socket is connecting and send them when connected', async () => { + const { result } = renderHook(() => + useRoomSocket({ + roomId: validRoomId, + autoConnect: false, + }) + ) + + // Try to emit while disconnected + act(() => { + result.current.emitRoomStateUpdate({ + participants: [], + wheelState: { isSpinning: false }, + timerState: { isActive: false, currentTime: 0, maxTime: 600 }, + sessionActive: true, + }) + }) + + // Should not emit immediately + expect(mockSocketManager.emitRoomStateUpdate).not.toHaveBeenCalled() + + // Now connect + act(() => { + result.current.connect() + }) + + // Wait for connection setup + await waitFor(() => { + expect(mockSocketManager.onConnection).toHaveBeenCalled() + }) + + // Simulate connection success + act(() => { + const onConnectionCallback = mockSocketManager.onConnection.mock.calls[0][0] + onConnectionCallback({ + roomId: validRoomId, + timestamp: new Date().toISOString(), + message: 'Connected', + socketId: 'socket-123', + }) + }) + + // Should now emit the queued message + expect(mockSocketManager.emitRoomStateUpdate).toHaveBeenCalledWith({ + participants: [], + wheelState: { isSpinning: false }, + timerState: { isActive: false, currentTime: 0, maxTime: 600 }, + sessionActive: true, + }) + }) + + it('should emit immediately when connected', async () => { + const { result } = renderHook(() => + useRoomSocket({ + roomId: validRoomId, + autoConnect: true, + }) + ) + + // Simulate connected state + mockSocketManager.isConnected.mockReturnValue(true) + + act(() => { + result.current.emitParticipantUpdate({ + participant: { + id: 'participant-1', + name: 'Test User', + status: 'queued', + role: 'guest', + joinedAt: new Date(), + lastUpdatedAt: new Date(), + lastSelectedAt: null, + }, + action: 'added', + }) + }) + + expect(mockSocketManager.emitParticipantUpdate).toHaveBeenCalledWith({ + participant: { + id: 'participant-1', + name: 'Test User', + status: 'queued', + role: 'guest', + joinedAt: expect.any(Date), + lastUpdatedAt: expect.any(Date), + lastSelectedAt: null, + }, + action: 'added', + }) + }) + }) + + describe('7) Subscribe to room state diffs', () => { + it('should set up subscription and return cleanup function', () => { + const { result } = renderHook(() => + useRoomSocket({ + roomId: validRoomId, + autoConnect: true, + }) + ) + + const mockCallback = vi.fn() + const cleanup = result.current.onStateUpdate(mockCallback) + + expect(mockSocketManager.onRoomStateUpdate).toHaveBeenCalledWith(mockCallback) + expect(typeof cleanup).toBe('function') + + // Call cleanup + cleanup() + expect(mockSocketManager.off).toHaveBeenCalledWith('room_state_update', mockCallback) + }) + }) + + describe('8) Connection errors are visible to the UI', () => { + it('should set error status when connection fails', async () => { + mockSocketManager.connect.mockRejectedValue(new Error('Connection failed')) + + const { result } = renderHook(() => + useRoomSocket({ + roomId: validRoomId, + autoConnect: true, + }) + ) + + await waitFor(() => { + expect(result.current.status).toBe('error') + expect(result.current.error).toBe('Connection failed') + }) + }) + + it('should handle socket errors via error callback', async () => { + const { result } = renderHook(() => + useRoomSocket({ + roomId: validRoomId, + autoConnect: true, + }) + ) + + // Wait for connection setup + await waitFor(() => { + expect(mockSocketManager.onError).toHaveBeenCalled() + }) + + // Simulate error + act(() => { + const errorCallback = mockSocketManager.onError.mock.calls[0][0] + errorCallback({ + roomId: validRoomId, + timestamp: new Date().toISOString(), + error: 'Network error', + }) + }) + + expect(result.current.status).toBe('error') + expect(result.current.error).toBe('Network error') + }) + }) + + describe('9) Invalid roomId', () => { + it('should not connect and show error for invalid roomId', async () => { + const { result } = renderHook(() => + useRoomSocket({ + roomId: invalidRoomId, + autoConnect: true, + }) + ) + + await waitFor(() => { + expect(result.current.status).toBe('error') + expect(result.current.error).toBe('Invalid room ID format. Expected UUID v4.') + }) + + expect(mockSocketManager.connect).not.toHaveBeenCalled() + }) + + it('should not connect for empty roomId', async () => { + const { result } = renderHook(() => + useRoomSocket({ + roomId: '', + autoConnect: true, + }) + ) + + await waitFor(() => { + expect(result.current.status).toBe('error') + expect(result.current.error).toBe('Invalid room ID format. Expected UUID v4.') + }) + + expect(mockSocketManager.connect).not.toHaveBeenCalled() + }) + }) + + describe('10) Multiple tabs support', () => { + it('should allow multiple hook instances for the same room', async () => { + // Clear mocks to get clean counts + vi.clearAllMocks() + + const { result: result1 } = renderHook(() => + useRoomSocket({ + roomId: validRoomId, + autoConnect: false, // Use manual connect to control timing + }) + ) + + const { result: result2 } = renderHook(() => + useRoomSocket({ + roomId: validRoomId, + autoConnect: false, + }) + ) + + // Connect both manually + act(() => { + result1.current.connect() + result2.current.connect() + }) + + // Both should attempt to connect + await waitFor(() => { + expect(mockSocketManager.connect).toHaveBeenCalledTimes(2) + }) + + // Both should have the same configuration + expect(mockSocketManager.connect).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ roomId: validRoomId }) + ) + expect(mockSocketManager.connect).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ roomId: validRoomId }) + ) + }) + }) + + describe('11) Manual connection control', () => { + it('should not auto-connect when autoConnect is false', async () => { + renderHook(() => + useRoomSocket({ + roomId: validRoomId, + autoConnect: false, + }) + ) + + // Wait a bit to ensure no automatic connection + await new Promise(resolve => setTimeout(resolve, 100)) + + expect(mockSocketManager.connect).not.toHaveBeenCalled() + }) + + it('should connect manually when connect() is called', async () => { + const { result } = renderHook(() => + useRoomSocket({ + roomId: validRoomId, + autoConnect: false, + }) + ) + + expect(mockSocketManager.connect).not.toHaveBeenCalled() + + act(() => { + result.current.connect() + }) + + expect(mockSocketManager.connect).toHaveBeenCalled() + }) + }) + + describe('12) Proper disconnect functionality', () => { + it('should disconnect and clear state when disconnect() is called', async () => { + const { result } = renderHook(() => + useRoomSocket({ + roomId: validRoomId, + autoConnect: true, + }) + ) + + // Wait for connection + await waitFor(() => { + expect(mockSocketManager.connect).toHaveBeenCalled() + }) + + // Simulate connected state + act(() => { + const onConnectionCallback = mockSocketManager.onConnection.mock.calls[0][0] + onConnectionCallback({ + roomId: validRoomId, + timestamp: new Date().toISOString(), + message: 'Connected', + socketId: 'socket-123', + }) + }) + + expect(result.current.isConnected).toBe(true) + expect(result.current.socketId).toBe('socket-123') + + // Disconnect + await act(async () => { + await result.current.disconnect() + }) + + expect(mockSocketManager.disconnect).toHaveBeenCalled() + expect(result.current.status).toBe('disconnected') + expect(result.current.socketId).toBe(null) + expect(result.current.error).toBe(null) + }) + }) +}) diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..bacf17c --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,2 @@ +export { useRoomSocket } from './useRoomSocket' +export type { UseRoomSocketConfig, UseRoomSocketReturn } from './useRoomSocket' diff --git a/src/hooks/useRoomSocket.ts b/src/hooks/useRoomSocket.ts new file mode 100644 index 0000000..dfef729 --- /dev/null +++ b/src/hooks/useRoomSocket.ts @@ -0,0 +1,396 @@ +'use client' + +import { useEffect, useRef, useState, useCallback } from 'react' +import { getSocketManager } from '@/infrastructure/communication/socket-client' +import { RoleDetectionService } from '@/app/room/utils/role-detection' +import { ParticipantRoleEnum } from '@/domain/room/value-objects/participant-attributes' +import type { + SocketStatus, + SocketConfig, + RoomStateUpdateEvent, + ParticipantUpdateEvent, + WheelSpinEvent, + TimerUpdateEvent, + RoomMessageEvent, + ErrorEvent, + ConnectionErrorEvent, +} from '@/types/socket' + +/** + * Configuration for the useRoomSocket hook + */ +export interface UseRoomSocketConfig { + roomId: string + userId?: string + userName?: string + /** URL for socket connection, defaults to current origin */ + url?: string + /** Auto-connect on mount, defaults to true */ + autoConnect?: boolean +} + +/** + * Return type for the useRoomSocket hook + */ +export interface UseRoomSocketReturn { + /** Current connection status */ + status: SocketStatus + /** Connection error if any */ + error: string | null + /** Socket ID when connected */ + socketId: string | null + /** Current user role */ + role: ParticipantRoleEnum + /** Whether socket is connected */ + isConnected: boolean + /** Connect to the room */ + connect: () => void + /** Disconnect from the room */ + disconnect: () => void + /** Subscribe to room state updates */ + onStateUpdate: (callback: (data: RoomStateUpdateEvent) => void) => () => void + /** Emit room state update */ + emitRoomStateUpdate: (data: Omit) => void + /** Emit participant update */ + emitParticipantUpdate: (data: Omit) => void + /** Emit wheel spin event */ + emitWheelSpin: (data: Omit) => void + /** Emit timer update */ + emitTimerUpdate: (data: Omit) => void + /** Emit room message */ + emitRoomMessage: (data: Omit) => void +} + +/** + * Hook for managing Socket.IO connection to a room with auto-reconnect + * + * This hook provides: + * - Automatic connection management with room namespace + * - Auto-reconnect functionality for network loss and tab backgrounding + * - Role detection and restoration from cookies + * - Event subscription management with cleanup + * - Error handling and connection state management + * - Graceful handling of emit during reconnect states + */ +export function useRoomSocket(config: UseRoomSocketConfig): UseRoomSocketReturn { + const { + roomId, + userId, + userName, + url = typeof window !== 'undefined' ? window.location.origin : '', + autoConnect = true, + } = config + + // State management + const [status, setStatus] = useState('disconnected') + const [error, setError] = useState(null) + const [socketId, setSocketId] = useState(null) + const [role, setRole] = useState(ParticipantRoleEnum.GUEST) + + // Refs for cleanup and preventing stale closures + const socketManagerRef = useRef(getSocketManager()) + const currentRoomIdRef = useRef(null) + const pendingEmitsRef = useRef>([]) + const statusUpdateCallbackRef = useRef<((status: SocketStatus) => void) | null>(null) + + // Validate roomId + const isValidRoomId = useCallback((id: string): boolean => { + if (!id || typeof id !== 'string') return false + // Check if it's a valid UUID v4 + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + return uuidRegex.test(id) + }, []) + + // Update role from cookies + const updateRole = useCallback(() => { + if (typeof window === 'undefined') return + + const currentRole = RoleDetectionService.getCurrentRole() + setRole(currentRole) + }, []) + + // Handle connection status changes + const handleStatusChange = useCallback((newStatus: SocketStatus) => { + setStatus(newStatus) + + // Clear error when successfully connected + if (newStatus === 'connected') { + setError(null) + // Process any pending emits + const pendingEmits = pendingEmitsRef.current + if (pendingEmits.length > 0) { + pendingEmits.forEach(({ type, data }) => { + // Process each pending emit based on its type + const manager = socketManagerRef.current + switch (type) { + case 'room_state_update': + manager.emitRoomStateUpdate( + data as Omit + ) + break + case 'participant_update': + manager.emitParticipantUpdate( + data as Omit + ) + break + case 'wheel_spin': + manager.emitWheelSpin(data as Omit) + break + case 'timer_update': + manager.emitTimerUpdate(data as Omit) + break + case 'room_message': + manager.emitRoomMessage(data as Omit) + break + default: + console.warn(`Unknown pending emit type: ${type}`) + } + }) + pendingEmitsRef.current = [] + } + } + + // Notify external callback if set + if (statusUpdateCallbackRef.current) { + statusUpdateCallbackRef.current(newStatus) + } + }, []) + + // Handle errors + const handleError = useCallback((errorData: ErrorEvent | ConnectionErrorEvent) => { + const errorMessage = errorData.error + setError(errorMessage) + setStatus('error') + }, []) + + // Connect to room + const connect = useCallback(async () => { + if (!isValidRoomId(roomId)) { + setError('Invalid room ID format. Expected UUID v4.') + setStatus('error') + return + } + + try { + setStatus('connecting') + setError(null) + + // Update role from cookies + updateRole() + + const socketConfig: SocketConfig = { + url, + roomId, + userId, + userName, + role: role, + } + + // Connect using the socket manager + await socketManagerRef.current.connect(socketConfig) + + // Set up event listeners + const manager = socketManagerRef.current + + // Connection events + manager.onConnection(data => { + setSocketId(data.socketId) + handleStatusChange('connected') + }) + + manager.onError(handleError) + + // Track current room + currentRoomIdRef.current = roomId + + handleStatusChange('connected') + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Connection failed' + setError(errorMessage) + setStatus('error') + } + }, [ + roomId, + userId, + userName, + url, + role, + isValidRoomId, + updateRole, + handleStatusChange, + handleError, + ]) + + // Disconnect from room + const disconnect = useCallback(async () => { + try { + await socketManagerRef.current.disconnect() + setStatus('disconnected') + setSocketId(null) + setError(null) + currentRoomIdRef.current = null + pendingEmitsRef.current = [] + } catch (err) { + console.warn('Error during disconnect:', err) + } + }, []) + + // Subscribe to room state updates + const onStateUpdate = useCallback((callback: (data: RoomStateUpdateEvent) => void) => { + const manager = socketManagerRef.current + + // Set up the listener + manager.onRoomStateUpdate(callback) + + // Return cleanup function + return () => { + manager.off('room_state_update', callback) + } + }, []) + + // Emit functions + const emitRoomStateUpdate = useCallback( + (data: Omit) => { + const manager = socketManagerRef.current + + if (manager.isConnected()) { + manager.emitRoomStateUpdate(data) + } else if (status === 'connecting' || status === 'disconnected') { + pendingEmitsRef.current.push({ type: 'room_state_update', data }) + } else { + console.warn('Cannot emit room_state_update: socket is in error state') + } + }, + [status] + ) + + const emitParticipantUpdate = useCallback( + (data: Omit) => { + const manager = socketManagerRef.current + + if (manager.isConnected()) { + manager.emitParticipantUpdate(data) + } else if (status === 'connecting' || status === 'disconnected') { + pendingEmitsRef.current.push({ type: 'participant_update', data }) + } else { + console.warn('Cannot emit participant_update: socket is in error state') + } + }, + [status] + ) + + const emitWheelSpin = useCallback( + (data: Omit) => { + const manager = socketManagerRef.current + + if (manager.isConnected()) { + manager.emitWheelSpin(data) + } else if (status === 'connecting' || status === 'disconnected') { + pendingEmitsRef.current.push({ type: 'wheel_spin', data }) + } else { + console.warn('Cannot emit wheel_spin: socket is in error state') + } + }, + [status] + ) + + const emitTimerUpdate = useCallback( + (data: Omit) => { + const manager = socketManagerRef.current + + if (manager.isConnected()) { + manager.emitTimerUpdate(data) + } else if (status === 'connecting' || status === 'disconnected') { + pendingEmitsRef.current.push({ type: 'timer_update', data }) + } else { + console.warn('Cannot emit timer_update: socket is in error state') + } + }, + [status] + ) + + const emitRoomMessage = useCallback( + (data: Omit) => { + const manager = socketManagerRef.current + + if (manager.isConnected()) { + manager.emitRoomMessage(data) + } else if (status === 'connecting' || status === 'disconnected') { + pendingEmitsRef.current.push({ type: 'room_message', data }) + } else { + console.warn('Cannot emit room_message: socket is in error state') + } + }, + [status] + ) + + // Handle roomId changes - reconnect to new room + useEffect(() => { + const prevRoomId = currentRoomIdRef.current + + if (prevRoomId && prevRoomId !== roomId && socketManagerRef.current.isConnected()) { + // Room ID changed, disconnect from previous and connect to new + disconnect().then(() => { + if (autoConnect) { + connect() + } + }) + } else if (!prevRoomId && autoConnect) { + // Initial connection + connect() + } + }, [roomId, autoConnect, connect, disconnect]) + + // Handle role changes + useEffect(() => { + updateRole() + }, [updateRole]) + + // Handle visibility change for auto-reconnect after tab backgrounding + useEffect(() => { + if (typeof window === 'undefined') return + + const handleVisibilityChange = () => { + if (!document.hidden && status === 'disconnected' && currentRoomIdRef.current) { + // Tab became visible and we're disconnected, try to reconnect + connect() + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [status, connect]) + + // Cleanup on unmount + useEffect(() => { + const manager = socketManagerRef.current + return () => { + // Remove all listeners and disconnect + if (manager.isConnected()) { + manager.removeAllListeners() + manager.disconnect() + } + currentRoomIdRef.current = null + pendingEmitsRef.current = [] + } + }, []) + + return { + status, + error, + socketId, + role, + isConnected: status === 'connected', + connect, + disconnect, + onStateUpdate, + emitRoomStateUpdate, + emitParticipantUpdate, + emitWheelSpin, + emitTimerUpdate, + emitRoomMessage, + } +}