fixed bug causing websockets to reconnect after leaving a page

This commit is contained in:
Carter 2026-03-23 11:05:38 -06:00
parent 72afd06309
commit d6e3fe7a16
2 changed files with 84 additions and 38 deletions

View file

@ -1,5 +1,5 @@
import { pond } from "protobuf-ts/pond"; import { pond } from "protobuf-ts/pond";
import { useEffect, useRef, useCallback, useMemo } from "react"; import { useEffect, useRef, useMemo } from "react";
import { getWsBaseUrl } from "utils/getWsBaseUrl"; import { getWsBaseUrl } from "utils/getWsBaseUrl";
/** Off by default to reduce server load; set VITE_ENABLE_WEBSOCKETS=true to enable live streams. */ /** 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<Map<string, WebSocket>>(new Map()); const wsMapRef = useRef<Map<string, WebSocket>>(new Map());
const retriesRef = useRef<Map<string, number>>(new Map()); const retriesRef = useRef<Map<string, number>>(new Map());
const reconnectTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map()); const reconnectTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(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<Set<string>>(new Set());
allowedIdsRef.current = new Set(deviceIds);
const connect = useCallback( // Normalize for stable comparison: same set of IDs = same string regardless of order
(deviceId: string) => { 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 (!token || !deviceId) return;
if (gen !== generationRef.current) return;
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("token", token); params.set("token", token);
@ -47,18 +84,19 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) {
if (types?.length) params.set("types", types.toString()); if (types?.length) params.set("types", types.toString());
if (as) params.set("as", as); if (as) params.set("as", as);
// Match useWebSocket / pond routes: /v1/live/devices/:id/status
const path = `/v1/live/devices/${deviceId}/status`; const path = `/v1/live/devices/${deviceId}/status`;
const url = `${getWsBaseUrl()}${path}?${params.toString()}`; const url = `${getWsBaseUrl()}${path}?${params.toString()}`;
const ws = new WebSocket(url); const ws = new WebSocket(url);
wsMapRef.current.set(deviceId, ws); wsMapRef.current.set(deviceId, ws);
ws.onopen = () => { ws.onopen = () => {
if (gen !== generationRef.current) return;
console.debug(`[ws] connected: ${path}`); console.debug(`[ws] connected: ${path}`);
retriesRef.current.set(deviceId, 0); retriesRef.current.set(deviceId, 0);
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
if (gen !== generationRef.current) return;
try { try {
const raw = JSON.parse(event.data); const raw = JSON.parse(event.data);
const status = pond.DeviceStatus.fromObject(raw ?? {}); const status = pond.DeviceStatus.fromObject(raw ?? {});
@ -75,7 +113,15 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) {
ws.onclose = (event) => { ws.onclose = (event) => {
wsMapRef.current.delete(deviceId); 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) { if (event.code === 1000 || event.code === 4001 || event.code === 4003) {
return; return;
} }
@ -85,55 +131,36 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) {
retriesRef.current.set(deviceId, retries + 1); retriesRef.current.set(deviceId, retries + 1);
console.debug(`[ws] reconnecting ${path} in ${delay}ms...`); 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); 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); const currentIds = new Set(deviceIds);
// Connect to new devices
deviceIds.forEach((id) => { deviceIds.forEach((id) => {
if (!wsMapRef.current.has(id)) { if (!wsMapRef.current.has(id)) {
connect(id); connect(id);
} }
}); });
// Disconnect from devices no longer in the list
wsMapRef.current.forEach((ws, id) => { wsMapRef.current.forEach((ws, id) => {
if (!currentIds.has(id)) { if (!currentIds.has(id)) {
clearReconnect(id);
ws.close(1000, "device removed"); ws.close(1000, "device removed");
wsMapRef.current.delete(id); wsMapRef.current.delete(id);
const t = reconnectTimeoutsRef.current.get(id); retriesRef.current.delete(id);
if (t) {
clearTimeout(t);
reconnectTimeoutsRef.current.delete(id);
}
} }
}); });
return () => { return () => {
wsMapRef.current.forEach((ws) => ws.close(1000, "cleanup")); generationRef.current += 1;
wsMapRef.current.clear(); shutdownAll();
reconnectTimeoutsRef.current.forEach((t) => clearTimeout(t));
reconnectTimeoutsRef.current.clear();
}; };
}, [enabled, token, deviceIdsKey, deviceIds.length, connect]); }, [enabled, token, deviceIdsKey, deviceIds.length, keys?.join(), types?.join(), as]);
} }

View file

@ -48,10 +48,14 @@ export function useWebSocket<T>(options: UseWebSocketOptions<T>) {
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>(); const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const retriesRef = useRef(0); 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(() => { const connect = useCallback(() => {
if (!token) return; if (!token) return;
const gen = generationRef.current;
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("token", token); params.set("token", token);
if (rate > 0) params.set("rate", rate.toString()); if (rate > 0) params.set("rate", rate.toString());
@ -85,6 +89,10 @@ export function useWebSocket<T>(options: UseWebSocketOptions<T>) {
console.debug(`[ws] closed: ${path} (code: ${event.code})`); console.debug(`[ws] closed: ${path} (code: ${event.code})`);
wsRef.current = null; wsRef.current = null;
if (gen !== generationRef.current) {
return;
}
// Don't reconnect on clean close or auth rejection // Don't reconnect on clean close or auth rejection
if (event.code === 1000 || event.code === 4001 || event.code === 4003) { if (event.code === 1000 || event.code === 4001 || event.code === 4003) {
return; return;
@ -94,22 +102,33 @@ export function useWebSocket<T>(options: UseWebSocketOptions<T>) {
const delay = Math.min(1000 * Math.pow(2, retriesRef.current), 30000); const delay = Math.min(1000 * Math.pow(2, retriesRef.current), 30000);
retriesRef.current += 1; retriesRef.current += 1;
console.debug(`[ws] reconnecting in ${delay}ms...`); 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]); }, [path, token, rate, keys?.join(), types?.join(), as]);
useEffect(() => { useEffect(() => {
if (!WEBSOCKETS_ENABLED || !enabled || !token) { if (!WEBSOCKETS_ENABLED || !enabled || !token) {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) { if (wsRef.current) {
wsRef.current.close(1000, "disabled"); wsRef.current.close(1000, "disabled");
wsRef.current = null; wsRef.current = null;
} }
return; retriesRef.current = 0;
return () => {
generationRef.current += 1;
};
} }
retriesRef.current = 0;
connect(); connect();
return () => { return () => {
generationRef.current += 1;
if (reconnectTimeoutRef.current) { if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current); clearTimeout(reconnectTimeoutRef.current);
} }