Files
multica/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx
Bohan Jiang 9455310c0c fix(realtime): invalidate per-issue caches on WS reconnect (#3992)
* fix(realtime): invalidate per-issue caches on WS reconnect (MUL-3189)

Per-issue caches (timeline, reactions, subscribers, usage, attachments,
tasks) are keyed without wsId, so the issueKeys.all(wsId) prefix in
invalidateWorkspaceScopedQueries never reached them. With the
staleTime: Infinity default they rely entirely on WS events for
freshness, so a comment:created event lost during a disconnect (e.g.
macOS sleep) left the timeline stale until a full view reload — the
inbox showed the agent's new comment while the issue's comment area
stayed empty.

Add *All prefix helpers for the per-issue key families and invalidate
them in the reconnect / WS-instance-change recovery path. Inactive
caches are only marked stale and refetch on next mount; the mounted
issue refetches immediately, matching its existing useWSReconnect
behavior, so this does not reintroduce the MUL-1941 memo thrash.

Fixes #3953

Co-authored-by: multica-agent <github@multica.ai>

* refactor(core): define issueKeys.tasks via tasksAll prefix helper

Review nit on #3992 — keep the per-issue key families consistently
defined in terms of their *All prefix helpers. No behavior change.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 15:36:09 +08:00

168 lines
5.9 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { WSClient } from "../api/ws-client";
import { useRealtimeSync, type RealtimeSyncStores } from "./use-realtime-sync";
vi.mock("../platform/workspace-storage", () => ({
getCurrentWsId: () => "ws-1",
getCurrentSlug: () => "test-ws",
}));
vi.mock("../paths", () => ({
useHasOnboarded: () => true,
resolvePostAuthDestination: () => "/",
}));
function createMockWs(): WSClient {
return {
on: vi.fn(() => () => {}),
onAny: vi.fn(() => () => {}),
onReconnect: vi.fn(() => () => {}),
} as unknown as WSClient;
}
function createStores(): RealtimeSyncStores {
return {
authStore: Object.assign(() => ({}), {
getState: () => ({ user: { id: "u1" } }),
subscribe: () => () => {},
setState: () => {},
destroy: () => {},
}),
} as unknown as RealtimeSyncStores;
}
function createWrapper(qc: QueryClient) {
// Named function (not arrow) so react/display-name lint rule passes —
// anonymous render-fn components break that rule even in test files.
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
};
}
describe("useRealtimeSync — ws instance change", () => {
let qc: QueryClient;
let stores: RealtimeSyncStores;
let invalidateSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
stores = createStores();
invalidateSpy = vi.spyOn(qc, "invalidateQueries");
});
it("skips invalidation on first non-null ws instance", () => {
const ws = createMockWs();
renderHook(() => useRealtimeSync(ws, stores), {
wrapper: createWrapper(qc),
});
// The main effect calls invalidateQueries for its own setup, but the
// ws-instance-change effect should NOT have fired invalidation.
// The only invalidateQueries calls should come from the main effect's
// event handlers, not from the instance-change effect.
// We verify by checking that no call was made with workspaceKeys.list()
// pattern from the instance-change path (it logs a specific message).
// Simpler: count calls — first mount with a ws should not trigger the
// workspace-scoped bulk invalidation.
expect(invalidateSpy).not.toHaveBeenCalled();
});
it("does not invalidate when ws goes from instance to null", () => {
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
invalidateSpy.mockClear();
rerender({ ws: null });
expect(invalidateSpy).not.toHaveBeenCalled();
});
it("invalidates exactly once when a new ws instance appears after null gap", () => {
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
// Simulate workspace switch: ws -> null -> new ws
invalidateSpy.mockClear();
rerender({ ws: null });
expect(invalidateSpy).not.toHaveBeenCalled();
const ws2 = createMockWs();
rerender({ ws: ws2 });
// Should have called invalidateQueries for all workspace-scoped keys
// (15 workspace-scoped + 6 per-issue prefixes + 1 workspaceKeys.list()
// = 22 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(22);
});
it("does not re-invalidate when rerendered with the same ws instance", () => {
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
invalidateSpy.mockClear();
// Rerender with same instance
rerender({ ws: ws1 });
expect(invalidateSpy).not.toHaveBeenCalled();
});
it("invalidates chat, pins, labels, and invitations queries on ws instance change", () => {
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
invalidateSpy.mockClear();
rerender({ ws: null });
const ws2 = createMockWs();
rerender({ ws: ws2 });
const calls = invalidateSpy.mock.calls.map((call: [{ queryKey?: unknown }, ...unknown[]]) => call[0].queryKey);
expect(calls).toContainEqual(["chat", "ws-1"]);
expect(calls).toContainEqual(["labels", "ws-1"]);
expect(calls).toContainEqual(["workspaces", "ws-1", "invitations"]);
});
it("invalidates per-issue caches (no wsId in key) on ws instance change", () => {
// These keys are not under the ["issues", wsId] prefix, so they need
// their own invalidation on recovery — otherwise events missed while
// disconnected leave them stale forever (staleTime: Infinity, #3953).
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
invalidateSpy.mockClear();
rerender({ ws: null });
const ws2 = createMockWs();
rerender({ ws: ws2 });
const calls = invalidateSpy.mock.calls.map((call: [{ queryKey?: unknown }, ...unknown[]]) => call[0].queryKey);
expect(calls).toContainEqual(["issues", "timeline"]);
expect(calls).toContainEqual(["issues", "reactions"]);
expect(calls).toContainEqual(["issues", "subscribers"]);
expect(calls).toContainEqual(["issues", "usage"]);
expect(calls).toContainEqual(["issues", "attachments"]);
expect(calls).toContainEqual(["issues", "tasks"]);
});
});