diff --git a/src/workspaces/hooks/src/useChatWebSocket.ts b/src/workspaces/hooks/src/useChatWebSocket.ts index 5d539abe..d2dc620a 100644 --- a/src/workspaces/hooks/src/useChatWebSocket.ts +++ b/src/workspaces/hooks/src/useChatWebSocket.ts @@ -33,6 +33,8 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar * reason — the composer is allowed to fire before the transport is ready. */ const pendingRef = useRef([]); + /** A deferred teardown, cancelled when the effect re-runs — see the cleanup below. */ + const closeTimerRef = useRef(null); const connect = () => { if (isCleaningUpRef.current) return; @@ -78,18 +80,42 @@ 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 pane whose subtree is re-created — a tab re-render, 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. A pane could churn forever and never hold a connection: exactly what a fresh + * remote pane did. + * + * 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; } - if (socketRef.current) { - socketRef.current.close(); - socketRef.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); }; }, [url]);