Files
multica/packages/core/issues/cache-helpers.ts
Naiyuan Qing 45dae3185f fix(issues): eliminate optimistic-update drag flicker (board, list, batch, WS) (#4415)
* fix(issues): stop kanban card snapping back on drag

A cross-column drag on a non-position-sorted board left the card in its
origin column for the whole request, then jumped to the target only when
the mutation settled — the "snaps back, then moves" glitch. Root cause was
three coupled choices in the optimistic path:

- board-view never updated local columns on drop for sortBy != "position"
  (onDragOver is a no-op there), so the card relied on the settle refetch
  to move across.
- useUpdateIssue invalidated the whole list on settle, replacing the column
  and re-landing the card even on success.
- patchIssueInBuckets appended a moved card to the column tail instead of
  its position slot, so any later cache refresh teleported it to the end.

Fixes:
- board-view: optimistically move the card into the target column on drop
  for the non-position path (insertIdByPosition), and reconcile local
  columns from the cache on settle for both paths (revert on error now that
  the list is no longer refetched).
- mutations: reconcile via onSuccess surgical patch of the returned entity;
  drop the list/detail invalidation from onSettled (aggregates still flush).
- cache-helpers: patchIssueInBuckets inserts the moved/reordered card at its
  position slot; a plain field update still keeps its slot.

Adds cache-helpers and drag-utils unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* 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>

* fix(issues): drop redundant WS position->list invalidate

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 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): batch update patches myList + stops list refetch on settle

- onMutate now patches both issueKeys.list and the filtered issueKeys.myAll
  bucketed caches, so a batch edit on a My-Issues / Project board is
  optimistic too. Previously only the workspace board was patched, so batch
  edits on those boards relied entirely on the settle refetch.
- onSettled no longer invalidates issueKeys.list: the optimistic patch is a
  complete reconcile for these bucketed boards (batch changes status /
  priority / project, never a server-computed value), so a full-board
  refetch only re-introduced the flicker the single-issue path removed.
  Aggregate / grouped caches still refresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): list view optimistically moves row on non-position drag

The sortBy != "position" branch called onMoveIssue without moving the row in
local columns, so the row sat in its origin group for the whole request and
only jumped across on settle -- the same snap-back the board view had before
its fix. Now mirrors board-view: setColumns(insertIdByPosition) on drop so
the settle rebuild is a visual no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): keep My-Issues/Project boards in place on non-membership WS change

onIssueUpdated now surgically patches the filtered myList (myAll) caches and
only invalidates them when the change can actually move an issue in/out of the
filter: an assignee change (covers My-Issues direct-assignee + the involves leg
+ actor panels) or a project change (Project board). A pure status / position /
priority / label change reconciles in place -- no refetch -- removing the last
drag flicker on filtered boards.

Uses the assignee_changed flag the server already sends on issue:updated
(surfaced on IssueUpdatedPayload + forwarded by the realtime dispatch); project
change is diffed client-side against the cached value. No predicate replication,
no backend change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): add settle-lock to swimlane drag (no clobber mid-flight)

The swimlane drag had no settle window: the resync useEffect (and the issueMap
freeze) guarded only isDraggingRef, so a cache change landing after drop but
before the move settled could rebuild localCells out from under the optimistic
move. Adds isSettlingRef + settleVersion (mirroring board-view / list-view): the
lock is held from drop until onMoveIssue settles, then released, forcing a
single resync from the reconciled cache.

onMoveIssue now accepts the same optional onSettled callback board/list already
use; the parent handleMoveIssue supplies it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* refactor(issues): extract shared useDragSettle hook for board + list

board-view and list-view carried byte-identical drag/settle scaffolding (the
local columns mirror, the dragging/settling locks, the post-move animation-frame
throttle, and the settle callback). That duplication is exactly what let
list-view silently drift earlier (it had lost the optimistic-move half of the
fix, and its position-branch settle callback omitted the settleVersion bump).
Extract the primitive into useDragSettle so both surfaces share one
implementation and can't drift again.

Behavior-preserving for board-view. For list-view the one intended alignment:
its position-branch failed move now reverts, gaining the settleVersion bump
board-view already had.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 09:20:01 +08:00

133 lines
4.3 KiB
TypeScript

