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:
@@ -20,6 +20,20 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
|
||||
const onOpenRef = useRef(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 = () => {
|
||||
if (isCleaningUpRef.current) 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;
|
||||
setIsConnected(true);
|
||||
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?.();
|
||||
for (const message of queued) socket.send(message);
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (ev) => {
|
||||
@@ -76,8 +95,16 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
|
||||
|
||||
const send = (data: Record<string, unknown>) => {
|
||||
const socket = socketRef.current;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
||||
socket.send(JSON.stringify(data));
|
||||
const message = 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 };
|
||||
|
||||
Reference in New Issue
Block a user