diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 9d9806e473d..c2793d745bb 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -41,6 +41,28 @@ describe("LiveUpdatesProvider issue invalidation", () => { } client.clear(); }); + it("refreshes cached tasks whose relation summaries embed a changed blocker", () => { + const client = new QueryClient(); + const blocker = { id: "blocker-uuid", identifier: "PAP-3808", title: "Blocker", status: "blocked" }; + const dependentKey = queryKeys.issues.detail("PAP-3797"); + const dependentByIdKey = queryKeys.issues.detail("dependent-uuid"); + const blockedTaskKey = queryKeys.issues.detail("PAP-3900"); + const unrelatedKey = queryKeys.issues.detail("PAP-1"); + client.setQueryData(dependentKey, { id: "dependent-uuid", identifier: "PAP-3797", blockedBy: [blocker], blocks: [] }); + client.setQueryData(dependentByIdKey, { id: "dependent-uuid", identifier: "PAP-3797", blockedBy: [blocker], blocks: [] }); + client.setQueryData(blockedTaskKey, { id: "blocked-uuid", identifier: "PAP-3900", blockedBy: [], blocks: [blocker] }); + client.setQueryData(unrelatedKey, { id: "other-uuid", identifier: "PAP-1", blockedBy: [], blocks: [] }); + // Authenticated events can carry only the UUID; the dependent cache is still found by its summary id. + __liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", { + entityType: "issue", entityId: "blocker-uuid", action: "issue.updated", actorType: "user", actorId: "user-1", + details: { status: "done", _previous: { status: "blocked" } }, + }, { userId: "user-1", agentId: null }); + expect(client.getQueryState(dependentKey)?.isInvalidated).toBe(true); + expect(client.getQueryState(dependentByIdKey)?.isInvalidated).toBe(true); + expect(client.getQueryState(blockedTaskKey)?.isInvalidated).toBe(true); + expect(client.getQueryState(unrelatedKey)?.isInvalidated).toBe(false); + client.clear(); + }); it("drops cached decision content when its access is revoked", () => { const client = new QueryClient(); const receiptKey = ["issues", "decision-receipts", "chat-1", "user-1"]; diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 86ca8a050c6..782f0d72c0c 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -1498,6 +1498,43 @@ function invalidateHeartbeatProgressQueries( } } +// Actions that can change the status, retry or recovery fields that related +// tasks embed in their `blockedBy`/`blocks` summaries. +const ISSUE_RELATION_SUMMARY_ACTIVITY_ACTIONS = new Set([ + "issue.updated", + "issue.deleted", + "issue.status_decision_recorded", + "issue.checked_out", + "issue.released", + "issue.recovery_action_resolved", + "issue.recovery_generation_replaced", + "issue.retry_requested", + "issue.scheduled_retry_retry_now", +]); + +/** + * A task detail embeds summaries of its blockers and the tasks it blocks. When + * one of those changes, refresh every cached detail that embeds it so the + * dependent page does not keep showing the old blocker status. + */ +function invalidateIssueDetailsEmbeddingRelation(queryClient: QueryClient, refs: Iterable) { + const changed = new Set(refs); + if (changed.size === 0) return; + const embeds = (summaries: unknown) => + Array.isArray(summaries) && + summaries.some((summary) => { + const record = readRecord(summary); + return !!record && (changed.has(readString(record.id) ?? "") || changed.has(readString(record.identifier) ?? "")); + }); + queryClient.invalidateQueries({ + queryKey: ["issues", "detail"], + predicate: (query) => { + const cached = readRecord(query.state.data); + return !!cached && !changed.has(readString(cached.id) ?? "") && (embeds(cached.blockedBy) || embeds(cached.blocks)); + }, + }); +} + function invalidateActivityQueries( queryClient: ReturnType, companyId: string, @@ -1649,6 +1686,9 @@ function invalidateActivityQueries( details, options, ); + if (action && ISSUE_RELATION_SUMMARY_ACTIVITY_ACTIONS.has(action)) { + invalidateIssueDetailsEmbeddingRelation(queryClient, [entityId, ...issueRefs]); + } const taskDocumentsChanged = !!action && ISSUE_DOCUMENT_ACTIVITY_ACTIONS.has(action); const caseWorkChanged = !!action && ISSUE_CASE_WORK_ACTIVITY_ACTIONS.has(action); if (caseWorkChanged) {