From 0399e387f84edfc2907719fa2322943e6059431f Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:23:27 +0800 Subject: [PATCH] fix(views): resolve stale status in cmd+k recent issues list Recent issues store was duplicating server data (title, status, identifier) in Zustand, violating the single-source-of-truth architecture. Now the store only tracks visit records (id + visitedAt), and the search command joins fresh data from the TanStack Query issue list cache at render time. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/issues/stores/recent-issues-store.ts | 12 ++-- .../views/issues/components/issue-detail.tsx | 7 +-- packages/views/search/search-command.test.tsx | 57 ++++++++++++++++++- packages/views/search/search-command.tsx | 16 +++++- 4 files changed, 74 insertions(+), 18 deletions(-) diff --git a/packages/core/issues/stores/recent-issues-store.ts b/packages/core/issues/stores/recent-issues-store.ts index d8c5f6740..a77471219 100644 --- a/packages/core/issues/stores/recent-issues-store.ts +++ b/packages/core/issues/stores/recent-issues-store.ts @@ -2,7 +2,6 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; -import type { IssueStatus } from "../../types"; import { createWorkspaceAwareStorage, registerForWorkspaceRehydration, @@ -13,25 +12,22 @@ const MAX_RECENT_ISSUES = 20; export interface RecentIssueEntry { id: string; - identifier: string; - title: string; - status: IssueStatus; visitedAt: number; } interface RecentIssuesState { items: RecentIssueEntry[]; - recordVisit: (entry: Omit) => void; + recordVisit: (id: string) => void; } export const useRecentIssuesStore = create()( persist( (set) => ({ items: [], - recordVisit: (entry) => + recordVisit: (id) => set((state) => { - const filtered = state.items.filter((i) => i.id !== entry.id); - const updated: RecentIssueEntry = { ...entry, visitedAt: Date.now() }; + const filtered = state.items.filter((i) => i.id !== id); + const updated: RecentIssueEntry = { id, visitedAt: Date.now() }; return { items: [updated, ...filtered].slice(0, MAX_RECENT_ISSUES), }; diff --git a/packages/views/issues/components/issue-detail.tsx b/packages/views/issues/components/issue-detail.tsx index 3983222c0..01e49c81e 100644 --- a/packages/views/issues/components/issue-detail.tsx +++ b/packages/views/issues/components/issue-detail.tsx @@ -367,12 +367,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo const recordVisit = useRecentIssuesStore((s) => s.recordVisit); useEffect(() => { if (issue) { - recordVisit({ - id: issue.id, - identifier: issue.identifier, - title: issue.title, - status: issue.status, - }); + recordVisit(issue.id); } }, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps diff --git a/packages/views/search/search-command.test.tsx b/packages/views/search/search-command.test.tsx index 4648abe9b..e91f13382 100644 --- a/packages/views/search/search-command.test.tsx +++ b/packages/views/search/search-command.test.tsx @@ -5,10 +5,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { SearchCommand } from "./search-command"; import { useSearchStore } from "./search-store"; -const { mockPush, mockSearchIssues, mockSearchProjects } = vi.hoisted(() => ({ +const { mockPush, mockSearchIssues, mockSearchProjects, mockRecentItems, mockAllIssues } = vi.hoisted(() => ({ mockPush: vi.fn(), mockSearchIssues: vi.fn(), mockSearchProjects: vi.fn(), + mockRecentItems: { current: [] as Array<{ id: string; visitedAt: number }> }, + mockAllIssues: { current: [] as Array> }, })); vi.mock("@multica/core/api", () => ({ @@ -19,12 +21,24 @@ vi.mock("@multica/core/api", () => ({ })); vi.mock("@multica/core/issues/stores", () => ({ - useRecentIssuesStore: (selector?: (state: { items: [] }) => unknown) => { - const state = { items: [] as [] }; + useRecentIssuesStore: (selector?: (state: { items: typeof mockRecentItems.current }) => unknown) => { + const state = { items: mockRecentItems.current }; return selector ? selector(state) : state; }, })); +vi.mock("@multica/core", () => ({ + useWorkspaceId: () => "ws-test", +})); + +vi.mock("@multica/core/issues/queries", () => ({ + issueListOptions: () => ({ queryKey: ["issues", "ws-test", "list"], enabled: false }), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: mockAllIssues.current }), +})); + vi.mock("../navigation", () => ({ useNavigation: () => ({ push: mockPush, @@ -36,6 +50,8 @@ describe("SearchCommand", () => { mockPush.mockReset(); mockSearchIssues.mockReset().mockResolvedValue({ issues: [] }); mockSearchProjects.mockReset().mockResolvedValue({ projects: [] }); + mockRecentItems.current = []; + mockAllIssues.current = []; // cmdk calls scrollIntoView on the first selected item, which jsdom doesn't implement Element.prototype.scrollIntoView = vi.fn(); @@ -97,4 +113,39 @@ describe("SearchCommand", () => { expect(mockPush).toHaveBeenCalledWith("/settings"); expect(useSearchStore.getState().open).toBe(false); }); + + it("renders recent issues from query cache joined with store visit records", () => { + mockRecentItems.current = [ + { id: "issue-1", visitedAt: 1000 }, + { id: "issue-2", visitedAt: 900 }, + ]; + mockAllIssues.current = [ + { id: "issue-1", identifier: "MUL-1", title: "First issue", status: "todo" }, + { id: "issue-2", identifier: "MUL-2", title: "Second issue", status: "done" }, + ]; + + render(); + + expect(screen.getByText("Recent")).toBeInTheDocument(); + expect(screen.getByText("First issue")).toBeInTheDocument(); + expect(screen.getByText("MUL-1")).toBeInTheDocument(); + expect(screen.getByText("Second issue")).toBeInTheDocument(); + expect(screen.getByText("MUL-2")).toBeInTheDocument(); + }); + + it("filters out recent items not present in query cache", () => { + mockRecentItems.current = [ + { id: "issue-1", visitedAt: 1000 }, + { id: "deleted-issue", visitedAt: 900 }, + ]; + mockAllIssues.current = [ + { id: "issue-1", identifier: "MUL-1", title: "Existing issue", status: "in_progress" }, + ]; + + render(); + + expect(screen.getByText("Recent")).toBeInTheDocument(); + expect(screen.getByText("Existing issue")).toBeInTheDocument(); + expect(screen.queryByText("deleted-issue")).not.toBeInTheDocument(); + }); }); diff --git a/packages/views/search/search-command.tsx b/packages/views/search/search-command.tsx index 00ade59e3..ff95fd19f 100644 --- a/packages/views/search/search-command.tsx +++ b/packages/views/search/search-command.tsx @@ -17,9 +17,12 @@ import { type LucideIcon, } from "lucide-react"; import { Command as CommandPrimitive } from "cmdk"; +import { useQuery } from "@tanstack/react-query"; import type { SearchIssueResult, SearchProjectResult } from "@multica/core/types"; import { api } from "@multica/core/api"; import { useRecentIssuesStore } from "@multica/core/issues/stores"; +import { issueListOptions } from "@multica/core/issues/queries"; +import { useWorkspaceId } from "@multica/core"; import { StatusIcon } from "../issues/components"; import { STATUS_CONFIG } from "@multica/core/issues/config"; import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config"; @@ -97,7 +100,18 @@ export function SearchCommand() { const { push } = useNavigation(); const open = useSearchStore((s) => s.open); const setOpen = useSearchStore((s) => s.setOpen); - const recentIssues = useRecentIssuesStore((s) => s.items); + const recentItems = useRecentIssuesStore((s) => s.items); + const wsId = useWorkspaceId(); + const { data: allIssues = [] } = useQuery(issueListOptions(wsId)); + + const recentIssues = useMemo(() => { + const issueMap = new Map(allIssues.map((i) => [i.id, i])); + return recentItems.flatMap((item) => { + const issue = issueMap.get(item.id); + return issue ? [issue] : []; + }); + }, [recentItems, allIssues]); + const [query, setQuery] = useState(""); const [results, setResults] = useState({ issues: [], projects: [] }); const [isLoading, setIsLoading] = useState(false);