-
Notifications
You must be signed in to change notification settings - Fork 67
Description
Bug
useStableSocket does not handle React Strict Mode correctly. In development, Strict Mode mounts, unmounts (running effect cleanup), then remounts components. This causes usePartySocket/useWebSocket to create two separate socket connections instead of one.
Root Cause
In useStableSocket, the effect uses socketInitializedRef to detect if a socket has already been initialized:
if (socketInitializedRef.current === socket)
setSocket(createSocketRef.current({ ...socketOptions, startClosed: false }));The problem: refs persist across Strict Mode's simulated unmount/remount. So on remount:
socketInitializedRef.current === socket→true(ref was set during mount 1)- Code assumes this means options changed → creates a new socket (second connection)
But in Strict Mode, the socket was just closed by cleanup and should be reconnected, not replaced.
Sequence in Strict Mode
| Step | What happens |
|---|---|
| Mount 1 | socket.reconnect(), socketInitializedRef.current = socket, returns () => socket.close() as cleanup |
| Strict Mode unmount | socket.close() called |
| Strict Mode remount | socketInitializedRef.current === socket → true → setSocket(new socket) → second connection |
Fix
Track the previous socketOptions reference. Since socketOptions is already memoized via useMemo, reference equality reliably detects real option changes vs. Strict Mode remounts:
const prevSocketOptionsRef = useRef(socketOptions);
// in the effect:
if (socketInitializedRef.current === socket) {
const optionsChanged = prevSocketOptionsRef.current !== socketOptions;
prevSocketOptionsRef.current = socketOptions;
if (optionsChanged) {
// Real option change — create new socket
setSocket(createSocketRef.current({ ...socketOptions, startClosed: false }));
} else {
// Strict Mode remount — just reconnect the closed socket
socket.reconnect();
return () => { socket.close(); };
}
} else {
prevSocketOptionsRef.current = socketOptions;
// ... existing logic
}Reproduction
Any app using usePartySocket or useWebSocket with React <StrictMode> in development will open two WebSocket connections on mount. Observable via browser DevTools → Network → WS tab.
Impact
This is primarily a development Strict Mode lifecycle bug, but it is development-blocking for SDK consumers.
With React <StrictMode> enabled, usePartySocket/useWebSocket can recreate/replace the socket during the simulated remount path. In Cloudflare Agents SDK usage (useAgent + useAgentChat), this can leave chat logic bound to a stale socket reference, so local chat/tool-call flows stop working reliably.
In our repro route (apps/web/src/routes/_public/dev.agent.tsx), the result is:
- Socket lifecycle mismatch during Strict Mode remount.
useAgentChatbound to stale/replaced connection state.- Dev chat becomes unreliable/broken unless Strict Mode is disabled.
So while production may be unaffected, this currently forces developers to remove <StrictMode> to continue local development.
Workaround
Applied via pnpm patch until this is fixed upstream:
+ const prevSocketOptionsRef = useRef(socketOptions);
if (socketInitializedRef.current === socket)
- setSocket(createSocketRef.current({ ...socketOptions, startClosed: false }));
- else {
+ {
+ const optionsChanged = prevSocketOptionsRef.current !== socketOptions;
+ prevSocketOptionsRef.current = socketOptions;
+ if (optionsChanged) {
+ setSocket(createSocketRef.current({ ...socketOptions, startClosed: false }));
+ } else {
+ socket.reconnect();
+ return () => { socket.close(); };
+ }
+ } else {
+ prevSocketOptionsRef.current = socketOptions;
if (!socketInitializedRef.current && socketOptions.startClosed !== true)
socket.reconnect();
socketInitializedRef.current = socket;
return () => { socket.close(); };
}partysocket version: 1.1.13
React version: 19.2.4