fix: WS reconnect leak, refetch on reconnect, error handling

- intentionalClose ref prevents onclose from reconnecting after cleanup
- refetch full history on WS reconnect (catches missed messages)
- onerror handler, try/catch on JSON.parse
- fixes codex review: orphaned sockets, stale closures, missing messages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 20:48:23 +02:00
parent 407ac504b8
commit 9e375fd953
2 changed files with 32 additions and 10 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useCallback } from "react";
import type { Message } from "@/types/Message";
import { getCurrentUsername } from "@/api";
import { getCurrentUsername, getMessages } from "@/api";
interface WsMessageEvent {
event: "message";
@@ -16,19 +16,22 @@ type WsEvent = WsMessageEvent | WsConnectedEvent;
export function useChannelSocket(
channelId: string | null,
onMessage: (msg: Message) => void,
onReconnect: () => void,
) {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const intentionalClose = useRef(false);
const connect = useCallback(() => {
if (!channelId) return;
intentionalClose.current = false;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.host;
const ws = new WebSocket(`${protocol}//${host}/ws/${channelId}`);
ws.onopen = () => {
// Send auth message
ws.send(JSON.stringify({
type: "auth",
user: getCurrentUsername(),
@@ -36,23 +39,38 @@ export function useChannelSocket(
};
ws.onmessage = (e) => {
const event: WsEvent = JSON.parse(e.data);
if (event.event === "message") {
onMessage(event.data);
try {
const event: WsEvent = JSON.parse(e.data);
if (event.event === "message") {
onMessage(event.data);
} else if (event.event === "connected") {
// Refetch history on reconnect to catch missed messages
onReconnect();
}
} catch {
// Ignore malformed messages
}
};
ws.onerror = () => {
// Will trigger onclose
};
ws.onclose = () => {
// Reconnect after 3s
reconnectTimer.current = setTimeout(connect, 3000);
// Only reconnect if this wasn't an intentional teardown
if (!intentionalClose.current) {
reconnectTimer.current = setTimeout(connect, 3000);
}
};
wsRef.current = ws;
}, [channelId, onMessage]);
}, [channelId, onMessage, onReconnect]);
useEffect(() => {
connect();
return () => {
// Mark as intentional so onclose doesn't reconnect
intentionalClose.current = true;
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
wsRef.current?.close();
wsRef.current = null;