diff --git a/packages/core/issues/queries.ts b/packages/core/issues/queries.ts index beceaa5f6..221419e44 100644 --- a/packages/core/issues/queries.ts +++ b/packages/core/issues/queries.ts @@ -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 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() }); }