From ac2f2c7789da8f73247b050967b71a42f937ea2f Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:26:43 +0800 Subject: [PATCH] fix(issues): drop redundant WS position->list invalidate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onIssueUpdated already surgically patches the non-filtered workspace board via patchIssueInBuckets (cross-status move + same-column reorder). The extra `if (position) invalidateQueries(list)` re-pulled the whole board on top of that, re-introducing drag flicker through the echoed-back WS event. Removed. Filtered myAll lists still invalidate (membership can change there) — the client-side membership reconciliation for those is a separate follow-up. Co-Authored-By: Claude Opus 4.8 Co-authored-by: multica-agent --- packages/core/issues/ws-updaters.test.ts | 35 ++++++++++++++++++++++++ packages/core/issues/ws-updaters.ts | 14 ++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/core/issues/ws-updaters.test.ts b/packages/core/issues/ws-updaters.test.ts index 9f44a1726..241fc3e83 100644 --- a/packages/core/issues/ws-updaters.test.ts +++ b/packages/core/issues/ws-updaters.test.ts @@ -262,6 +262,41 @@ describe("project progress invalidation", () => { }); }); +describe("onIssueUpdated — position move is surgical, not a list refetch", () => { + let qc: QueryClient; + + beforeEach(() => { + qc = new QueryClient(); + }); + + const issueA: Issue = { ...baseIssue, id: "issue-1", position: 0 }; + const issueB: Issue = { ...baseIssue, id: "issue-2", position: 10 }; + + it("reorders the moved card in place and does NOT invalidate the workspace list", () => { + qc.setQueryData(issueKeys.list(WS_ID), makeListCache(issueA, issueB)); + + // issue-1 moves below issue-2 (position 0 -> 20) — a remote/echoed drag. + onIssueUpdated(qc, WS_ID, { ...issueA, position: 20 }); + + const list = qc.getQueryData(issueKeys.list(WS_ID)); + // Surgically reordered into its new slot: proof the patch alone suffices. + expect(list?.byStatus.todo?.issues.map((i) => i.id)).toEqual(["issue-2", "issue-1"]); + // The old redundant `position -> invalidate(list)` is gone — no full-board + // refetch on top of the surgical patch (that was the flicker source). + expect(qc.getQueryState(issueKeys.list(WS_ID))?.isInvalidated).toBe(false); + }); + + it("still invalidates the filtered myAll lists (membership can change there)", () => { + qc.setQueryData(issueKeys.list(WS_ID), makeListCache(issueA, issueB)); + qc.setQueryData(issueKeys.myAll(WS_ID), makeListCache(issueA)); + + onIssueUpdated(qc, WS_ID, { ...issueA, position: 20 }); + + expectInvalidated(qc, issueKeys.myAll(WS_ID)); + expect(qc.getQueryState(issueKeys.list(WS_ID))?.isInvalidated).toBe(false); + }); +}); + describe("onIssueDeleted", () => { let qc: QueryClient; diff --git a/packages/core/issues/ws-updaters.ts b/packages/core/issues/ws-updaters.ts index 6ad090008..9b7ab826d 100644 --- a/packages/core/issues/ws-updaters.ts +++ b/packages/core/issues/ws-updaters.ts @@ -59,9 +59,17 @@ export function onIssueUpdated( for (const [key, data] of listQueries) { if (data) qc.setQueryData(key, patchIssueInBuckets(data, issue.id, issue)); } - if (issue.position !== undefined) { - qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); - } + // The workspace board (issueKeys.list) is NOT filtered: an issue is always a + // member, so patchIssueInBuckets above is a complete surgical reconcile — + // cross-status move, same-column reorder, and field updates all land in the + // right bucket/slot. The old `if (position) invalidateQueries(list)` re-pulled + // the entire board on top of that, which is the full-list refetch that made a + // drag (local or echoed back over WS) flicker. It is pure redundancy here. + // + // myAll (My Issues / Project / actor lists) IS filtered: a change can move an + // issue in/out of the filter and the client cannot recompute that membership + // here, so that one is still invalidated. (Replacing this with client-side + // membership reconciliation is the separate follow-up — see issue thread.) qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });