mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
* 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>
168 lines
5.9 KiB
TypeScript
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"]);
|
|
});
|
|
});
|