import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'; import { io, Socket } from 'socket.io-client'; import { getWebSocketUrl } from '../services/websocketService'; import { toastService } from '@/lib/toast'; interface WebSocketState { isConnected: boolean; isConnecting: boolean; error: string | null; socketId?: string; } interface WebSocketContextType { socket: Socket | null; state: WebSocketState; connect: (token: string) => void; disconnect: () => void; joinFocusGroup: (focusGroupId: string) => void; leaveFocusGroup: (focusGroupId: string) => void; on: (event: string, listener: (...args: any[]) => void) => () => void; // Returns cleanup function emit: (event: string, data?: any) => void; } const WebSocketContext = createContext(null); interface WebSocketProviderProps { children: React.ReactNode; } /** * Singleton WebSocket Provider * Provides stable WebSocket connection and event management across the application. * Based on GPT-5 analysis to fix listener unbinding issues during AI mode. */ export function WebSocketProvider({ children }: WebSocketProviderProps) { // Single socket instance - never recreated const socketRef = useRef(null); const [state, setState] = useState({ isConnected: false, isConnecting: false, error: null, }); // Stable event listener registry - persists through reconnects const listenersRef = useRef(new Map void>>()); const currentTokenRef = useRef(null); const currentFocusGroupRef = useRef(null); // Stable logging function const log = useCallback((message: string, ...args: any[]) => { console.log(`[WebSocket-Singleton] ${message}`, ...args); }, []); // Stable state updater const updateState = useCallback((updates: Partial) => { setState(prev => ({ ...prev, ...updates })); }, []); // Stable event listener management with cleanup function return const on = useCallback((event: string, listener: (...args: any[]) => void): (() => void) => { log(`Adding listener for event: ${event}`); // Add listener to registry if (!listenersRef.current.has(event)) { listenersRef.current.set(event, new Set()); } listenersRef.current.get(event)!.add(listener); // If socket exists, bind listener immediately if (socketRef.current) { socketRef.current.on(event, listener); log(`Bound listener to socket for event: ${event}`); } // Return cleanup function return () => { log(`Removing listener for event: ${event}`); const eventListeners = listenersRef.current.get(event); if (eventListeners) { eventListeners.delete(listener); if (eventListeners.size === 0) { listenersRef.current.delete(event); } } // Remove from socket if exists if (socketRef.current) { socketRef.current.off(event, listener); } }; }, [log]); // Stable emit function const emit = useCallback((event: string, data?: any) => { if (socketRef.current?.connected) { socketRef.current.emit(event, data); log(`Emitted event: ${event}`, data); } else { log(`Cannot emit ${event}: not connected`); } }, [log]); // Stable room management functions const joinFocusGroup = useCallback((focusGroupId: string) => { if (!socketRef.current?.connected) { log('Cannot join focus group: not connected'); return; } log(`Joining focus group: ${focusGroupId}`); console.log('🔍 [GPT-5] JOIN socket.id:', socketRef.current.id); currentFocusGroupRef.current = focusGroupId; socketRef.current.emit('join_focus_group', { focus_group_id: focusGroupId }); }, [log]); const leaveFocusGroup = useCallback((focusGroupId: string) => { if (!socketRef.current?.connected) { log('Cannot leave focus group: not connected'); return; } log(`Leaving focus group: ${focusGroupId}`); if (currentFocusGroupRef.current === focusGroupId) { currentFocusGroupRef.current = null; } socketRef.current.emit('leave_focus_group', { focus_group_id: focusGroupId }); }, [log]); // Bind all registered listeners to socket const bindAllListeners = useCallback(() => { if (!socketRef.current) return; log(`Binding ${listenersRef.current.size} event types to socket`); for (const [event, listeners] of listenersRef.current.entries()) { for (const listener of listeners) { socketRef.current.on(event, listener); log(`Bound listener for event: ${event}`); } } }, [log]); // Stable connect function const connect = useCallback((token: string) => { if (!token) { log('Cannot connect: no token provided'); return; } if (socketRef.current?.connected && currentTokenRef.current === token) { log('Already connected with same token'); return; } updateState({ isConnecting: true, error: null }); // Disconnect existing socket if different token if (socketRef.current && currentTokenRef.current !== token) { log('Disconnecting existing socket (token changed)'); socketRef.current.disconnect(); socketRef.current = null; } currentTokenRef.current = token; if (!socketRef.current) { const baseUrl = getWebSocketUrl(); log(`Creating new socket connection to: ${baseUrl}`); const socketOptions: any = { auth: { token }, transports: ['websocket'], upgrade: true, rememberUpgrade: true, timeout: 60000, // forceNew: true, // Removed as recommended by GPT-5 - can fight with singleton pingInterval: 45000, pingTimeout: 120000 }; // Set WebSocket path from environment variable const path = import.meta.env.VITE_WEBSOCKET_PATH || '/semblance_back/socket.io/'; socketOptions.path = path; const socket = io(baseUrl, socketOptions); socketRef.current = socket; // GPT-5 DEBUG: Audit all socket.off calls to catch listener removal const originalOff = socket.off.bind(socket); socket.off = (ev: any, fn?: any) => { console.log('🚨 [SOCKET OFF]', ev, fn?.name || 'anonymous'); return originalOff(ev, fn); }; const originalOffAny = (socket as any).offAny?.bind(socket); if (originalOffAny) { (socket as any).offAny = (fn?: any) => { console.log('🚨 [SOCKET OFFANY]', fn?.name || 'anonymous'); return originalOffAny(fn); }; } const originalRemoveAllListeners = socket.removeAllListeners?.bind(socket); if (originalRemoveAllListeners) { socket.removeAllListeners = (ev?: any) => { console.log('🚨 [SOCKET REMOVE_ALL_LISTENERS]', ev || 'ALL EVENTS'); return originalRemoveAllListeners(ev); }; } // Connection handlers socket.on('connect', () => { log('✅ Connected successfully!', { socketId: socket.id }); console.log('🔍 [GPT-5] CONNECT socket.id:', socket.id); updateState({ isConnected: true, isConnecting: false, error: null, socketId: socket.id }); // Rebind all registered listeners bindAllListeners(); // Rejoin focus group if we were in one if (currentFocusGroupRef.current) { log(`Rejoining focus group: ${currentFocusGroupRef.current}`); socket.emit('join_focus_group', { focus_group_id: currentFocusGroupRef.current }); } // User feedback toastService.success('WebSocket connected - receiving real-time updates'); }); socket.on('connect_error', (error) => { console.log('🚨 [GPT-5 CONNECT_ERROR] Error:', error); console.log('🚨 [GPT-5 CONNECT_ERROR] Message:', error.message); console.log('🚨 [GPT-5 CONNECT_ERROR] Type:', error.type); console.log('🚨 [GPT-5 CONNECT_ERROR] Description:', error.description); console.log('🚨 [GPT-5 CONNECT_ERROR] Time:', new Date().toISOString()); log('Connection error:', error.message); updateState({ isConnected: false, isConnecting: false, error: `Connection failed: ${error.message}` }); toastService.error('WebSocket connection failed - using polling fallback'); }); socket.on('disconnect', (reason) => { console.log('🚨 [GPT-5 DISCONNECT] Reason:', reason); console.log('🚨 [GPT-5 DISCONNECT] Time:', new Date().toISOString()); console.log('🚨 [GPT-5 DISCONNECT] Socket ID:', socket.id); log('Disconnected:', reason); clearInterval(statusInterval); updateState({ isConnected: false, isConnecting: false, error: reason === 'io client disconnect' ? null : `Disconnected: ${reason}`, socketId: undefined }); if (reason !== 'io client disconnect') { toastService.warning('WebSocket disconnected - attempting to reconnect...'); } }); // Authentication success socket.on('connected', (data) => { log('🔐 Authentication successful', data); }); // Focus group events socket.on('joined_focus_group', (data) => { log('🏠 Joined focus group room:', data.focus_group_id); }); socket.on('left_focus_group', (data) => { log('🚪 Left focus group room:', data.focus_group_id); }); // Error handling socket.on('error', (error) => { log('Socket error:', error); updateState({ error: error.message || 'WebSocket error occurred' }); }); // DEBUG: Raw event monitoring const originalOnevent = socket.onevent; socket.onevent = function(packet) { console.log(`🔥 [WebSocket-Singleton] RAW EVENT:`, packet); return originalOnevent.call(this, packet); }; // GPT-5 DEBUG: Prove events reach browser (bypass React logic) (window as any).__seen = []; socket.onAny((ev: string, ...args: any[]) => { const timestamp = new Date().toISOString(); console.log(`🔍 [GPT-5 onAny] ${timestamp} ${ev}:`, args); console.log(`🔍 [GPT-5 onAny] socket.connected: ${socket.connected}, socket.id: ${socket.id}`); // GPT-5 DEBUG: Log message IDs to track which messages we're actually receiving if (ev === 'message_update' && args[0]?.message?.id) { console.log(`🔍 [GPT-5 MESSAGE ID] Received message ID: ${args[0].message.id} at ${timestamp}`); } (window as any).__seen.push([timestamp, ev, ...args]); }); socket.on('message_update', (d: any) => { console.log('🔍 [GPT-5 MU]', d); (window as any).__lastMU = d; }); socket.on('ai_status_update', (d: any) => { console.log('🔍 [GPT-5 AI]', d); (window as any).__lastAI = d; }); // GPT-5 DEBUG: Verify socket ID consistency console.log('🔍 [GPT-5] LISTENER socket.id:', socket.id); // GPT-5 DEBUG: Monitor connection status periodically const statusInterval = setInterval(() => { console.log(`🔍 [GPT-5 STATUS] connected: ${socket.connected}, id: ${socket.id}, time: ${new Date().toISOString()}`); }, 5000); socket.on('disconnect', () => { clearInterval(statusInterval); }); } else { // Reuse existing socket, just reconnect log('Reconnecting existing socket'); socketRef.current.connect(); } }, [updateState, bindAllListeners, log]); // Stable disconnect function const disconnect = useCallback(() => { log('Disconnecting...'); if (socketRef.current) { socketRef.current.disconnect(); socketRef.current = null; } currentTokenRef.current = null; currentFocusGroupRef.current = null; updateState({ isConnected: false, isConnecting: false, error: null, socketId: undefined }); }, [updateState, log]); // Cleanup on unmount useEffect(() => { return () => { if (socketRef.current) { log('Provider unmounting - cleaning up socket'); socketRef.current.disconnect(); } }; }, [log]); const contextValue: WebSocketContextType = { socket: socketRef.current, state, connect, disconnect, joinFocusGroup, leaveFocusGroup, on, emit, }; return ( {children} ); } // Hook to use WebSocket context export function useWebSocketContext(): WebSocketContextType { const context = useContext(WebSocketContext); if (!context) { throw new Error('useWebSocketContext must be used within a WebSocketProvider'); } return context; }