mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
50
packages/core/issues/delete-cache.test.ts
Normal file
50
packages/core/issues/delete-cache.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user