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:
74
apps/client/src/features/user/connect-resync.test.ts
Normal file
74
apps/client/src/features/user/connect-resync.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
41
apps/client/src/features/user/connect-resync.ts
Normal file
41
apps/client/src/features/user/connect-resync.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user