diff --git a/src/workspaces/hooks/src/useChatWebSocket.ts b/src/workspaces/hooks/src/useChatWebSocket.ts index 7f1104b0..776da795 100644 --- a/src/workspaces/hooks/src/useChatWebSocket.ts +++ b/src/workspaces/hooks/src/useChatWebSocket.ts @@ -15,8 +15,6 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar const retryRef = useRef(0); const retryTimeoutRef = useRef(null); const isCleaningUpRef = useRef(false); - /** A deferred teardown, cancelled when the effect re-runs — see the cleanup below. */ - const closeTimerRef = useRef(null); const onMessageRef = useRef(onMessage); onMessageRef.current = onMessage; const onOpenRef = useRef(onOpen); @@ -24,16 +22,7 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar const connect = () => { if (isCleaningUpRef.current) return; - // CONNECTING counts as ours, not just OPEN. The deferred teardown below keeps a remount's socket - // alive mid-handshake, and this is what reclaims it: checking only OPEN meant an effect re-run - // built a SECOND socket and orphaned the first, which then stayed open forever with its own `open` - // handler bailing on the socketRef mismatch. - // - // This is per-instance and does NOT explain the two sockets a /chat/new load opens — measured with - // a WebSocket-constructor counter, those come from two separate `useChat` instances mounting, each - // with its own refs. Unresolved, and tracked separately; both connect, so it reads as healthy. - const existing = socketRef.current; - if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) return; + if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) return; const socket = new WebSocket(url); socketRef.current = socket; @@ -70,41 +59,18 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar }; useEffect(() => { - // A pending teardown from a remount that is about to be undone — see below. - if (closeTimerRef.current !== null) { - clearTimeout(closeTimerRef.current); - closeTimerRef.current = null; - } isCleaningUpRef.current = false; connect(); - return () => { - /** - * Close LATER, not now. - * - * Closing here directly is correct for a real unmount and disastrous for a remount, and this hook - * cannot tell them apart at the moment it runs. React's dev StrictMode double-invokes every effect - * (mount → unmount → mount), and a subtree that is re-created — a resolved transcript, a parent key - * change — does the same. Each time, the socket was closed while still CONNECTING, the browser - * logged "closed before the connection is established", and the replacement was closed in turn, so - * the view could churn forever and never hold a connection while sitting on Disconnected. - * - * Deferring by a tick makes the two distinguishable. A remount re-runs the effect immediately and - * cancels this timer, so the live socket is kept and the handshake completes. A real unmount has - * nobody to cancel it and the socket closes a frame later, which costs nothing. - */ isCleaningUpRef.current = true; if (retryTimeoutRef.current !== null) { clearTimeout(retryTimeoutRef.current); retryTimeoutRef.current = null; } - const socket = socketRef.current; - closeTimerRef.current = window.setTimeout(() => { - closeTimerRef.current = null; - if (!isCleaningUpRef.current) return; // remounted: the effect above already reclaimed it - if (socket) socket.close(); - if (socketRef.current === socket) socketRef.current = null; - }, 0); + if (socketRef.current) { + socketRef.current.close(); + socketRef.current = null; + } }; }, [url]);