fix(issues): patch My-Issues / Project board caches on move too

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 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-06-22 16:33:59 +08:00
parent 24cf98dead
commit f9cda25a06
2 changed files with 140 additions and 11 deletions

View File

@@ -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<typeof vi.fn<(id: string, data: unknown) => Promise<Issue>>>;
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<ListIssuesCache>(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<ListIssuesCache>(wsKey, makeBucketed());
qc.setQueryData<ListIssuesCache>(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<Issue>((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";

View File

@@ -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<ListIssuesCache>({ queryKey: issueKeys.list(wsId) }),
...qc.getQueriesData<ListIssuesCache>({ 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<ListIssuesCache>({ queryKey: issueKeys.list(wsId) });
qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) });
const prevLists = readBucketedLists();
const firstListData = prevLists[0]?.[1];
const prevDetail = qc.getQueryData<Issue>(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<ListIssuesCache>({
queryKey: issueKeys.list(wsId),
});
for (const [key, cached] of lists) {
if (cached)
qc.setQueryData<ListIssuesCache>(
key,
patchIssueInBuckets(cached, serverIssue.id, serverIssue),
);
for (const [key, cached] of readBucketedLists()) {
qc.setQueryData<ListIssuesCache>(
key,
patchIssueInBuckets(cached, serverIssue.id, serverIssue),
);
}
qc.setQueryData<Issue>(issueKeys.detail(wsId, serverIssue.id), (old) =>
old ? { ...old, ...serverIssue } : old,