From 5732b0dae8880ce6d5294ca47cef06d65306d01e Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Thu, 28 May 2026 13:06:13 +0800 Subject: [PATCH] fix(issues): clear deleted ids from recent issues store (#3420) cleanupDeletedIssueCaches now also calls a new useRecentIssuesStore.forgetIssue(wsId, issueId) action so the persisted Recent Issues bucket no longer keeps deleted ids around. Both the delete mutation and the WS delete event flow through the same cleanup, so this covers self-delete and cross-client delete. Without this, Cmd+K fires a detail query for every recent id on open and returns a steady stream of 404s for issues the user has deleted (#3413). MUL-2765 Co-authored-by: J Co-authored-by: multica-agent --- packages/core/issues/delete-cache.test.ts | 50 ++++++++++++++++ packages/core/issues/delete-cache.ts | 7 +++ .../issues/stores/recent-issues-store.test.ts | 59 +++++++++++++++++++ .../core/issues/stores/recent-issues-store.ts | 15 +++++ 4 files changed, 131 insertions(+) create mode 100644 packages/core/issues/delete-cache.test.ts diff --git a/packages/core/issues/delete-cache.test.ts b/packages/core/issues/delete-cache.test.ts new file mode 100644 index 000000000..95858593a --- /dev/null +++ b/packages/core/issues/delete-cache.test.ts @@ -0,0 +1,50 @@ +import { QueryClient } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { cleanupDeletedIssueCaches } from "./delete-cache"; +import { issueKeys } from "./queries"; +import { useRecentIssuesStore } from "./stores/recent-issues-store"; + +const WS_ID = "ws-a"; + +beforeEach(() => { + useRecentIssuesStore.setState({ byWorkspace: {} }); +}); + +describe("cleanupDeletedIssueCaches — recent issues store", () => { + it("removes the deleted issue from the recent issues bucket", () => { + const { recordVisit } = useRecentIssuesStore.getState(); + recordVisit(WS_ID, "issue-1"); + recordVisit(WS_ID, "issue-2"); + + const qc = new QueryClient(); + cleanupDeletedIssueCaches(qc, WS_ID, "issue-1"); + + const ids = useRecentIssuesStore + .getState() + .byWorkspace[WS_ID]?.map((e) => e.id); + expect(ids).toEqual(["issue-2"]); + }); + + it("does not touch the recent bucket of an unrelated workspace", () => { + const { recordVisit } = useRecentIssuesStore.getState(); + recordVisit(WS_ID, "issue-1"); + recordVisit("ws-b", "issue-1"); + + const qc = new QueryClient(); + cleanupDeletedIssueCaches(qc, WS_ID, "issue-1"); + + const state = useRecentIssuesStore.getState().byWorkspace; + expect(state[WS_ID]).toBeUndefined(); + expect(state["ws-b"]?.map((e) => e.id)).toEqual(["issue-1"]); + }); + + it("still removes the cached detail query for the deleted issue", () => { + const qc = new QueryClient(); + qc.setQueryData(issueKeys.detail(WS_ID, "issue-1"), { id: "issue-1" }); + + cleanupDeletedIssueCaches(qc, WS_ID, "issue-1"); + + expect(qc.getQueryData(issueKeys.detail(WS_ID, "issue-1"))).toBeUndefined(); + }); +}); diff --git a/packages/core/issues/delete-cache.ts b/packages/core/issues/delete-cache.ts index e926a287d..7719b63c1 100644 --- a/packages/core/issues/delete-cache.ts +++ b/packages/core/issues/delete-cache.ts @@ -9,6 +9,7 @@ import { labelKeys } from "../labels/queries"; import type { Issue, ListIssuesCache } from "../types"; import { findIssueLocation, removeIssueFromBuckets } from "./cache-helpers"; import { issueKeys } from "./queries"; +import { useRecentIssuesStore } from "./stores/recent-issues-store"; export type DeletedIssueCacheMetadata = { parentIssueIds: string[]; @@ -172,4 +173,10 @@ export function cleanupDeletedIssueCaches( // scheduled bar visible right now. qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) }); invalidateDeletedIssueDependentCaches(qc, wsId); + + // Recent Issues store persists to localStorage and survives reloads, so a + // deleted id left behind keeps the Cmd+K command bar firing 404s on every + // open. Both the delete mutation and the WS delete event flow through here, + // so a single call covers self-delete and cross-client delete. + useRecentIssuesStore.getState().forgetIssue(wsId, issueId); } diff --git a/packages/core/issues/stores/recent-issues-store.test.ts b/packages/core/issues/stores/recent-issues-store.test.ts index 2cc21fc21..64c0982ff 100644 --- a/packages/core/issues/stores/recent-issues-store.test.ts +++ b/packages/core/issues/stores/recent-issues-store.test.ts @@ -35,6 +35,65 @@ describe("useRecentIssuesStore.recordVisit", () => { }); }); +describe("useRecentIssuesStore.forgetIssue", () => { + it("removes a single id from the workspace bucket", () => { + const { recordVisit, forgetIssue } = useRecentIssuesStore.getState(); + recordVisit("ws-a", "issue-1"); + recordVisit("ws-a", "issue-2"); + recordVisit("ws-a", "issue-3"); + + forgetIssue("ws-a", "issue-2"); + + const ids = useRecentIssuesStore + .getState() + .byWorkspace["ws-a"]?.map((e) => e.id); + expect(ids).toEqual(["issue-3", "issue-1"]); + }); + + it("drops the bucket entirely when the last id is removed", () => { + const { recordVisit, forgetIssue } = useRecentIssuesStore.getState(); + recordVisit("ws-a", "issue-1"); + recordVisit("ws-b", "issue-2"); + + forgetIssue("ws-a", "issue-1"); + + const state = useRecentIssuesStore.getState().byWorkspace; + expect(state["ws-a"]).toBeUndefined(); + expect(state["ws-b"]?.map((e) => e.id)).toEqual(["issue-2"]); + }); + + it("does not touch other workspaces' buckets", () => { + const { recordVisit, forgetIssue } = useRecentIssuesStore.getState(); + recordVisit("ws-a", "issue-1"); + recordVisit("ws-b", "issue-1"); + + forgetIssue("ws-a", "issue-1"); + + const state = useRecentIssuesStore.getState().byWorkspace; + expect(state["ws-a"]).toBeUndefined(); + expect(state["ws-b"]?.map((e) => e.id)).toEqual(["issue-1"]); + }); + + it("is a no-op when the id is not in the bucket", () => { + const { recordVisit, forgetIssue } = useRecentIssuesStore.getState(); + recordVisit("ws-a", "issue-1"); + const before = useRecentIssuesStore.getState().byWorkspace; + + forgetIssue("ws-a", "issue-missing"); + + expect(useRecentIssuesStore.getState().byWorkspace).toBe(before); + }); + + it("is a no-op when the workspace has no bucket", () => { + const { forgetIssue } = useRecentIssuesStore.getState(); + const before = useRecentIssuesStore.getState().byWorkspace; + + forgetIssue("ws-missing", "issue-1"); + + expect(useRecentIssuesStore.getState().byWorkspace).toBe(before); + }); +}); + describe("useRecentIssuesStore.pruneWorkspaces", () => { it("drops buckets for workspaces not in the active set", () => { const { recordVisit, pruneWorkspaces } = useRecentIssuesStore.getState(); diff --git a/packages/core/issues/stores/recent-issues-store.ts b/packages/core/issues/stores/recent-issues-store.ts index da74f2db4..c81f12731 100644 --- a/packages/core/issues/stores/recent-issues-store.ts +++ b/packages/core/issues/stores/recent-issues-store.ts @@ -16,6 +16,7 @@ export interface RecentIssueEntry { interface RecentIssuesState { byWorkspace: Record; recordVisit: (wsId: string, id: string) => void; + forgetIssue: (wsId: string, id: string) => void; pruneWorkspaces: (activeWsIds: string[]) => void; } @@ -62,6 +63,20 @@ export const useRecentIssuesStore = create()( return { byWorkspace: nextByWorkspace }; }), + forgetIssue: (wsId, id) => + set((state) => { + const bucket = state.byWorkspace[wsId]; + if (!bucket) return state; + const nextBucket = bucket.filter((entry) => entry.id !== id); + if (nextBucket.length === bucket.length) return state; + if (nextBucket.length === 0) { + const { [wsId]: _, ...rest } = state.byWorkspace; + return { byWorkspace: rest }; + } + return { + byWorkspace: { ...state.byWorkspace, [wsId]: nextBucket }, + }; + }), pruneWorkspaces: (activeWsIds) => set((state) => { const allow = new Set(activeWsIds);