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 <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan Jiang
2026-05-28 13:06:13 +08:00
committed by GitHub
parent 1947830d4b
commit 5732b0dae8
4 changed files with 131 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ export interface RecentIssueEntry {
interface RecentIssuesState {
byWorkspace: Record<string, RecentIssueEntry[]>;
recordVisit: (wsId: string, id: string) => void;
forgetIssue: (wsId: string, id: string) => void;
pruneWorkspaces: (activeWsIds: string[]) => void;
}
@@ -62,6 +63,20 @@ export const useRecentIssuesStore = create<RecentIssuesState>()(
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);