From 11d9f228c1d8e6aaf5867dc3b8462f2e44e6239c Mon Sep 17 00:00:00 2001 From: Matt Sutkowski Date: Tue, 17 Feb 2026 15:00:09 -0800 Subject: [PATCH 1/2] fix: handle options change and reconnects --- .../src/tests/react-hooks.test.tsx | 60 +++++++++++++++++++ packages/partysocket/src/use-socket.ts | 58 ++++++++++++++---- 2 files changed, 108 insertions(+), 10 deletions(-) diff --git a/packages/partysocket/src/tests/react-hooks.test.tsx b/packages/partysocket/src/tests/react-hooks.test.tsx index 8fc20e0e..51d1b7a0 100644 --- a/packages/partysocket/src/tests/react-hooks.test.tsx +++ b/packages/partysocket/src/tests/react-hooks.test.tsx @@ -802,6 +802,66 @@ describe.skipIf(!!process.env.GITHUB_ACTIONS)("usePartySocket", () => { result.current.close(); }); + + test("creates new socket when options change while re-enabling", async () => { + // Bug: when enabled goes false→true at the same time as options change + // (e.g., query with fresh auth token), useStableSocket calls + // socket.reconnect() on the OLD socket and short-circuits past the + // option-change detection that would create a new socket. + const { result, rerender } = renderHook( + ({ enabled, query }: { enabled: boolean; query: Record }) => + usePartySocket({ + host: "example.com", + room: "test-room", + query, + enabled, + startClosed: true + }), + { initialProps: { enabled: true, query: { token: "old-token" } } } + ); + + const firstSocket = result.current; + + // Disable socket (simulates onClose → awaitingQueryRefresh = true) + rerender({ enabled: false, query: { token: "old-token" } }); + expect(result.current).toBe(firstSocket); // same instance, just closed + + // Re-enable with new query (simulates fresh token resolved) + rerender({ enabled: true, query: { token: "new-token" } }); + + // Should be a NEW socket created with the fresh query params. + // If this fails, the old socket was .reconnect()'d with stale params. + await waitFor(() => { + expect(result.current).not.toBe(firstSocket); + }); + }); + + test("closes socket on unmount after enabled toggle", () => { + // Bug: the useEffect in useStableSocket returns no cleanup function + // on the enabled toggle paths (both false→true and true→false). + // If the component unmounts while in one of those paths, the socket + // is never closed and reconnects forever as a zombie. + const { result, rerender, unmount } = renderHook( + ({ enabled }) => + usePartySocket({ + host: "example.com", + room: "test-room", + enabled, + startClosed: true + }), + { initialProps: { enabled: true } } + ); + + // Toggle enabled: true → false → true (the reconnect path) + rerender({ enabled: false }); + rerender({ enabled: true }); + + const closeSpy = vitest.spyOn(result.current, "close"); + + unmount(); + + expect(closeSpy).toHaveBeenCalled(); + }); }); describe.skipIf(!!process.env.GITHUB_ACTIONS)("useWebSocket", () => { diff --git a/packages/partysocket/src/use-socket.ts b/packages/partysocket/src/use-socket.ts index dc86fa4d..1089363f 100644 --- a/packages/partysocket/src/use-socket.ts +++ b/packages/partysocket/src/use-socket.ts @@ -71,6 +71,11 @@ export function useStableSocket< // whether the connection options actually changed. const prevSocketOptionsRef = useRef(socketOptions); + // tracks whether options changed at any point while the socket was disabled. + // The disabled path early-returns without creating a new socket, so we need + // to remember that options drifted and create a new socket on re-enable. + const optionsChangedWhileDisabledRef = useRef(false); + // finally, initialize the socket useEffect(() => { const optionsChanged = prevSocketOptionsRef.current !== socketOptions; @@ -80,14 +85,38 @@ export function useStableSocket< if (!enabled) { socket.close(); prevEnabledRef.current = enabled; - return; + if (optionsChanged) { + optionsChangedWhileDisabledRef.current = true; + } + return () => { + socket.close(); + }; } - // if enabled just changed from false to true, reconnect + // if enabled just changed from false to true... if (!prevEnabledRef.current && enabled) { - socket.reconnect(); prevEnabledRef.current = enabled; - return; + const needsNewSocket = + optionsChanged || optionsChangedWhileDisabledRef.current; + optionsChangedWhileDisabledRef.current = false; + + if (!needsNewSocket) { + // options unchanged — reconnect existing socket + socket.reconnect(); + return () => { + socket.close(); + }; + } + + // options changed while disabled — create new socket with current config + const newSocket = createSocketRef.current({ + ...socketOptions, + startClosed: true + }); + setSocket(newSocket); + return () => { + newSocket.close(); + }; } prevEnabledRef.current = enabled; @@ -95,16 +124,20 @@ export function useStableSocket< // we haven't yet restarted the socket if (socketInitializedRef.current === socket) { if (optionsChanged) { - // connection options changed — create new socket with new config + // connection options changed — create new socket with new config. + // startClosed: true so it's inert until the else branch below + // connects it on the next render. This ensures the socket is safe + // to clean up if the component unmounts before that re-render. const newSocket = createSocketRef.current({ ...socketOptions, - // when reconnecting because of options change, we always reconnect - // (startClosed only applies to initial mount) - startClosed: false + startClosed: true }); // update socket reference (this will cause the effect to run again) setSocket(newSocket); + return () => { + newSocket.close(); + }; } else { // HMR or React Strict Mode effect re-run — reconnect the existing // socket instead of creating a new instance. This preserves the @@ -119,8 +152,13 @@ export function useStableSocket< }; } } else { - // if this is the first time we are running the hook, connect... - if (!socketInitializedRef.current && socketOptions.startClosed !== true) { + if (!socketInitializedRef.current) { + // first mount — respect the caller's startClosed preference + if (socketOptions.startClosed !== true) { + socket.reconnect(); + } + } else if (socketInitializedRef.current !== socket) { + // replacement socket from an options change — always connect socket.reconnect(); } // track initialized socket so we know not to do it again From bf6c446c62a197abe93e75164009f51dddd41f80 Mon Sep 17 00:00:00 2001 From: Sunil Pai Date: Sun, 22 Feb 2026 17:18:51 +0000 Subject: [PATCH 2/2] Switch Durable Object migrations to SQLite Replace wrangler migration entries from `new_classes` to `new_sqlite_classes` across fixtures and READMEs and add a changeset documenting that partyserver now relies on SQLite-backed Durable Objects (synchronous storage). Update use-socket logic to track option changes while disabled, ensure correct socket creation/cleanup on re-enable and StrictMode/HMR, and always connect replacement sockets. Expand tests in partysocket to cover these re-enable, token-refresh, cleanup, and wire-level scenarios. Breaking: Durable Object namespaces must use `new_sqlite_classes` when deployed. --- .changeset/require-sqlite-classes.md | 11 + fixtures/globe/wrangler.jsonc | 2 +- fixtures/hono/wrangler.jsonc | 2 +- fixtures/lexical-yjs/wrangler.jsonc | 2 +- fixtures/monaco-yjs/wrangler.jsonc | 2 +- fixtures/node/wrangler.jsonc | 2 +- fixtures/pubsub/wrangler.jsonc | 2 +- fixtures/rpc-sanity/wrangler.jsonc | 2 +- fixtures/tldraw/wrangler.jsonc | 2 +- packages/hono-party/README.md | 4 +- packages/partyserver/README.md | 2 +- .../src/tests/react-hooks.test.tsx | 366 +++++++++++++++++- packages/partysub/README.md | 2 +- packages/partywhen/README.md | 2 +- 14 files changed, 390 insertions(+), 13 deletions(-) create mode 100644 .changeset/require-sqlite-classes.md diff --git a/.changeset/require-sqlite-classes.md b/.changeset/require-sqlite-classes.md new file mode 100644 index 00000000..b264d95a --- /dev/null +++ b/.changeset/require-sqlite-classes.md @@ -0,0 +1,11 @@ +--- +"partyserver": minor +--- + +Uses `ctx.storage.kv` (synchronous KV) internally to persist `Server.name` across cold starts. This requires SQLite-backed Durable Objects, so you must use `new_sqlite_classes` instead of `new_classes` in your wrangler configuration's `migrations` field. + +**Breaking:** If your Durable Object namespace still uses `new_classes`, you will see the following runtime error: + +> The storage.kv (synchronous KV) API is only available for SQLite-backed Durable Objects, but this object's namespace is not declared to use SQLite. You can use the older, asynchronous interface via methods of `storage` itself (e.g. `storage.get()`). Alternatively, to enable SQLite, change `new_classes` to `new_sqlite_classes` within the 'migrations' field in your wrangler.jsonc or wrangler.toml file. + +**Note:** This migration cannot be reversed once deployed to production. diff --git a/fixtures/globe/wrangler.jsonc b/fixtures/globe/wrangler.jsonc index b356c096..262f27a0 100644 --- a/fixtures/globe/wrangler.jsonc +++ b/fixtures/globe/wrangler.jsonc @@ -16,7 +16,7 @@ "migrations": [ { "tag": "v1", // Should be unique for each entry - "new_classes": ["Globe"] + "new_sqlite_classes": ["Globe"] } ] } diff --git a/fixtures/hono/wrangler.jsonc b/fixtures/hono/wrangler.jsonc index f48aa137..76b7c45c 100644 --- a/fixtures/hono/wrangler.jsonc +++ b/fixtures/hono/wrangler.jsonc @@ -16,7 +16,7 @@ "migrations": [ { "tag": "v1", // Should be unique for each entry - "new_classes": ["Chat"] + "new_sqlite_classes": ["Chat"] } ] } diff --git a/fixtures/lexical-yjs/wrangler.jsonc b/fixtures/lexical-yjs/wrangler.jsonc index 0bfa6c33..67e79860 100644 --- a/fixtures/lexical-yjs/wrangler.jsonc +++ b/fixtures/lexical-yjs/wrangler.jsonc @@ -16,7 +16,7 @@ "migrations": [ { "tag": "v1", - "new_classes": ["LexicalDocument"] + "new_sqlite_classes": ["LexicalDocument"] } ] } diff --git a/fixtures/monaco-yjs/wrangler.jsonc b/fixtures/monaco-yjs/wrangler.jsonc index 052a1d64..be940b0d 100644 --- a/fixtures/monaco-yjs/wrangler.jsonc +++ b/fixtures/monaco-yjs/wrangler.jsonc @@ -16,7 +16,7 @@ "migrations": [ { "tag": "v1", - "new_classes": ["MonacoServer"] + "new_sqlite_classes": ["MonacoServer"] } ] } diff --git a/fixtures/node/wrangler.jsonc b/fixtures/node/wrangler.jsonc index 9e2ef0bf..c7f89441 100644 --- a/fixtures/node/wrangler.jsonc +++ b/fixtures/node/wrangler.jsonc @@ -13,7 +13,7 @@ "migrations": [ { "tag": "v1", - "new_classes": ["MyServer"] + "new_sqlite_classes": ["MyServer"] } ] } diff --git a/fixtures/pubsub/wrangler.jsonc b/fixtures/pubsub/wrangler.jsonc index 9851fe79..1fcadae6 100644 --- a/fixtures/pubsub/wrangler.jsonc +++ b/fixtures/pubsub/wrangler.jsonc @@ -16,7 +16,7 @@ "migrations": [ { "tag": "v1", - "new_classes": ["PubSubServer"] + "new_sqlite_classes": ["PubSubServer"] } ] } diff --git a/fixtures/rpc-sanity/wrangler.jsonc b/fixtures/rpc-sanity/wrangler.jsonc index db25dd9b..edf6ba95 100644 --- a/fixtures/rpc-sanity/wrangler.jsonc +++ b/fixtures/rpc-sanity/wrangler.jsonc @@ -13,7 +13,7 @@ "migrations": [ { "tag": "v1", - "new_classes": ["MyServer"] + "new_sqlite_classes": ["MyServer"] } ] } diff --git a/fixtures/tldraw/wrangler.jsonc b/fixtures/tldraw/wrangler.jsonc index 44318787..3ca3ae8a 100644 --- a/fixtures/tldraw/wrangler.jsonc +++ b/fixtures/tldraw/wrangler.jsonc @@ -16,7 +16,7 @@ "migrations": [ { "tag": "v1", // Should be unique for each entry - "new_classes": ["Tldraw"] + "new_sqlite_classes": ["Tldraw"] } ] } diff --git a/packages/hono-party/README.md b/packages/hono-party/README.md index 0cad0e4d..6e37b374 100644 --- a/packages/hono-party/README.md +++ b/packages/hono-party/README.md @@ -91,7 +91,9 @@ const socket = usePartySocket({ { "name": "Document", "class_name": "Document" } ] }, - "migrations": [{ "tag": "v1", "new_classes": ["Chat", "Game", "Document"] }] + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["Chat", "Game", "Document"] } + ] } ``` diff --git a/packages/partyserver/README.md b/packages/partyserver/README.md index 48af8884..a8c0d361 100644 --- a/packages/partyserver/README.md +++ b/packages/partyserver/README.md @@ -75,7 +75,7 @@ And configure your `wrangler.jsonc`: "migrations": [ { "tag": "v1", // Should be unique for each entry - "new_classes": ["MyServer"] + "new_sqlite_classes": ["MyServer"] } ] } diff --git a/packages/partysocket/src/tests/react-hooks.test.tsx b/packages/partysocket/src/tests/react-hooks.test.tsx index 51d1b7a0..c29ad935 100644 --- a/packages/partysocket/src/tests/react-hooks.test.tsx +++ b/packages/partysocket/src/tests/react-hooks.test.tsx @@ -809,7 +809,13 @@ describe.skipIf(!!process.env.GITHUB_ACTIONS)("usePartySocket", () => { // socket.reconnect() on the OLD socket and short-circuits past the // option-change detection that would create a new socket. const { result, rerender } = renderHook( - ({ enabled, query }: { enabled: boolean; query: Record }) => + ({ + enabled, + query + }: { + enabled: boolean; + query: Record; + }) => usePartySocket({ host: "example.com", room: "test-room", @@ -833,6 +839,9 @@ describe.skipIf(!!process.env.GITHUB_ACTIONS)("usePartySocket", () => { // If this fails, the old socket was .reconnect()'d with stale params. await waitFor(() => { expect(result.current).not.toBe(firstSocket); + expect(result.current.partySocketOptions.query).toEqual({ + token: "new-token" + }); }); }); @@ -862,6 +871,174 @@ describe.skipIf(!!process.env.GITHUB_ACTIONS)("usePartySocket", () => { expect(closeSpy).toHaveBeenCalled(); }); + + test("re-enable with same options preserves socket identity", async () => { + const { result, rerender } = renderHook( + ({ enabled }) => + usePartySocket({ + host: "example.com", + room: "test-room", + query: { token: "same-token" }, + enabled, + startClosed: true + }), + { initialProps: { enabled: true } } + ); + + const firstSocket = result.current; + + rerender({ enabled: false }); + rerender({ enabled: true }); + + // Same options → should reconnect the same socket, not create a new one + expect(result.current).toBe(firstSocket); + }); + + test("multiple options changes while disabled uses final options", async () => { + const { result, rerender } = renderHook( + ({ + enabled, + query + }: { + enabled: boolean; + query: Record; + }) => + usePartySocket({ + host: "example.com", + room: "test-room", + query, + enabled, + startClosed: true + }), + { initialProps: { enabled: true, query: { token: "v1" } } } + ); + + const firstSocket = result.current; + + // Disable, then change options twice while disabled + rerender({ enabled: false, query: { token: "v1" } }); + rerender({ enabled: false, query: { token: "v2" } }); + rerender({ enabled: false, query: { token: "v3" } }); + + // Re-enable — should get a new socket (options changed) + rerender({ enabled: true, query: { token: "v3" } }); + + await waitFor(() => { + expect(result.current).not.toBe(firstSocket); + expect(result.current.partySocketOptions.query).toEqual({ + token: "v3" + }); + }); + }); + + test("cleans up pending socket on unmount during options change", async () => { + const { result, rerender, unmount } = renderHook( + ({ room }) => + usePartySocket({ + host: "example.com", + room, + startClosed: true + }), + { initialProps: { room: "room1" } } + ); + + const firstSocket = result.current; + + // Change options to trigger setSocket(newSocket) in the optionsChanged branch + rerender({ room: "room2" }); + + await waitFor(() => { + expect(result.current).not.toBe(firstSocket); + }); + + const closeSpy = vitest.spyOn(result.current, "close"); + + unmount(); + + expect(closeSpy).toHaveBeenCalled(); + }); + + test("does not call reconnect on stale socket during token refresh", async () => { + const { result, rerender } = renderHook( + ({ + enabled, + query + }: { + enabled: boolean; + query: Record; + }) => + usePartySocket({ + host: "example.com", + room: "test-room", + query, + enabled, + startClosed: true + }), + { initialProps: { enabled: true, query: { token: "t1" } } } + ); + + const oldSocket = result.current; + const reconnectSpy = vitest.spyOn(oldSocket, "reconnect"); + + // Disable (simulates auth failure / server close) + rerender({ enabled: false, query: { token: "t1" } }); + + // Re-enable with fresh token (simulates token refresh complete) + rerender({ enabled: true, query: { token: "t2" } }); + + // The old socket should NOT have been reconnected — it should have + // been replaced with a new socket that has the fresh token. + expect(reconnectSpy).not.toHaveBeenCalled(); + + await waitFor(() => { + expect(result.current).not.toBe(oldSocket); + expect(result.current.partySocketOptions.query).toEqual({ + token: "t2" + }); + }); + }); + + test("does not create multiple sockets during single re-enable cycle", async () => { + const socketInstances: unknown[] = []; + + const { result, rerender } = renderHook( + ({ + enabled, + query + }: { + enabled: boolean; + query: Record; + }) => { + const socket = usePartySocket({ + host: "example.com", + room: "test-room", + query, + enabled, + startClosed: true + }); + socketInstances.push(socket); + return socket; + }, + { initialProps: { enabled: true, query: { token: "t1" } } } + ); + + const countBefore = new Set(socketInstances).size; + + // Disable, then re-enable with new token + rerender({ enabled: false, query: { token: "t1" } }); + rerender({ enabled: true, query: { token: "t2" } }); + + await waitFor(() => { + expect(result.current.partySocketOptions.query).toEqual({ + token: "t2" + }); + }); + + // Should have created at most 1 additional socket (the replacement). + // A reconnect storm would create many more. + const uniqueSockets = new Set(socketInstances).size; + expect(uniqueSockets - countBefore).toBeLessThanOrEqual(1); + }); }); describe.skipIf(!!process.env.GITHUB_ACTIONS)("useWebSocket", () => { @@ -1344,5 +1521,192 @@ describe.skipIf(!!process.env.GITHUB_ACTIONS)( expect(closeSpy).toHaveBeenCalled(); }); + + test("re-enable with changed options creates new socket under StrictMode", async () => { + const { result, rerender } = renderHook( + ({ + enabled, + query + }: { + enabled: boolean; + query: Record; + }) => + usePartySocket({ + host: "example.com", + room: "test-room", + query, + enabled, + startClosed: true + }), + { + initialProps: { enabled: true, query: { token: "old" } }, + wrapper: strictModeWrapper + } + ); + + const firstSocket = result.current; + + // Disable, then re-enable with changed query under StrictMode's + // double-invoke. Should still produce exactly one new socket. + rerender({ enabled: false, query: { token: "old" } }); + rerender({ enabled: true, query: { token: "new" } }); + + await waitFor(() => { + expect(result.current).not.toBe(firstSocket); + expect(result.current.partySocketOptions.query).toEqual({ + token: "new" + }); + }); + }); + } +); + +const WIRE_PORT = 50145; + +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Wire-level: usePartySocket enabled/disabled with real connections", + () => { + let wss: WebSocketServer; + let connectionUrls: string[]; + + beforeAll(() => { + connectionUrls = []; + return new Promise((resolve) => { + wss = new WebSocketServer({ port: WIRE_PORT }, () => resolve()); + wss.on("connection", (_ws, req) => { + connectionUrls.push(req.url ?? ""); + }); + }); + }); + + afterAll(() => { + return new Promise((resolve) => { + wss.clients.forEach((client) => { + client.terminate(); + }); + wss.close(() => { + resolve(); + }); + }); + }); + + test("reconnects with fresh query params after disable/re-enable", async () => { + connectionUrls.length = 0; + + const { result, rerender } = renderHook( + ({ + enabled, + query + }: { + enabled: boolean; + query: Record; + }) => + usePartySocket({ + host: `localhost:${WIRE_PORT}`, + room: "wire-test", + query, + enabled + }), + { initialProps: { enabled: true, query: { token: "old" } } } + ); + + // Wait for first connection to arrive at the server + await waitFor( + () => { + expect(connectionUrls.length).toBeGreaterThanOrEqual(1); + }, + { timeout: 10000 } + ); + + const firstUrl = connectionUrls[connectionUrls.length - 1]; + expect(firstUrl).toContain("token=old"); + + // Disable, then re-enable with a fresh token + const urlCountBeforeToggle = connectionUrls.length; + rerender({ enabled: false, query: { token: "old" } }); + + await waitFor( + () => { + expect(result.current.readyState).toBe(WebSocket.CLOSED); + }, + { timeout: 3000 } + ); + + rerender({ enabled: true, query: { token: "fresh" } }); + + // Wait for the new connection with the fresh token + await waitFor( + () => { + expect(connectionUrls.length).toBeGreaterThan(urlCountBeforeToggle); + }, + { timeout: 10000 } + ); + + const latestUrl = connectionUrls[connectionUrls.length - 1]; + expect(latestUrl).toContain("token=fresh"); + expect(latestUrl).not.toContain("token=old"); + + result.current.close(); + }, 30000); + + test("does not cause multiple connections on single re-enable", async () => { + connectionUrls.length = 0; + + const { result, rerender } = renderHook( + ({ + enabled, + query + }: { + enabled: boolean; + query: Record; + }) => + usePartySocket({ + host: `localhost:${WIRE_PORT}`, + room: "storm-wire-test", + query, + enabled + }), + { initialProps: { enabled: true, query: { token: "t1" } } } + ); + + // Wait for initial connection + await waitFor( + () => { + expect(connectionUrls.length).toBeGreaterThanOrEqual(1); + }, + { timeout: 10000 } + ); + + // Disable, then re-enable with new token + rerender({ enabled: false, query: { token: "t1" } }); + + await waitFor( + () => { + expect(result.current.readyState).toBe(WebSocket.CLOSED); + }, + { timeout: 3000 } + ); + + const urlCountAfterClose = connectionUrls.length; + + rerender({ enabled: true, query: { token: "t2" } }); + + // Wait for the new connection + await waitFor( + () => { + expect(connectionUrls.length).toBeGreaterThan(urlCountAfterClose); + }, + { timeout: 10000 } + ); + + // Allow a brief window for any spurious extra connections + await new Promise((r) => setTimeout(r, 500)); + + // Should be exactly 1 new connection after re-enable, not a storm + const newConnections = connectionUrls.length - urlCountAfterClose; + expect(newConnections).toBe(1); + + result.current.close(); + }, 30000); } ); diff --git a/packages/partysub/README.md b/packages/partysub/README.md index 852b55f9..71491ff1 100644 --- a/packages/partysub/README.md +++ b/packages/partysub/README.md @@ -76,7 +76,7 @@ And setup your wrangler.jsonc: "migrations": [ { "tag": "v1", - "new_classes": ["PubSubServer"] + "new_sqlite_classes": ["PubSubServer"] } ] } diff --git a/packages/partywhen/README.md b/packages/partywhen/README.md index 3ec48a3c..ca52a07b 100644 --- a/packages/partywhen/README.md +++ b/packages/partywhen/README.md @@ -29,7 +29,7 @@ export { Scheduler }; "migrations": [ { "tag": "v1", - "new_classes": ["Scheduler"] + "new_sqlite_classes": ["Scheduler"] } ] }