import type {
Issue,
IssueStatus,
IssueStatusBucket,
ListIssuesCache,
} from "../types";
import { PAGINATED_STATUSES } from "./queries";
const EMPTY_BUCKET: IssueStatusBucket = { issues: [], total: 0 };
export function getBucket(
resp: ListIssuesCache,
status: IssueStatus,
): IssueStatusBucket {
return resp.byStatus[status] ?? EMPTY_BUCKET;
}
export function setBucket(
resp: ListIssuesCache,
status: IssueStatus,
bucket: IssueStatusBucket,
): ListIssuesCache {
return { ...resp, byStatus: { ...resp.byStatus, [status]: bucket } };
}
/** Locate which status bucket holds `id`, if any. */
export function findIssueLocation(
resp: ListIssuesCache,
id: string,
): { status: IssueStatus; issue: Issue } | null {
for (const status of PAGINATED_STATUSES) {
const bucket = resp.byStatus[status];
const found = bucket?.issues.find((i) => i.id === id);
if (found) return { status, issue: found };
}
return null;
}
/** Add an issue to its status bucket (no-op if already present). */
export function addIssueToBuckets(
resp: ListIssuesCache,
issue: Issue,
): ListIssuesCache {
const bucket = getBucket(resp, issue.status);
if (bucket.issues.some((i) => i.id === issue.id)) return resp;
return setBucket(resp, issue.status, {
issues: [...bucket.issues, issue],
total: bucket.total + 1,
});
}
/** Remove an issue from whichever bucket contains it. */
export function removeIssueFromBuckets(
resp: ListIssuesCache,
id: string,
): ListIssuesCache {
const loc = findIssueLocation(resp, id);
if (!loc) return resp;
const bucket = getBucket(resp, loc.status);
return setBucket(resp, loc.status, {
issues: bucket.issues.filter((i) => i.id !== id),
total: Math.max(0, bucket.total - 1),
});
}
/**
* Insert `issue` into `issues` at the slot implied by `position ASC` — the same
* ordering the board renders (server `ORDER BY position ASC`). Returns a new
* array; the input is not mutated.
*
* Inserting at the right slot (instead of appending to the end) is what keeps an
* optimistic move from snapping: the card lands where it will be after the
* server confirms, so no later cache refresh teleports it to the column tail.
*/
export function insertByPosition(issues: Issue[], issue: Issue): Issue[] {
const idx = issues.findIndex((i) => i.position > issue.position);
if (idx === -1) return [...issues, issue];
return [...issues.slice(0, idx), issue, ...issues.slice(idx)];
}
/**
* Merge `patch` into the issue with `id`. If `patch.status` differs from the
* current bucket, the issue moves to the new bucket and both buckets' totals
* are adjusted. The moved card — and a same-column card whose `position`
* changed — is re-inserted at its `position`-sorted slot rather than appended,
* so the cache order stays consistent with what the board renders. A plain
* field update (no status/position change) keeps the card in place.
*/
export function patchIssueInBuckets(
resp: ListIssuesCache,
id: string,
patch: Partial<Issue>,
): ListIssuesCache {
const loc = findIssueLocation(resp, id);
if (!loc) return resp;
const merged: Issue = { ...loc.issue, ...patch };
const nextStatus = patch.status ?? loc.status;
if (nextStatus === loc.status) {
const bucket = getBucket(resp, loc.status);
const positionChanged =
patch.position !== undefined && patch.position !== loc.issue.position;
if (!positionChanged) {
// Plain field update (labels, metadata, title, …): keep the slot so a
// remote edit never reorders an otherwise-untouched column.
return setBucket(resp, loc.status, {
...bucket,
issues: bucket.issues.map((i) => (i.id === id ? merged : i)),
});
}
// Same-column reorder: lift the card out and re-insert at its new slot.
return setBucket(resp, loc.status, {
...bucket,
issues: insertByPosition(
bucket.issues.filter((i) => i.id !== id),
merged,
),
});
}
const fromBucket = getBucket(resp, loc.status);
const toBucket = getBucket(resp, nextStatus);
let next = setBucket(resp, loc.status, {
issues: fromBucket.issues.filter((i) => i.id !== id),
total: Math.max(0, fromBucket.total - 1),
});
next = setBucket(next, nextStatus, {
issues: insertByPosition(toBucket.issues, merged),
total: toBucket.total + 1,
});
return next;
}