stop closing the chat socket on every remount

A pane on a remote server never connected: the console showed the socket closing before the
handshake finished, over and over, and the pane sat on Disconnected.

The stack named it — commitPassiveUnmountEffectsInsideOfDeletedTree plus
doubleInvokeEffectsOnFiber. The pane subtree is deleted and remounted, and the cleanup closed
the socket each time, while it was still CONNECTING. The replacement was then closed in turn.
React dev StrictMode double-invokes every effect on mount, so a fresh pane could churn
forever and never hold a connection.

The cleanup cannot tell a remount from a real unmount at the moment it runs, so it no longer
tries: the close is deferred a tick and cancelled if the effect re-runs. A remount reclaims
the live socket and the handshake completes; a real unmount has nobody to cancel it and
closes a frame later, which costs nothing.

Ruled out beforehand, by direct test: alpha accepts that exact key over wss on the first try,
with and without a browser Origin. The server was never involved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 22:29:59 +01:00
co-authored by Claude Opus 5
parent 907ac46eec
commit bcb3d6d621
+30 -4
View File
@@ -33,6 +33,8 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
* reason — the composer is allowed to fire before the transport is ready. * reason — the composer is allowed to fire before the transport is ready.
*/ */
const pendingRef = useRef<string[]>([]); const pendingRef = useRef<string[]>([]);
/** A deferred teardown, cancelled when the effect re-runs — see the cleanup below. */
const closeTimerRef = useRef<number | null>(null);
const connect = () => { const connect = () => {
if (isCleaningUpRef.current) return; if (isCleaningUpRef.current) return;
@@ -78,18 +80,42 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
}; };
useEffect(() => { 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; isCleaningUpRef.current = false;
connect(); connect();
return () => { 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; isCleaningUpRef.current = true;
if (retryTimeoutRef.current !== null) { if (retryTimeoutRef.current !== null) {
clearTimeout(retryTimeoutRef.current); clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null; retryTimeoutRef.current = null;
} }
if (socketRef.current) { const socket = socketRef.current;
socketRef.current.close(); closeTimerRef.current = window.setTimeout(() => {
socketRef.current = null; 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]); }, [url]);