stop closing the chat socket on every remount, again
Re-appliesbcb3d6d, 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:
@@ -15,6 +15,8 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
|
||||
const retryRef = useRef(0);
|
||||
const retryTimeoutRef = useRef<number | null>(null);
|
||||
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);
|
||||
onMessageRef.current = onMessage;
|
||||
const onOpenRef = useRef(onOpen);
|
||||
@@ -59,18 +61,41 @@ 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;
|
||||
}
|
||||
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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user