From 4de47c5f8a60b70a8ac45e0b02a73f0d36c46752 Mon Sep 17 00:00:00 2001 From: Soorej S Date: Sun, 8 Feb 2026 23:17:29 +0530 Subject: [PATCH 1/5] Fix: improve WebSocket error handling and recovery --- frontend/src/hooks/useDebateWS.ts | 156 +++++++++++++++++++++++++----- 1 file changed, 130 insertions(+), 26 deletions(-) diff --git a/frontend/src/hooks/useDebateWS.ts b/frontend/src/hooks/useDebateWS.ts index 64657fb9..23984ac5 100644 --- a/frontend/src/hooks/useDebateWS.ts +++ b/frontend/src/hooks/useDebateWS.ts @@ -10,6 +10,7 @@ import { presenceAtom, spectatorHashAtom, lastEventIdAtom, + wsErrorAtom, // <-- NEW PollInfo, } from '../atoms/debateAtoms'; import ReconnectingWebSocket from 'reconnecting-websocket'; @@ -20,6 +21,11 @@ interface Event { timestamp: number; } +// Constants for error handling +const ERROR_THROTTLE_MS = 5000; // 5 seconds between error messages +const MAX_RETRIES = 3; +const RETRY_BASE_DELAY = 1000; // 1 second initial delay + export const useDebateWS = (debateId: string | null) => { const [ws, setWs] = useAtom(wsAtom); const [, setDebateId] = useAtom(debateIdAtom); @@ -30,7 +36,11 @@ export const useDebateWS = (debateId: string | null) => { const [, setPresence] = useAtom(presenceAtom); const [, setLastEventId] = useAtom(lastEventIdAtom); const [spectatorHash] = useAtom(spectatorHashAtom); + const [, setWsError] = useAtom(wsErrorAtom); + const wsRef = useRef(null); + const lastErrorTime = useRef(0); + const retryCount = useRef(0); useEffect(() => { if (!debateId) return; @@ -62,11 +72,12 @@ export const useDebateWS = (debateId: string | null) => { } setWsStatus('connecting'); + setWsError(null); // clear old errors when reconnecting - // Get WebSocket URL const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const apiUrl = import.meta.env.VITE_API_URL; let host = window.location.host; + if (apiUrl) { try { host = new URL(apiUrl).host; @@ -97,23 +108,69 @@ export const useDebateWS = (debateId: string | null) => { setWs(rws as unknown as WebSocket); let ownsConnection = true; + const handleError = (error: unknown, context = 'WebSocket error') => { + const now = Date.now(); + if (now - lastErrorTime.current > ERROR_THROTTLE_MS) { + const errorMessage = error instanceof Error + ? `${context}: ${error.message}` + : `${context}: An unknown error occurred`; + + console.error(errorMessage, error); + setWsError(errorMessage); + lastErrorTime.current = now; + } + setWsStatus('error'); + }; + + const handleReconnect = () => { + if (retryCount.current < MAX_RETRIES) { + const delay = RETRY_BASE_DELAY * Math.pow(2, retryCount.current); + retryCount.current++; + + console.log(`Attempting to reconnect (${retryCount.current}/${MAX_RETRIES})...`); + + setTimeout(() => { + if (wsRef.current) { + wsRef.current.reconnect(); + } + }, delay); + } else { + handleError(new Error('Max reconnection attempts reached')); + } + }; + rws.onopen = () => { + // Reset retry counter on successful connection + retryCount.current = 0; setWsStatus('connected'); + setWsError(null); - const spectatorHashValue = - spectatorHash || localStorage.getItem('spectatorHash') || ''; - const joinMessage = { - type: 'join', - payload: { - spectatorHash: spectatorHashValue, - }, - }; - rws.send(JSON.stringify(joinMessage)); + const spectatorHashValue = spectatorHash || localStorage.getItem('spectatorHash') || ''; + + try { + const joinMessage = { + type: 'join', + payload: { spectatorHash: spectatorHashValue }, + }; + rws.send(JSON.stringify(joinMessage)); + } catch (error) { + handleError(error, 'Failed to send join message'); + } }; rws.onmessage = (event) => { try { + if (!event.data) { + throw new Error('Received empty message'); + } + const eventData: Event = JSON.parse(event.data); + + // Reset error state on successful message + if (retryCount.current > 0) { + retryCount.current = 0; + setWsError(null); + } if (eventData.type !== 'poll_snapshot' && eventData.timestamp) { setLastEventId(String(eventData.timestamp)); @@ -123,36 +180,45 @@ export const useDebateWS = (debateId: string | null) => { case 'poll_snapshot': { const payload = eventData.payload || {}; const pollsPayload = payload.polls; + if (Array.isArray(pollsPayload)) { const nextState: Record = {}; + pollsPayload.forEach((poll) => { if (!poll) return; + const pollId = typeof poll.pollId === 'string' ? poll.pollId : String(poll.pollId ?? ''); + if (!pollId) return; + const countsRaw = poll.counts || {}; const counts: Record = {}; + if (countsRaw && typeof countsRaw === 'object') { Object.entries(countsRaw).forEach(([option, value]) => { - const numericValue = + counts[option] = typeof value === 'number' ? value : Number(value ?? 0) || 0; - counts[option] = numericValue; }); } + let options: string[] = []; + if (Array.isArray(poll.options)) { options = poll.options .map((opt: unknown) => String(opt ?? '').trim()) .filter((opt: string) => opt.length > 0); } + if (options.length === 0) { options = Object.keys(counts); } - const info: PollInfo = { + + nextState[pollId] = { pollId, question: typeof poll.question === 'string' ? poll.question : '', @@ -163,18 +229,20 @@ export const useDebateWS = (debateId: string | null) => { ? poll.voters : Number(poll.voters ?? 0) || 0, }; - nextState[pollId] = info; }); + setPollState(nextState); } else if (payload.pollState) { - // Backwards compatibility: convert legacy structure const legacyState = payload.pollState as Record< string, Record >; + const legacyResult: Record = {}; + Object.entries(legacyState).forEach(([pollId, counts]) => { const options = Object.keys(counts || {}); + legacyResult[pollId] = { pollId, question: '', @@ -186,6 +254,7 @@ export const useDebateWS = (debateId: string | null) => { : 0, }; }); + setPollState(legacyResult); } break; @@ -195,11 +264,14 @@ export const useDebateWS = (debateId: string | null) => { setPollState((prev) => { const pollId = eventData.payload?.pollId; const option = eventData.payload?.option; + if (typeof pollId !== 'string' || typeof option !== 'string') { return prev; } + const nextState = { ...prev }; const existing = nextState[pollId]; + if (!existing) { nextState[pollId] = { pollId, @@ -210,44 +282,54 @@ export const useDebateWS = (debateId: string | null) => { }; return nextState; } + const nextCounts = { ...existing.counts }; nextCounts[option] = (nextCounts[option] || 0) + 1; + const nextOptions = existing.options.includes(option) ? existing.options : [...existing.options, option]; + nextState[pollId] = { ...existing, options: nextOptions, counts: nextCounts, }; + return nextState; }); break; case 'poll_created': { const poll = eventData.payload; + if (poll && poll.pollId) { setPollState((prev) => { const pollId = String(poll.pollId); const countsRaw = poll.counts || {}; const counts: Record = {}; + Object.entries(countsRaw).forEach(([option, value]) => { counts[option] = typeof value === 'number' ? value : Number(value ?? 0) || 0; }); + const options = Array.isArray(poll.options) ? poll.options .map((opt: unknown) => String(opt ?? '').trim()) .filter((opt: string) => opt.length > 0) : Object.keys(counts); + return { ...prev, [pollId]: { pollId, question: - typeof poll.question === 'string' ? poll.question : '', + typeof poll.question === 'string' + ? poll.question + : '', options, counts, voters: @@ -293,29 +375,51 @@ export const useDebateWS = (debateId: string | null) => { default: } } catch (error) { + handleError(error, 'Error processing WebSocket message'); + handleReconnect(); } }; - rws.onerror = () => { - setWsStatus('error'); + rws.onerror = (error) => { + handleError(error, 'WebSocket connection error'); + handleReconnect(); }; - rws.onclose = () => { - setWsStatus('disconnected'); + rws.onclose = (event) => { + // Don't treat normal closure as error + if (event.code !== 1000) { // 1000 is normal closure + handleError(new Error(`Connection closed with code ${event.code}: ${event.reason || 'Unknown reason'}`)); + handleReconnect(); + } else { + setWsStatus('disconnected'); + setWsError(null); + } + setWs(null); + if (wsRef.current === rws) { wsRef.current = null; } }; + // Cleanup function return () => { if (ownsConnection) { - rws.close(); - if (wsRef.current === rws) { - wsRef.current = null; + try { + if (rws.readyState === WebSocket.OPEN) { + rws.close(1000, 'Component unmounting'); + } + rws.close(); + } catch (error) { + console.error('Error during WebSocket cleanup:', error); + } finally { + if (wsRef.current === rws) { + wsRef.current = null; + } + setWs(null); + setWsStatus('disconnected'); + setWsError(null); } - setWs(null); - setWsStatus('disconnected'); } ownsConnection = false; }; @@ -331,6 +435,7 @@ export const useDebateWS = (debateId: string | null) => { setWsStatus, setLastEventId, setPresence, + setWsError, ]); const sendMessage = (type: string, payload: any) => { @@ -345,4 +450,3 @@ export const useDebateWS = (debateId: string | null) => { ws: wsRef.current, }; }; - From 5a39ce68fbf199dceb88f1e14c020a2b4d843661 Mon Sep 17 00:00:00 2001 From: Soorej S Date: Fri, 13 Mar 2026 11:14:29 +0530 Subject: [PATCH 2/5] fix(ws): improve WebSocket error handling and lifecycle management --- frontend/src/hooks/useDebateWS.ts | 156 +++++------------------------- 1 file changed, 26 insertions(+), 130 deletions(-) diff --git a/frontend/src/hooks/useDebateWS.ts b/frontend/src/hooks/useDebateWS.ts index 23984ac5..64657fb9 100644 --- a/frontend/src/hooks/useDebateWS.ts +++ b/frontend/src/hooks/useDebateWS.ts @@ -10,7 +10,6 @@ import { presenceAtom, spectatorHashAtom, lastEventIdAtom, - wsErrorAtom, // <-- NEW PollInfo, } from '../atoms/debateAtoms'; import ReconnectingWebSocket from 'reconnecting-websocket'; @@ -21,11 +20,6 @@ interface Event { timestamp: number; } -// Constants for error handling -const ERROR_THROTTLE_MS = 5000; // 5 seconds between error messages -const MAX_RETRIES = 3; -const RETRY_BASE_DELAY = 1000; // 1 second initial delay - export const useDebateWS = (debateId: string | null) => { const [ws, setWs] = useAtom(wsAtom); const [, setDebateId] = useAtom(debateIdAtom); @@ -36,11 +30,7 @@ export const useDebateWS = (debateId: string | null) => { const [, setPresence] = useAtom(presenceAtom); const [, setLastEventId] = useAtom(lastEventIdAtom); const [spectatorHash] = useAtom(spectatorHashAtom); - const [, setWsError] = useAtom(wsErrorAtom); - const wsRef = useRef(null); - const lastErrorTime = useRef(0); - const retryCount = useRef(0); useEffect(() => { if (!debateId) return; @@ -72,12 +62,11 @@ export const useDebateWS = (debateId: string | null) => { } setWsStatus('connecting'); - setWsError(null); // clear old errors when reconnecting + // Get WebSocket URL const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const apiUrl = import.meta.env.VITE_API_URL; let host = window.location.host; - if (apiUrl) { try { host = new URL(apiUrl).host; @@ -108,69 +97,23 @@ export const useDebateWS = (debateId: string | null) => { setWs(rws as unknown as WebSocket); let ownsConnection = true; - const handleError = (error: unknown, context = 'WebSocket error') => { - const now = Date.now(); - if (now - lastErrorTime.current > ERROR_THROTTLE_MS) { - const errorMessage = error instanceof Error - ? `${context}: ${error.message}` - : `${context}: An unknown error occurred`; - - console.error(errorMessage, error); - setWsError(errorMessage); - lastErrorTime.current = now; - } - setWsStatus('error'); - }; - - const handleReconnect = () => { - if (retryCount.current < MAX_RETRIES) { - const delay = RETRY_BASE_DELAY * Math.pow(2, retryCount.current); - retryCount.current++; - - console.log(`Attempting to reconnect (${retryCount.current}/${MAX_RETRIES})...`); - - setTimeout(() => { - if (wsRef.current) { - wsRef.current.reconnect(); - } - }, delay); - } else { - handleError(new Error('Max reconnection attempts reached')); - } - }; - rws.onopen = () => { - // Reset retry counter on successful connection - retryCount.current = 0; setWsStatus('connected'); - setWsError(null); - const spectatorHashValue = spectatorHash || localStorage.getItem('spectatorHash') || ''; - - try { - const joinMessage = { - type: 'join', - payload: { spectatorHash: spectatorHashValue }, - }; - rws.send(JSON.stringify(joinMessage)); - } catch (error) { - handleError(error, 'Failed to send join message'); - } + const spectatorHashValue = + spectatorHash || localStorage.getItem('spectatorHash') || ''; + const joinMessage = { + type: 'join', + payload: { + spectatorHash: spectatorHashValue, + }, + }; + rws.send(JSON.stringify(joinMessage)); }; rws.onmessage = (event) => { try { - if (!event.data) { - throw new Error('Received empty message'); - } - const eventData: Event = JSON.parse(event.data); - - // Reset error state on successful message - if (retryCount.current > 0) { - retryCount.current = 0; - setWsError(null); - } if (eventData.type !== 'poll_snapshot' && eventData.timestamp) { setLastEventId(String(eventData.timestamp)); @@ -180,45 +123,36 @@ export const useDebateWS = (debateId: string | null) => { case 'poll_snapshot': { const payload = eventData.payload || {}; const pollsPayload = payload.polls; - if (Array.isArray(pollsPayload)) { const nextState: Record = {}; - pollsPayload.forEach((poll) => { if (!poll) return; - const pollId = typeof poll.pollId === 'string' ? poll.pollId : String(poll.pollId ?? ''); - if (!pollId) return; - const countsRaw = poll.counts || {}; const counts: Record = {}; - if (countsRaw && typeof countsRaw === 'object') { Object.entries(countsRaw).forEach(([option, value]) => { - counts[option] = + const numericValue = typeof value === 'number' ? value : Number(value ?? 0) || 0; + counts[option] = numericValue; }); } - let options: string[] = []; - if (Array.isArray(poll.options)) { options = poll.options .map((opt: unknown) => String(opt ?? '').trim()) .filter((opt: string) => opt.length > 0); } - if (options.length === 0) { options = Object.keys(counts); } - - nextState[pollId] = { + const info: PollInfo = { pollId, question: typeof poll.question === 'string' ? poll.question : '', @@ -229,20 +163,18 @@ export const useDebateWS = (debateId: string | null) => { ? poll.voters : Number(poll.voters ?? 0) || 0, }; + nextState[pollId] = info; }); - setPollState(nextState); } else if (payload.pollState) { + // Backwards compatibility: convert legacy structure const legacyState = payload.pollState as Record< string, Record >; - const legacyResult: Record = {}; - Object.entries(legacyState).forEach(([pollId, counts]) => { const options = Object.keys(counts || {}); - legacyResult[pollId] = { pollId, question: '', @@ -254,7 +186,6 @@ export const useDebateWS = (debateId: string | null) => { : 0, }; }); - setPollState(legacyResult); } break; @@ -264,14 +195,11 @@ export const useDebateWS = (debateId: string | null) => { setPollState((prev) => { const pollId = eventData.payload?.pollId; const option = eventData.payload?.option; - if (typeof pollId !== 'string' || typeof option !== 'string') { return prev; } - const nextState = { ...prev }; const existing = nextState[pollId]; - if (!existing) { nextState[pollId] = { pollId, @@ -282,54 +210,44 @@ export const useDebateWS = (debateId: string | null) => { }; return nextState; } - const nextCounts = { ...existing.counts }; nextCounts[option] = (nextCounts[option] || 0) + 1; - const nextOptions = existing.options.includes(option) ? existing.options : [...existing.options, option]; - nextState[pollId] = { ...existing, options: nextOptions, counts: nextCounts, }; - return nextState; }); break; case 'poll_created': { const poll = eventData.payload; - if (poll && poll.pollId) { setPollState((prev) => { const pollId = String(poll.pollId); const countsRaw = poll.counts || {}; const counts: Record = {}; - Object.entries(countsRaw).forEach(([option, value]) => { counts[option] = typeof value === 'number' ? value : Number(value ?? 0) || 0; }); - const options = Array.isArray(poll.options) ? poll.options .map((opt: unknown) => String(opt ?? '').trim()) .filter((opt: string) => opt.length > 0) : Object.keys(counts); - return { ...prev, [pollId]: { pollId, question: - typeof poll.question === 'string' - ? poll.question - : '', + typeof poll.question === 'string' ? poll.question : '', options, counts, voters: @@ -375,51 +293,29 @@ export const useDebateWS = (debateId: string | null) => { default: } } catch (error) { - handleError(error, 'Error processing WebSocket message'); - handleReconnect(); } }; - rws.onerror = (error) => { - handleError(error, 'WebSocket connection error'); - handleReconnect(); + rws.onerror = () => { + setWsStatus('error'); }; - rws.onclose = (event) => { - // Don't treat normal closure as error - if (event.code !== 1000) { // 1000 is normal closure - handleError(new Error(`Connection closed with code ${event.code}: ${event.reason || 'Unknown reason'}`)); - handleReconnect(); - } else { - setWsStatus('disconnected'); - setWsError(null); - } - + rws.onclose = () => { + setWsStatus('disconnected'); setWs(null); - if (wsRef.current === rws) { wsRef.current = null; } }; - // Cleanup function return () => { if (ownsConnection) { - try { - if (rws.readyState === WebSocket.OPEN) { - rws.close(1000, 'Component unmounting'); - } - rws.close(); - } catch (error) { - console.error('Error during WebSocket cleanup:', error); - } finally { - if (wsRef.current === rws) { - wsRef.current = null; - } - setWs(null); - setWsStatus('disconnected'); - setWsError(null); + rws.close(); + if (wsRef.current === rws) { + wsRef.current = null; } + setWs(null); + setWsStatus('disconnected'); } ownsConnection = false; }; @@ -435,7 +331,6 @@ export const useDebateWS = (debateId: string | null) => { setWsStatus, setLastEventId, setPresence, - setWsError, ]); const sendMessage = (type: string, payload: any) => { @@ -450,3 +345,4 @@ export const useDebateWS = (debateId: string | null) => { ws: wsRef.current, }; }; + From 446cd10006c12d0d822ad3935d968655c387d94e Mon Sep 17 00:00:00 2001 From: Soorej S Date: Fri, 13 Mar 2026 12:04:05 +0530 Subject: [PATCH 3/5] fix(ws): undo previous file change --- frontend/src/hooks/useDebateWS.ts | 134 ++++++++++++++++++++++++------ 1 file changed, 107 insertions(+), 27 deletions(-) diff --git a/frontend/src/hooks/useDebateWS.ts b/frontend/src/hooks/useDebateWS.ts index 64657fb9..6265fb60 100644 --- a/frontend/src/hooks/useDebateWS.ts +++ b/frontend/src/hooks/useDebateWS.ts @@ -10,6 +10,7 @@ import { presenceAtom, spectatorHashAtom, lastEventIdAtom, + wsErrorAtom, // <-- NEW PollInfo, } from '../atoms/debateAtoms'; import ReconnectingWebSocket from 'reconnecting-websocket'; @@ -30,7 +31,11 @@ export const useDebateWS = (debateId: string | null) => { const [, setPresence] = useAtom(presenceAtom); const [, setLastEventId] = useAtom(lastEventIdAtom); const [spectatorHash] = useAtom(spectatorHashAtom); + const [, setWsError] = useAtom(wsErrorAtom); + const wsRef = useRef(null); + const lastErrorTime = useRef(0); + const retryCount = useRef(0); useEffect(() => { if (!debateId) return; @@ -62,11 +67,12 @@ export const useDebateWS = (debateId: string | null) => { } setWsStatus('connecting'); + setWsError(null); // clear old errors when reconnecting - // Get WebSocket URL const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const apiUrl = import.meta.env.VITE_API_URL; let host = window.location.host; + if (apiUrl) { try { host = new URL(apiUrl).host; @@ -87,7 +93,7 @@ export const useDebateWS = (debateId: string | null) => { const rws = new ReconnectingWebSocket(wsUrl, [], { connectionTimeout: 4000, - maxRetries: Infinity, + maxRetries: 3, maxReconnectionDelay: 10000, minReconnectionDelay: 1000, reconnectionDelayGrowFactor: 1.3, @@ -97,23 +103,51 @@ export const useDebateWS = (debateId: string | null) => { setWs(rws as unknown as WebSocket); let ownsConnection = true; + const handleError = (error: unknown, context = 'WebSocket error') => { + const now = Date.now(); + if (now - lastErrorTime.current > 5000) { // 5 seconds throttle + const errorMessage = error instanceof Error + ? `${context}: ${error.message}` + : `${context}: An unknown error occurred`; + + console.error(errorMessage, error); + setWsError(errorMessage); + setWsStatus('error'); // Move inside throttle guard + lastErrorTime.current = now; + } + }; + rws.onopen = () => { + // Reset retry counter on successful connection setWsStatus('connected'); + setWsError(null); + + const spectatorHashValue = spectatorHash || localStorage.getItem('spectatorHash') || ''; - const spectatorHashValue = - spectatorHash || localStorage.getItem('spectatorHash') || ''; - const joinMessage = { - type: 'join', - payload: { - spectatorHash: spectatorHashValue, - }, - }; - rws.send(JSON.stringify(joinMessage)); + try { + const joinMessage = { + type: 'join', + payload: { spectatorHash: spectatorHashValue }, + }; + rws.send(JSON.stringify(joinMessage)); + } catch (error) { + handleError(error, 'Failed to send join message'); + } }; rws.onmessage = (event) => { try { + if (!event.data) { + throw new Error('Received empty message'); + } + const eventData: Event = JSON.parse(event.data); + + // Reset error state on successful message + if (retryCount.current > 0) { + retryCount.current = 0; + setWsError(null); + } if (eventData.type !== 'poll_snapshot' && eventData.timestamp) { setLastEventId(String(eventData.timestamp)); @@ -123,36 +157,45 @@ export const useDebateWS = (debateId: string | null) => { case 'poll_snapshot': { const payload = eventData.payload || {}; const pollsPayload = payload.polls; + if (Array.isArray(pollsPayload)) { const nextState: Record = {}; + pollsPayload.forEach((poll) => { if (!poll) return; + const pollId = typeof poll.pollId === 'string' ? poll.pollId : String(poll.pollId ?? ''); + if (!pollId) return; + const countsRaw = poll.counts || {}; const counts: Record = {}; + if (countsRaw && typeof countsRaw === 'object') { Object.entries(countsRaw).forEach(([option, value]) => { - const numericValue = + counts[option] = typeof value === 'number' ? value : Number(value ?? 0) || 0; - counts[option] = numericValue; }); } + let options: string[] = []; + if (Array.isArray(poll.options)) { options = poll.options .map((opt: unknown) => String(opt ?? '').trim()) .filter((opt: string) => opt.length > 0); } + if (options.length === 0) { options = Object.keys(counts); } - const info: PollInfo = { + + nextState[pollId] = { pollId, question: typeof poll.question === 'string' ? poll.question : '', @@ -163,18 +206,20 @@ export const useDebateWS = (debateId: string | null) => { ? poll.voters : Number(poll.voters ?? 0) || 0, }; - nextState[pollId] = info; }); + setPollState(nextState); } else if (payload.pollState) { - // Backwards compatibility: convert legacy structure const legacyState = payload.pollState as Record< string, Record >; + const legacyResult: Record = {}; + Object.entries(legacyState).forEach(([pollId, counts]) => { const options = Object.keys(counts || {}); + legacyResult[pollId] = { pollId, question: '', @@ -186,6 +231,7 @@ export const useDebateWS = (debateId: string | null) => { : 0, }; }); + setPollState(legacyResult); } break; @@ -195,11 +241,14 @@ export const useDebateWS = (debateId: string | null) => { setPollState((prev) => { const pollId = eventData.payload?.pollId; const option = eventData.payload?.option; + if (typeof pollId !== 'string' || typeof option !== 'string') { return prev; } + const nextState = { ...prev }; const existing = nextState[pollId]; + if (!existing) { nextState[pollId] = { pollId, @@ -210,44 +259,54 @@ export const useDebateWS = (debateId: string | null) => { }; return nextState; } + const nextCounts = { ...existing.counts }; nextCounts[option] = (nextCounts[option] || 0) + 1; + const nextOptions = existing.options.includes(option) ? existing.options : [...existing.options, option]; + nextState[pollId] = { ...existing, options: nextOptions, counts: nextCounts, }; + return nextState; }); break; case 'poll_created': { const poll = eventData.payload; + if (poll && poll.pollId) { setPollState((prev) => { const pollId = String(poll.pollId); const countsRaw = poll.counts || {}; const counts: Record = {}; + Object.entries(countsRaw).forEach(([option, value]) => { counts[option] = typeof value === 'number' ? value : Number(value ?? 0) || 0; }); + const options = Array.isArray(poll.options) ? poll.options .map((opt: unknown) => String(opt ?? '').trim()) .filter((opt: string) => opt.length > 0) : Object.keys(counts); + return { ...prev, [pollId]: { pollId, question: - typeof poll.question === 'string' ? poll.question : '', + typeof poll.question === 'string' + ? poll.question + : '', options, counts, voters: @@ -293,29 +352,50 @@ export const useDebateWS = (debateId: string | null) => { default: } } catch (error) { + handleError(error, 'Error processing WebSocket message'); } }; - rws.onerror = () => { - setWsStatus('error'); + rws.onerror = (error) => { + handleError(error, 'WebSocket connection error'); + handleReconnect(); }; - rws.onclose = () => { - setWsStatus('disconnected'); + rws.onclose = (event) => { + // Don't treat normal closure as error + if (event.code !== 1000) { // 1000 is normal closure + handleError(new Error(`Connection closed with code ${event.code}: ${event.reason || 'Unknown reason'}`)); + handleReconnect(); + } else { + setWsStatus('disconnected'); + setWsError(null); + } + setWs(null); + if (wsRef.current === rws) { wsRef.current = null; } }; + // Cleanup function return () => { if (ownsConnection) { - rws.close(); - if (wsRef.current === rws) { - wsRef.current = null; + try { + if (rws.readyState === WebSocket.OPEN) { + rws.close(1000, 'Component unmounting'); + } + rws.close(); + } catch (error) { + console.error('Error during WebSocket cleanup:', error); + } finally { + if (wsRef.current === rws) { + wsRef.current = null; + } + setWs(null); + setWsStatus('disconnected'); + setWsError(null); } - setWs(null); - setWsStatus('disconnected'); } ownsConnection = false; }; @@ -331,6 +411,7 @@ export const useDebateWS = (debateId: string | null) => { setWsStatus, setLastEventId, setPresence, + setWsError, ]); const sendMessage = (type: string, payload: any) => { @@ -345,4 +426,3 @@ export const useDebateWS = (debateId: string | null) => { ws: wsRef.current, }; }; - From c442354441f5863ccb96cba0f3d84f62848d2520 Mon Sep 17 00:00:00 2001 From: Soorej S Date: Fri, 13 Mar 2026 12:17:01 +0530 Subject: [PATCH 4/5] fix(ws): coderabbitai suggestions considered 1 --- frontend/src/hooks/useDebateWS.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/useDebateWS.ts b/frontend/src/hooks/useDebateWS.ts index 6265fb60..30cbcce2 100644 --- a/frontend/src/hooks/useDebateWS.ts +++ b/frontend/src/hooks/useDebateWS.ts @@ -358,14 +358,12 @@ export const useDebateWS = (debateId: string | null) => { rws.onerror = (error) => { handleError(error, 'WebSocket connection error'); - handleReconnect(); }; rws.onclose = (event) => { // Don't treat normal closure as error if (event.code !== 1000) { // 1000 is normal closure handleError(new Error(`Connection closed with code ${event.code}: ${event.reason || 'Unknown reason'}`)); - handleReconnect(); } else { setWsStatus('disconnected'); setWsError(null); @@ -384,8 +382,9 @@ export const useDebateWS = (debateId: string | null) => { try { if (rws.readyState === WebSocket.OPEN) { rws.close(1000, 'Component unmounting'); + } else { + rws.close(); } - rws.close(); } catch (error) { console.error('Error during WebSocket cleanup:', error); } finally { From 63069ef24b42e9810e6e7e434b9f3c8a4ebdff07 Mon Sep 17 00:00:00 2001 From: Soorej S Date: Fri, 13 Mar 2026 12:56:58 +0530 Subject: [PATCH 5/5] fix(ws): coderabbitai suggestions considered 2 and remove retry logic and used constant with error clearing --- frontend/src/hooks/useDebateWS.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/frontend/src/hooks/useDebateWS.ts b/frontend/src/hooks/useDebateWS.ts index 30cbcce2..6990c341 100644 --- a/frontend/src/hooks/useDebateWS.ts +++ b/frontend/src/hooks/useDebateWS.ts @@ -15,6 +15,8 @@ import { } from '../atoms/debateAtoms'; import ReconnectingWebSocket from 'reconnecting-websocket'; +const ERROR_THROTTLE_MS = 5000; + interface Event { type: string; payload: any; @@ -35,7 +37,6 @@ export const useDebateWS = (debateId: string | null) => { const wsRef = useRef(null); const lastErrorTime = useRef(0); - const retryCount = useRef(0); useEffect(() => { if (!debateId) return; @@ -105,7 +106,7 @@ export const useDebateWS = (debateId: string | null) => { const handleError = (error: unknown, context = 'WebSocket error') => { const now = Date.now(); - if (now - lastErrorTime.current > 5000) { // 5 seconds throttle + if (now - lastErrorTime.current > ERROR_THROTTLE_MS) { // 5 seconds throttle const errorMessage = error instanceof Error ? `${context}: ${error.message}` : `${context}: An unknown error occurred`; @@ -143,11 +144,8 @@ export const useDebateWS = (debateId: string | null) => { const eventData: Event = JSON.parse(event.data); - // Reset error state on successful message - if (retryCount.current > 0) { - retryCount.current = 0; - setWsError(null); - } + // Clear error state on successful message + setWsError(null); if (eventData.type !== 'poll_snapshot' && eventData.timestamp) { setLastEventId(String(eventData.timestamp));