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:
J
2026-06-10 15:19:05 +08:00
parent 34c68e1e4c
commit 02f20c2003
3 changed files with 58 additions and 7 deletions

View File

@@ -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

View File

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

View File

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