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 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-21 05:49:24 +03:00
parent e1e44b2db8
commit 481146f73e
3 changed files with 124 additions and 11 deletions

View File

@@ -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();
});
});

View File

@@ -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<QueryClient, "invalidateQueries">,
): () => void {
let firstConnect = true;
return () => {
if (shouldResyncOnConnect(firstConnect)) {
queryClient.invalidateQueries({ queryKey: [...ROOT_SIDEBAR_PAGES_KEY] });
queryClient.invalidateQueries({ queryKey: [...SIDEBAR_PAGES_KEY] });
}
firstConnect = false;
};
}

View File

@@ -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 () => {