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:
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
try {
|
||||||
const event: WsEvent = JSON.parse(e.data);
|
const event: WsEvent = JSON.parse(e.data);
|
||||||
if (event.event === "message") {
|
if (event.event === "message") {
|
||||||
onMessage(event.data);
|
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
|
||||||
|
if (!intentionalClose.current) {
|
||||||
reconnectTimer.current = setTimeout(connect, 3000);
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user