diff --git a/src/hooks/useDeviceStatusStreams.ts b/src/hooks/useDeviceStatusStreams.ts index d04d663..76d7c68 100644 --- a/src/hooks/useDeviceStatusStreams.ts +++ b/src/hooks/useDeviceStatusStreams.ts @@ -1,5 +1,5 @@ import { pond } from "protobuf-ts/pond"; -import { useEffect, useRef, useCallback, useMemo } from "react"; +import { useEffect, useRef, useMemo } from "react"; import { getWsBaseUrl } from "utils/getWsBaseUrl"; /** Off by default to reduce server load; set VITE_ENABLE_WEBSOCKETS=true to enable live streams. */ @@ -36,10 +36,47 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) { const wsMapRef = useRef>(new Map()); const retriesRef = useRef>(new Map()); const reconnectTimeoutsRef = useRef>>(new Map()); + /** Bumps only in effect cleanup so late onclose / reconnect timers never run after unmount. */ + const generationRef = useRef(0); + /** Latest ID set so a device dropped from the list does not reconnect on abnormal close. */ + const allowedIdsRef = useRef>(new Set()); + allowedIdsRef.current = new Set(deviceIds); - const connect = useCallback( - (deviceId: string) => { + // Normalize for stable comparison: same set of IDs = same string regardless of order + const deviceIdsKey = useMemo( + () => [...new Set(deviceIds)].sort().join(","), + [deviceIds.join(",")] + ); + + useEffect(() => { + const clearReconnect = (deviceId: string) => { + const t = reconnectTimeoutsRef.current.get(deviceId); + if (t) { + clearTimeout(t); + reconnectTimeoutsRef.current.delete(deviceId); + } + }; + + const shutdownAll = () => { + reconnectTimeoutsRef.current.forEach((t) => clearTimeout(t)); + reconnectTimeoutsRef.current.clear(); + wsMapRef.current.forEach((ws) => ws.close(1000, "cleanup")); + wsMapRef.current.clear(); + retriesRef.current.clear(); + }; + + if (!WEBSOCKETS_ENABLED || !enabled || !token || deviceIds.length === 0) { + shutdownAll(); + return () => { + generationRef.current += 1; + }; + } + + const gen = generationRef.current; + + const connect = (deviceId: string) => { if (!token || !deviceId) return; + if (gen !== generationRef.current) return; const params = new URLSearchParams(); params.set("token", token); @@ -47,18 +84,19 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) { if (types?.length) params.set("types", types.toString()); if (as) params.set("as", as); - // Match useWebSocket / pond routes: /v1/live/devices/:id/status const path = `/v1/live/devices/${deviceId}/status`; const url = `${getWsBaseUrl()}${path}?${params.toString()}`; const ws = new WebSocket(url); wsMapRef.current.set(deviceId, ws); ws.onopen = () => { + if (gen !== generationRef.current) return; console.debug(`[ws] connected: ${path}`); retriesRef.current.set(deviceId, 0); }; ws.onmessage = (event) => { + if (gen !== generationRef.current) return; try { const raw = JSON.parse(event.data); const status = pond.DeviceStatus.fromObject(raw ?? {}); @@ -75,7 +113,15 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) { ws.onclose = (event) => { wsMapRef.current.delete(deviceId); - // Don't reconnect on clean close or auth rejection + if (gen !== generationRef.current) { + return; + } + + if (!allowedIdsRef.current.has(deviceId)) { + retriesRef.current.delete(deviceId); + return; + } + if (event.code === 1000 || event.code === 4001 || event.code === 4003) { return; } @@ -85,55 +131,36 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) { retriesRef.current.set(deviceId, retries + 1); console.debug(`[ws] reconnecting ${path} in ${delay}ms...`); - const timeout = setTimeout(() => connect(deviceId), delay); + const timeout = setTimeout(() => { + if (gen !== generationRef.current) return; + if (!allowedIdsRef.current.has(deviceId)) return; + reconnectTimeoutsRef.current.delete(deviceId); + connect(deviceId); + }, delay); reconnectTimeoutsRef.current.set(deviceId, timeout); }; - }, - [token, keys?.join(), types?.join(), as] - ); - - // Normalize for stable comparison: same set of IDs = same string regardless of order - const deviceIdsKey = useMemo( - () => [...new Set(deviceIds)].sort().join(","), - [deviceIds.join(",")] - ); - - useEffect(() => { - if (!WEBSOCKETS_ENABLED || !enabled || !token || deviceIds.length === 0) { - wsMapRef.current.forEach((ws) => ws.close(1000, "disabled")); - wsMapRef.current.clear(); - reconnectTimeoutsRef.current.forEach((t) => clearTimeout(t)); - reconnectTimeoutsRef.current.clear(); - return; - } + }; const currentIds = new Set(deviceIds); - // Connect to new devices deviceIds.forEach((id) => { if (!wsMapRef.current.has(id)) { connect(id); } }); - // Disconnect from devices no longer in the list wsMapRef.current.forEach((ws, id) => { if (!currentIds.has(id)) { + clearReconnect(id); ws.close(1000, "device removed"); wsMapRef.current.delete(id); - const t = reconnectTimeoutsRef.current.get(id); - if (t) { - clearTimeout(t); - reconnectTimeoutsRef.current.delete(id); - } + retriesRef.current.delete(id); } }); return () => { - wsMapRef.current.forEach((ws) => ws.close(1000, "cleanup")); - wsMapRef.current.clear(); - reconnectTimeoutsRef.current.forEach((t) => clearTimeout(t)); - reconnectTimeoutsRef.current.clear(); + generationRef.current += 1; + shutdownAll(); }; - }, [enabled, token, deviceIdsKey, deviceIds.length, connect]); + }, [enabled, token, deviceIdsKey, deviceIds.length, keys?.join(), types?.join(), as]); } diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index 9945f2d..86f4056 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -48,10 +48,14 @@ export function useWebSocket(options: UseWebSocketOptions) { const wsRef = useRef(null); const reconnectTimeoutRef = useRef>(); const retriesRef = useRef(0); + /** Bumps on each effect cleanup so late onclose / reconnect timers never run after unmount or dependency change. */ + const generationRef = useRef(0); const connect = useCallback(() => { if (!token) return; + const gen = generationRef.current; + const params = new URLSearchParams(); params.set("token", token); if (rate > 0) params.set("rate", rate.toString()); @@ -85,6 +89,10 @@ export function useWebSocket(options: UseWebSocketOptions) { console.debug(`[ws] closed: ${path} (code: ${event.code})`); wsRef.current = null; + if (gen !== generationRef.current) { + return; + } + // Don't reconnect on clean close or auth rejection if (event.code === 1000 || event.code === 4001 || event.code === 4003) { return; @@ -94,22 +102,33 @@ export function useWebSocket(options: UseWebSocketOptions) { const delay = Math.min(1000 * Math.pow(2, retriesRef.current), 30000); retriesRef.current += 1; console.debug(`[ws] reconnecting in ${delay}ms...`); - reconnectTimeoutRef.current = setTimeout(connect, delay); + reconnectTimeoutRef.current = setTimeout(() => { + if (gen !== generationRef.current) return; + connect(); + }, delay); }; }, [path, token, rate, keys?.join(), types?.join(), as]); useEffect(() => { if (!WEBSOCKETS_ENABLED || !enabled || !token) { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } if (wsRef.current) { wsRef.current.close(1000, "disabled"); wsRef.current = null; } - return; + retriesRef.current = 0; + return () => { + generationRef.current += 1; + }; } + retriesRef.current = 0; connect(); return () => { + generationRef.current += 1; if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); }