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

@@ -42,13 +42,17 @@ export default function App() {
// WebSocket: append new messages in real-time // WebSocket: append new messages in real-time
const handleWsMessage = useCallback((msg: Message) => { const handleWsMessage = useCallback((msg: Message) => {
setMessages((prev) => { setMessages((prev) => {
// Deduplicate — the sender also fetches after posting
if (prev.some((m) => m.id === msg.id)) return prev; if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, msg]; return [...prev, msg];
}); });
}, []); }, []);
useChannelSocket(activeChannelId, handleWsMessage); // On WS reconnect, refetch full history to catch missed messages
const handleWsReconnect = useCallback(() => {
loadMessages();
}, [loadMessages]);
useChannelSocket(activeChannelId, handleWsMessage, handleWsReconnect);
useEffect(() => { loadChannels(); }, [loadChannels]); useEffect(() => { loadChannels(); }, [loadChannels]);

View File

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