From f9cda25a06cd55dab2dd17c8f735cc1bc1c4223c Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:33:59 +0800 Subject: [PATCH] fix(issues): patch My-Issues / Project board caches on move too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drag fix made the board reconcile local columns from its feeding cache on settle. The workspace board rides issueKeys.list (patched by onMutate), but the My-Issues and Project boards ride the myList cache, which the mutation did not patch — so a successful move snapped back on those boards. useUpdateIssue now patches/snapshots/rolls back every bucketed list cache (workspace list + myList), selected by the ListIssuesCache `byStatus` shape so grouped (assignee) and flat (gantt) caches are skipped. Adds renderHook regression tests covering both-cache optimistic move, both-cache rollback, and no-list-invalidation-on-settle. Co-Authored-By: Claude Opus 4.8 Co-authored-by: multica-agent --- packages/core/issues/mutations.test.tsx | 119 +++++++++++++++++++++++- packages/core/issues/mutations.ts | 32 +++++-- 2 files changed, 140 insertions(+), 11 deletions(-) diff --git a/packages/core/issues/mutations.test.tsx b/packages/core/issues/mutations.test.tsx index 997ab8a6d..7de1e4427 100644 --- a/packages/core/issues/mutations.test.tsx +++ b/packages/core/issues/mutations.test.tsx @@ -8,7 +8,12 @@ import type { ReactNode } from "react"; import { setApiInstance } from "../api"; import type { ApiClient } from "../api/client"; -import { useLoadMoreByAssigneeGroup, useLoadMoreByStatus, useResolveComment } from "./mutations"; +import { + useLoadMoreByAssigneeGroup, + useLoadMoreByStatus, + useResolveComment, + useUpdateIssue, +} from "./mutations"; import { issueKeys, type IssueSortParam, @@ -314,6 +319,118 @@ describe("useLoadMoreByAssigneeGroup", () => { }); }); +describe("useUpdateIssue — optimistic move keeps every bucketed board in sync", () => { + const sort: IssueSortParam = { sort_by: "position", sort_direction: undefined }; + const myScope = "assigned"; + const myFilter = { assignee_id: "user-1" }; + const wsKey = issueKeys.listSorted(WS_ID, sort); + // My-Issues AND the Project board both ride this myList cache; a move that + // only patched the workspace cache snaps back on those boards. + const myKey = issueKeys.myListSorted(WS_ID, myScope, myFilter, sort); + + let qc: QueryClient; + let updateIssue: ReturnType Promise>>; + + function makeBucketed(): ListIssuesCache { + return { + byStatus: { + todo: { issues: [makeIssue(1)], total: 1 }, + in_progress: { issues: [], total: 0 }, + }, + }; + } + + function bucketIds( + key: readonly unknown[], + status: "todo" | "in_progress", + ): string[] { + const c = qc.getQueryData(key); + return (c?.byStatus[status]?.issues ?? []).map((i) => i.id); + } + + beforeEach(() => { + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + updateIssue = vi.fn(); + setApiInstance({ updateIssue } as unknown as ApiClient); + qc.setQueryData(wsKey, makeBucketed()); + qc.setQueryData(myKey, makeBucketed()); + }); + + afterEach(() => { + qc.clear(); + vi.restoreAllMocks(); + }); + + it("optimistically moves the card in both the workspace and myList caches", async () => { + let resolve!: (issue: Issue) => void; + updateIssue.mockReturnValue( + new Promise((r) => { + resolve = r; + }), + ); + + const { result } = renderHook(() => useUpdateIssue(), { + wrapper: createWrapper(qc), + }); + + act(() => { + result.current.mutate({ id: "issue-1", status: "in_progress", position: 5 }); + }); + + // Optimistic state — the regression: myList must move too, not just ws. + for (const key of [wsKey, myKey]) { + expect(bucketIds(key, "todo")).toEqual([]); + expect(bucketIds(key, "in_progress")).toEqual(["issue-1"]); + } + + await act(async () => { + resolve(makeIssue(1, { status: "in_progress", position: 5 })); + }); + + // Authoritative settle keeps the card in place in both caches. + for (const key of [wsKey, myKey]) { + expect(bucketIds(key, "in_progress")).toEqual(["issue-1"]); + } + }); + + it("rolls both caches back when the request fails", async () => { + updateIssue.mockRejectedValue(new Error("boom")); + + const { result } = renderHook(() => useUpdateIssue(), { + wrapper: createWrapper(qc), + }); + + await act(async () => { + await result.current + .mutateAsync({ id: "issue-1", status: "in_progress", position: 5 }) + .catch(() => {}); + }); + + for (const key of [wsKey, myKey]) { + expect(bucketIds(key, "todo")).toEqual(["issue-1"]); + expect(bucketIds(key, "in_progress")).toEqual([]); + } + }); + + it("does not invalidate the board list on settle (no refetch flicker)", async () => { + updateIssue.mockResolvedValue(makeIssue(1, { status: "in_progress", position: 5 })); + const invalidateSpy = vi.spyOn(qc, "invalidateQueries"); + + const { result } = renderHook(() => useUpdateIssue(), { + wrapper: createWrapper(qc), + }); + + await act(async () => { + await result.current.mutateAsync({ id: "issue-1", status: "in_progress", position: 5 }); + }); + + const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey); + // The board list + myList are reconciled surgically, never refetched. + expect(invalidatedKeys).not.toContainEqual(issueKeys.list(WS_ID)); + expect(invalidatedKeys).not.toContainEqual(issueKeys.myAll(WS_ID)); + }); +}); + describe("useResolveComment", () => { const ISSUE_ID = "issue-1"; diff --git a/packages/core/issues/mutations.ts b/packages/core/issues/mutations.ts index d1d35ecc6..0386fb787 100644 --- a/packages/core/issues/mutations.ts +++ b/packages/core/issues/mutations.ts @@ -211,6 +211,21 @@ export function useCreateIssue() { export function useUpdateIssue() { const qc = useQueryClient(); const wsId = useWorkspaceId(); + // Every bucketed board cache an optimistic move must keep in sync: the + // workspace board (issueKeys.list*) AND the My-Issues / Project board + // (issueKeys.myList* under `my`), which share the ListIssuesCache shape. + // Filtering by `byStatus` skips the grouped (assignee) and flat + // (gantt/detail/children) caches that also live under those prefixes. The + // board reconciles local columns from its own feeding cache on settle, so a + // move that only patched the workspace cache would snap back on My-Issues / + // Project boards. + const readBucketedLists = () => + [ + ...qc.getQueriesData({ queryKey: issueKeys.list(wsId) }), + ...qc.getQueriesData({ queryKey: issueKeys.myAll(wsId) }), + ].filter( + (entry): entry is [QueryKey, ListIssuesCache] => !!entry[1]?.byStatus, + ); return useMutation({ mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) => api.updateIssue(id, data), @@ -220,7 +235,8 @@ export function useUpdateIssue() { // yield to the event loop, letting @dnd-kit reset its visual state // before the optimistic update lands. qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); - const prevLists = qc.getQueriesData({ queryKey: issueKeys.list(wsId) }); + qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) }); + const prevLists = readBucketedLists(); const firstListData = prevLists[0]?.[1]; const prevDetail = qc.getQueryData(issueKeys.detail(wsId, id)); @@ -288,15 +304,11 @@ export function useUpdateIssue() { // card re-landed. updateIssue returns the full issue and a position update // touches only that row, so a surgical patch is the authoritative // reconcile and is a visual no-op when the optimistic value matched. - const lists = qc.getQueriesData({ - queryKey: issueKeys.list(wsId), - }); - for (const [key, cached] of lists) { - if (cached) - qc.setQueryData( - key, - patchIssueInBuckets(cached, serverIssue.id, serverIssue), - ); + for (const [key, cached] of readBucketedLists()) { + qc.setQueryData( + key, + patchIssueInBuckets(cached, serverIssue.id, serverIssue), + ); } qc.setQueryData(issueKeys.detail(wsId, serverIssue.id), (old) => old ? { ...old, ...serverIssue } : old,