From 9455310c0cdfdede746ea98e96012e533af82b93 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:36:09 +0800 Subject: [PATCH] fix(realtime): invalidate per-issue caches on WS reconnect (#3992) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: J Co-authored-by: multica-agent --- packages/core/issues/queries.ts | 26 +++++++++++----- .../use-realtime-sync-ws-instance.test.tsx | 30 +++++++++++++++++-- packages/core/realtime/use-realtime-sync.ts | 13 ++++++++ 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/packages/core/issues/queries.ts b/packages/core/issues/queries.ts index beceaa5f6..aefd544d7 100644 --- a/packages/core/issues/queries.ts +++ b/packages/core/issues/queries.ts @@ -64,23 +64,35 @@ 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, - /** Per-issue task list (issue-detail Execution log section). */ - tasks: (issueId: string) => ["issues", "tasks", issueId] as const, + attachments: (issueId: string) => + [...issueKeys.attachmentsAll(), issueId] as const, /** Prefix-match key for invalidating tasks across all issues — used by * the global WS task: prefix path so any task lifecycle event refreshes * every per-issue list, regardless of which issue is currently mounted. */ tasksAll: () => ["issues", "tasks"] as const, + /** Per-issue task list (issue-detail Execution log section). */ + tasks: (issueId: string) => [...issueKeys.tasksAll(), issueId] as const, }; export type MyIssuesFilter = Pick< diff --git a/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx b/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx index 9a349d6b1..0e70f7c58 100644 --- a/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx +++ b/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx @@ -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"]); + }); }); diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index b619fdd3b..e63a42d9d 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -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() }); }