From 5418e259a6cc16e0abb448b949a6fa3cc726f6b5 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 05:49:24 +0300 Subject: [PATCH] test(ws): cover the user-provider reconnect-resync branch (#106) Extract makeConnectHandler(queryClient) (owning the firstConnect flag) from UserProvider and test it: first connect does NOT invalidate; a reconnect invalidates both root-sidebar-pages + sidebar-pages. Behavior-identical (#66). Co-Authored-By: Claude Opus 4.8 --- .../src/features/user/connect-resync.test.ts | 74 +++++++++++++++++++ .../src/features/user/connect-resync.ts | 41 ++++++++++ .../src/features/user/user-provider.tsx | 20 +++-- 3 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 apps/client/src/features/user/connect-resync.test.ts create mode 100644 apps/client/src/features/user/connect-resync.ts diff --git a/apps/client/src/features/user/connect-resync.test.ts b/apps/client/src/features/user/connect-resync.test.ts new file mode 100644 index 00000000..2af1c812 --- /dev/null +++ b/apps/client/src/features/user/connect-resync.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from "vitest"; +import { + makeConnectHandler, + shouldResyncOnConnect, + ROOT_SIDEBAR_PAGES_KEY, + SIDEBAR_PAGES_KEY, +} from "./connect-resync"; + +describe("shouldResyncOnConnect", () => { + it("does not resync on the first connect", () => { + expect(shouldResyncOnConnect(true)).toBe(false); + }); + + it("resyncs on a reconnect (not the first connect)", () => { + expect(shouldResyncOnConnect(false)).toBe(true); + }); +}); + +describe("makeConnectHandler", () => { + it("does NOT invalidate on the first connect", () => { + const invalidateQueries = vi.fn(); + const handler = makeConnectHandler({ invalidateQueries }); + + handler(); + + expect(invalidateQueries).not.toHaveBeenCalled(); + }); + + it("invalidates BOTH sidebar keys on the reconnect (second connect)", () => { + const invalidateQueries = vi.fn(); + const handler = makeConnectHandler({ invalidateQueries }); + + // First connect: the initial connection, no resync. + handler(); + expect(invalidateQueries).not.toHaveBeenCalled(); + + // Second connect: a reconnect after a gap, resync both tree levels. + handler(); + + expect(invalidateQueries).toHaveBeenCalledTimes(2); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: [...ROOT_SIDEBAR_PAGES_KEY], + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: [...SIDEBAR_PAGES_KEY], + }); + }); + + it("keeps invalidating on every subsequent reconnect", () => { + const invalidateQueries = vi.fn(); + const handler = makeConnectHandler({ invalidateQueries }); + + handler(); // first connect -> nothing + handler(); // reconnect #1 -> 2 calls + handler(); // reconnect #2 -> 2 more calls + + expect(invalidateQueries).toHaveBeenCalledTimes(4); + }); + + it("isolates state per handler instance (each factory call gets its own flag)", () => { + const invalidateA = vi.fn(); + const invalidateB = vi.fn(); + const handlerA = makeConnectHandler({ invalidateQueries: invalidateA }); + const handlerB = makeConnectHandler({ invalidateQueries: invalidateB }); + + // Exhausting handlerA's first connect must not affect handlerB. + handlerA(); + handlerA(); // reconnect on A + handlerB(); // still A's-independent first connect on B + + expect(invalidateA).toHaveBeenCalledTimes(2); + expect(invalidateB).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/client/src/features/user/connect-resync.ts b/apps/client/src/features/user/connect-resync.ts new file mode 100644 index 00000000..1f3e7d8f --- /dev/null +++ b/apps/client/src/features/user/connect-resync.ts @@ -0,0 +1,41 @@ +import type { QueryClient } from "@tanstack/react-query"; + +// Sidebar tree query keys that must be refetched (through the authorized API) +// after a socket reconnect so the view re-converges after a gap where ws events +// were missed (wifi blip, laptop sleep). Both the root level and the +// nested-page levels of every space tree are invalidated. +export const ROOT_SIDEBAR_PAGES_KEY = ["root-sidebar-pages"] as const; +export const SIDEBAR_PAGES_KEY = ["sidebar-pages"] as const; + +/** + * Pure decision for the reconnect-resync branch. + * + * The first `connect` event is the initial connection and must NOT trigger a + * resync (the data was just fetched). Every subsequent `connect` event is a + * RECONNECT after a gap and should trigger a resync. + */ +export function shouldResyncOnConnect(isFirstConnect: boolean): boolean { + return !isFirstConnect; +} + +/** + * Build the socket `connect` handler that owns the first-connect-vs-reconnect + * logic via a private closure flag. The returned handler is what the component + * registers with `socket.on("connect", ...)`. + * + * - 1st invocation -> first connect, no invalidation. + * - 2nd+ invocation -> reconnect, invalidate both sidebar tree key levels. + */ +export function makeConnectHandler( + queryClient: Pick, +): () => void { + let firstConnect = true; + + return () => { + if (shouldResyncOnConnect(firstConnect)) { + queryClient.invalidateQueries({ queryKey: [...ROOT_SIDEBAR_PAGES_KEY] }); + queryClient.invalidateQueries({ queryKey: [...SIDEBAR_PAGES_KEY] }); + } + firstConnect = false; + }; +} diff --git a/apps/client/src/features/user/user-provider.tsx b/apps/client/src/features/user/user-provider.tsx index 5720f29e..5c29203c 100644 --- a/apps/client/src/features/user/user-provider.tsx +++ b/apps/client/src/features/user/user-provider.tsx @@ -12,6 +12,7 @@ import { useNotificationSocket } from "@/features/notification/hooks/use-notific import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; import { Error404 } from "@/components/ui/error-404.tsx"; import { queryClient } from "@/main.tsx"; +import { makeConnectHandler } from "@/features/user/connect-resync.ts"; export function UserProvider({ children }: React.PropsWithChildren) { const [, setCurrentUser] = useAtom(currentUserAtom); @@ -34,19 +35,16 @@ export function UserProvider({ children }: React.PropsWithChildren) { // @ts-ignore setSocket(newSocket); - // Distinguish the first connect from a reconnect so we only resync after a gap. - let firstConnect = true; + // Distinguish the first connect from a reconnect so we only resync after a + // gap. The handler owns the first-connect-vs-reconnect decision through a + // private closure flag (see makeConnectHandler): on RECONNECT it refetches + // the sidebar tree through the authorized API so the view re-converges after + // a gap where ws events were missed (wifi blip, laptop sleep), invalidating + // both the root level and the nested-page levels of every space tree. + const handleConnect = makeConnectHandler(queryClient); newSocket.on("connect", () => { console.log("ws connected"); - if (!firstConnect) { - // On RECONNECT (not the first connect) refetch the sidebar tree through the - // authorized API so the view re-converges after a gap where ws events were - // missed (wifi blip, laptop sleep). Invalidate both the root level and the - // nested-page levels of every space tree. - queryClient.invalidateQueries({ queryKey: ["root-sidebar-pages"] }); - queryClient.invalidateQueries({ queryKey: ["sidebar-pages"] }); - } - firstConnect = false; + handleConnect(); }); return () => {