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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions