Skip to content

useStableSocket mishandles React Strict Mode remount: recreates socket instead of reconnecting, breaking dev consumers (agents/react) #336

@alexander-zuev

Description

@alexander-zuev

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:

  1. socketInitializedRef.current === sockettrue (ref was set during mount 1)
  2. 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 === sockettruesetSocket(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:

  1. Socket lifecycle mismatch during Strict Mode remount.
  2. useAgentChat bound to stale/replaced connection state.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions