stop closing the chat socket on every remount, again

Re-applies bcb3d6d, which the tabs/panes revert (0a4ff54) took out as collateral: the fix lived in
useChatWebSocket.ts, so reverting the panes work reverted it too. It was never panes-specific — the
mechanism is React remounting a subtree, which happens on this screen with one conversation just as
it did in a pane.

The cleanup closed the socket while it was still CONNECTING, and the replacement was closed in turn,
so the view churned and sat on Disconnected forever. The close is now deferred a tick and cancelled
if the effect re-runs: a remount reclaims the live socket, a real unmount has nobody to cancel it.

Diagnosed from the browser this time rather than guessed. A raw socket opened by hand from the
console on the same origin, with the same token, reports RAW OPEN and stays open:

  new WebSocket(`wss://${location.host}/api/chat/ws?token=${localStorage.getItem('BEARER_TOKEN')}`)

so transport, auth, the tailnet proxy and the server are all fine and the app was closing its own
socket. Two earlier theories are dead and worth naming: the token resolution mismatch (52d5678) does
not apply — the token IS in localStorage.BEARER_TOKEN where the old code looks — and StrictMode's
double-invoke is not the trigger here, since pm2 runs `bun start` with NODE_ENV=production where
React does not double-invoke. Some other remount is.

Not verified in a browser yet: whether this alone clears Disconnected. If it does not, the remaining
suspect is a continuous remount rather than a single one, which a WebSocket-constructor counter in
the console will show as a rising count.

Typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 00:12:13 +01:00
co-authored by Claude Opus 5
parent 31ffe084f5
commit bc13450fad
+29 -4
View File
@@ -15,6 +15,8 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
const retryRef = useRef(0); const retryRef = useRef(0);
const retryTimeoutRef = useRef<number | null>(null); const retryTimeoutRef = useRef<number | null>(null);
const isCleaningUpRef = useRef(false); const isCleaningUpRef = useRef(false);
/** A deferred teardown, cancelled when the effect re-runs — see the cleanup below. */
const closeTimerRef = useRef<number | null>(null);
const onMessageRef = useRef(onMessage); const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage; onMessageRef.current = onMessage;
const onOpenRef = useRef(onOpen); const onOpenRef = useRef(onOpen);
@@ -59,18 +61,41 @@ 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 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; 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]);