mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 04:25:46 +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>
This commit is contained in:
@@ -64,17 +64,29 @@ export const issueKeys = {
|
||||
[...issueKeys.childrenByParentsAll(wsId), parentIds] as const,
|
||||
childProgress: (wsId: string) =>
|
||||
[...issueKeys.all(wsId), "child-progress"] as const,
|
||||
/** Prefix-match keys for invalidating the per-issue caches below across
|
||||
* all issues. These keys carry no wsId, so `issueKeys.all(wsId)` does NOT
|
||||
* cover them — WS reconnect recovery must invalidate these `*All`
|
||||
* prefixes explicitly, or missed events leave them stale forever under
|
||||
* the staleTime: Infinity default (#3953). */
|
||||
timelineAll: () => ["issues", "timeline"] as const,
|
||||
/** Full-issue timeline (single TanStack Query, no cursor). */
|
||||
timeline: (issueId: string) =>
|
||||
["issues", "timeline", issueId] as const,
|
||||
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
|
||||
[...issueKeys.timelineAll(), issueId] as const,
|
||||
reactionsAll: () => ["issues", "reactions"] as const,
|
||||
reactions: (issueId: string) =>
|
||||
[...issueKeys.reactionsAll(), issueId] as const,
|
||||
subscribersAll: () => ["issues", "subscribers"] as const,
|
||||
subscribers: (issueId: string) =>
|
||||
["issues", "subscribers", issueId] as const,
|
||||
usage: (issueId: string) => ["issues", "usage", issueId] as const,
|
||||
[...issueKeys.subscribersAll(), issueId] as const,
|
||||
usageAll: () => ["issues", "usage"] as const,
|
||||
usage: (issueId: string) => [...issueKeys.usageAll(), issueId] as const,
|
||||
attachmentsAll: () => ["issues", "attachments"] as const,
|
||||
/** Issue-level attachments — used by the description editor so its
|
||||
* inline file-card / image NodeViews can re-sign download URLs at
|
||||
* click time. */
|
||||
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
|
||||
attachments: (issueId: string) =>
|
||||
[...issueKeys.attachmentsAll(), issueId] as const,
|
||||
/** Per-issue task list (issue-detail Execution log section). */
|
||||
tasks: (issueId: string) => ["issues", "tasks", issueId] as const,
|
||||
/** Prefix-match key for invalidating tasks across all issues — used by
|
||||
|
||||
@@ -102,8 +102,9 @@ describe("useRealtimeSync — ws instance change", () => {
|
||||
rerender({ ws: ws2 });
|
||||
|
||||
// Should have called invalidateQueries for all workspace-scoped keys
|
||||
// (15 workspace-scoped + 1 workspaceKeys.list() = 16 calls)
|
||||
expect(invalidateSpy).toHaveBeenCalledTimes(16);
|
||||
// (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", () => {
|
||||
@@ -138,4 +139,29 @@ describe("useRealtimeSync — ws instance change", () => {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -313,6 +313,19 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: labelKeys.all(wsId) });
|
||||
}
|
||||
// Per-issue caches are keyed without wsId, so the issueKeys.all(wsId)
|
||||
// prefix above does not reach them. They rely entirely on WS events for
|
||||
// freshness (staleTime: Infinity), so events missed while disconnected
|
||||
// left them stale until a full reload — the inbox showed an agent's new
|
||||
// comment while the issue timeline didn't (#3953). Inactive caches only
|
||||
// get marked stale here and refetch on next mount; the one mounted issue
|
||||
// refetches immediately, same as its own useWSReconnect already does.
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timelineAll() });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.reactionsAll() });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.subscribersAll() });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.usageAll() });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.attachmentsAll() });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.tasksAll() });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user