queue what you typed before the socket was ready

Reported from the mac: the alpha pane opened and read fine, and sending produced nothing at
all. The console showed the socket closing before it was established.

send dropped the message — readyState !== OPEN returned, silently, no error and no retry — so
enter did nothing and no turn ever started. Alpha was never at fault: the same key opens that
socket from outside the browser on the first try.

The window is not rare. React dev StrictMode double-invokes effects, so every socket is
created, closed and recreated on mount, and a reconnect reopens it again; with three chat
panes there are three sockets doing it at once, and one is always briefly not OPEN. One pane
with one stable socket is why this never bit before.

Queued and flushed on open, in order, after the resume/attach handshake rather than in front
of it. Bounded at 50 so a socket that never returns cannot grow it without limit, oldest
dropped first because the newest message is the one being waited on.

The mobile chat app has had this queue all along, for this exact reason. I read it this
morning, wrote the reason down, and did not port it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 22:16:53 +01:00
co-authored by Claude Opus 5
parent 4b3668f03c
commit 243bd04d97
+29 -2
View File
@@ -20,6 +20,20 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
const onOpenRef = useRef(onOpen); const onOpenRef = useRef(onOpen);
onOpenRef.current = onOpen; onOpenRef.current = onOpen;
/**
* Messages typed before the socket was ready.
*
* `send` used to drop them: `readyState !== OPEN` returned, silently, with no error and no retry — so
* pressing enter did nothing and the turn never happened. That window is not rare. React's dev
* StrictMode double-invokes effects, so every socket is created, closed and recreated on mount, and a
* reconnect after a drop reopens it again; with several chat panes on screen there are several sockets
* doing this at once. One of them is always briefly not OPEN.
*
* Queued and flushed on open, in order. The mobile chat app does exactly this and for exactly this
* reason — the composer is allowed to fire before the transport is ready.
*/
const pendingRef = useRef<string[]>([]);
const connect = () => { const connect = () => {
if (isCleaningUpRef.current) return; if (isCleaningUpRef.current) return;
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) return; if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) return;
@@ -31,7 +45,12 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
if (socketRef.current !== socket) return; if (socketRef.current !== socket) return;
setIsConnected(true); setIsConnected(true);
retryRef.current = 0; retryRef.current = 0;
// BEFORE onOpen, deliberately: onOpen sends the resume/attach handshake, and anything the user
// typed while connecting belongs after that, not in front of it.
const queued = pendingRef.current;
pendingRef.current = [];
onOpenRef.current?.(); onOpenRef.current?.();
for (const message of queued) socket.send(message);
}); });
socket.addEventListener('message', (ev) => { socket.addEventListener('message', (ev) => {
@@ -76,8 +95,16 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
const send = (data: Record<string, unknown>) => { const send = (data: Record<string, unknown>) => {
const socket = socketRef.current; const socket = socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) return; const message = JSON.stringify(data);
socket.send(JSON.stringify(data)); if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(message);
return;
}
// Not open yet, or reconnecting. Hold it rather than dropping it — see `pendingRef`. Bounded so a
// socket that never comes back cannot grow this without limit; the oldest go first, because the
// newest message is the one the user is still waiting on.
pendingRef.current.push(message);
if (pendingRef.current.length > 50) pendingRef.current.shift();
}; };
return { isConnected, send }; return { isConnected, send };