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>
This commit is contained in:
Naiyuan Qing
2026-06-22 20:45:20 +08:00
parent 42b4bc6af5
commit 53c04b6d6b
3 changed files with 117 additions and 72 deletions

View File

@@ -24,6 +24,7 @@ import { BoardCardContent } from "./board-card";
import { HiddenColumnsPanel, HiddenColumnRow } from "./hidden-columns-panel";
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
import type { ChildProgress } from "./list-row";
import { useDragSettle } from "./use-drag-settle";
import { useT } from "../../i18n";
import {
type DragMoveUpdates,
@@ -214,35 +215,27 @@ export function BoardView({
// --- Drag state ---
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
const isDraggingRef = useRef(false);
const isSettlingRef = useRef(false);
const [settleVersion, setSettleVersion] = useState(0);
// --- Local columns state ---
// Between drags: follows TQ via useEffect.
// During drag: local-only, driven by onDragOver/onDragEnd.
const [columns, setColumns] = useState<Record<string, string[]>>(() =>
buildColumns(groupedIssues, groups, grouping),
);
const columnsRef = useRef(columns);
columnsRef.current = columns;
// Shared drag/settle primitive: owns the local column mirror, the
// dragging/settling locks, the post-move animation-frame throttle, and the
// settle callback. Shared with list-view (and swimlane) so the surfaces
// can't drift apart. Local columns follow TQ between drags via the resync
// effect below; during a drag/settle they are frozen by the locks.
const {
columns,
setColumns,
columnsRef,
isDraggingRef,
isSettlingRef,
recentlyMovedRef,
settleVersion,
beginSettle,
} = useDragSettle(() => buildColumns(groupedIssues, groups, grouping));
useEffect(() => {
if (!isDraggingRef.current && !isSettlingRef.current) {
setColumns(buildColumns(groupedIssues, groups, grouping));
}
}, [groupedIssues, groups, grouping, settleVersion]);
// After a cross-column move, lock for one animation frame so dnd-kit's
// collision detection can stabilize before processing the next move.
// Without this, collision oscillates: A→B→A→B… until React bails out.
const recentlyMovedRef = useRef(false);
useEffect(() => {
const id = requestAnimationFrame(() => {
recentlyMovedRef.current = false;
});
return () => cancelAnimationFrame(id);
}, [columns]);
}, [groupedIssues, groups, grouping, settleVersion, setColumns, isDraggingRef, isSettlingRef]);
// --- Issue map ---
// Frozen during drag so BoardColumn/DraggableBoardCard props stay
@@ -270,7 +263,7 @@ export function BoardView({
const issue = issueMapRef.current.get(event.active.id as string) ?? null;
setActiveIssue(issue);
},
[],
[isDraggingRef],
);
const handleDragOver = useCallback(
@@ -297,7 +290,7 @@ export function BoardView({
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
});
},
[groupIds, sortBy],
[groupIds, sortBy, recentlyMovedRef, setColumns],
);
const handleDragEnd = useCallback(
@@ -377,11 +370,7 @@ export function BoardView({
);
return { ...prev, [activeCol]: fromIds, [overCol]: toIds };
});
isSettlingRef.current = true;
onMoveIssue(activeId, getMoveUpdates(finalGroup, currentIssue.position), () => {
isSettlingRef.current = false;
setSettleVersion((v) => v + 1);
});
onMoveIssue(activeId, getMoveUpdates(finalGroup, currentIssue.position), beginSettle());
return;
}
@@ -397,18 +386,14 @@ export function BoardView({
return;
}
isSettlingRef.current = true;
onMoveIssue(activeId, getMoveUpdates(finalGroup, newPosition), () => {
isSettlingRef.current = false;
// Reconcile local columns from the cache once settled: a no-op on
// success (onSuccess already patched the moved card in place), and the
// revert path on error (onError restored the snapshot). Without this
// bump a failed move would leave the card stranded at the drop target,
// since onSettled no longer refetches the list.
setSettleVersion((v) => v + 1);
});
// beginSettle() holds the lock and returns the onSettled callback that
// releases it and resyncs local columns from the cache: a no-op on
// success (onSuccess already patched the moved card in place), the revert
// on error (onError restored the snapshot). Without it a failed move would
// strand the card at the drop target, since onSettled no longer refetches.
onMoveIssue(activeId, getMoveUpdates(finalGroup, newPosition), beginSettle());
},
[groupedIssues, groups, grouping, onMoveIssue, groupIds, groupMap, sortBy],
[groupedIssues, groups, grouping, onMoveIssue, groupIds, groupMap, sortBy, beginSettle, columnsRef, isDraggingRef, setColumns],
);
return (

View File

@@ -25,6 +25,7 @@ import { useViewStore } from "@multica/core/issues/stores/view-store-context";
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
import { StatusHeading } from "./status-heading";
import { ListRow, DraggableListRow, type ChildProgress } from "./list-row";
import { useDragSettle } from "./use-drag-settle";
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
import { useT } from "../../i18n";
import {
@@ -114,29 +115,24 @@ export function ListView({
// --- Drag state ---
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
const isDraggingRef = useRef(false);
const isSettlingRef = useRef(false);
const [settleVersion, setSettleVersion] = useState(0);
const [columns, setColumns] = useState<Record<string, string[]>>(() =>
buildColumns(issues, groups, "status"),
);
const columnsRef = useRef(columns);
columnsRef.current = columns;
// Shared drag/settle primitive (see use-drag-settle) — same machine as
// board-view, so the two surfaces can't drift apart.
const {
columns,
setColumns,
columnsRef,
isDraggingRef,
isSettlingRef,
recentlyMovedRef,
settleVersion,
beginSettle,
} = useDragSettle(() => buildColumns(issues, groups, "status"));
useEffect(() => {
if (!isDraggingRef.current && !isSettlingRef.current) {
setColumns(buildColumns(issues, groups, "status"));
}
}, [issues, groups, settleVersion]);
const recentlyMovedRef = useRef(false);
useEffect(() => {
const id = requestAnimationFrame(() => {
recentlyMovedRef.current = false;
});
return () => cancelAnimationFrame(id);
}, [columns]);
}, [issues, groups, settleVersion, setColumns, isDraggingRef, isSettlingRef]);
const issueMap = useMemo(() => {
const map = new Map<string, Issue>();
@@ -166,7 +162,7 @@ export function ListView({
const issue = issueMapRef.current.get(event.active.id as string) ?? null;
setActiveIssue(issue);
},
[],
[isDraggingRef],
);
const handleDragOver = useCallback(
@@ -193,7 +189,7 @@ export function ListView({
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
});
},
[groupIds, sortBy],
[groupIds, sortBy, recentlyMovedRef, setColumns],
);
const handleDragEnd = useCallback(
@@ -270,11 +266,7 @@ export function ListView({
);
return { ...prev, [activeCol]: fromIds, [finalCol]: toIds };
});
isSettlingRef.current = true;
onMoveIssue(activeId, getMoveUpdates(finalGroup, currentIssue.position), () => {
isSettlingRef.current = false;
setSettleVersion((v) => v + 1);
});
onMoveIssue(activeId, getMoveUpdates(finalGroup, currentIssue.position), beginSettle());
return;
}
@@ -290,12 +282,12 @@ export function ListView({
return;
}
isSettlingRef.current = true;
onMoveIssue(activeId, getMoveUpdates(finalGroup, newPosition), () => {
isSettlingRef.current = false;
});
// beginSettle() also bumps settleVersion on settle (board-view did, this
// branch did not) so a failed position move reverts instead of stranding
// the row at the drop target.
onMoveIssue(activeId, getMoveUpdates(finalGroup, newPosition), beginSettle());
},
[issues, groups, onMoveIssue, groupIds, groupMap, sortBy],
[issues, groups, onMoveIssue, groupIds, groupMap, sortBy, beginSettle, setColumns, columnsRef, isDraggingRef],
);
const content = (

View File

@@ -0,0 +1,68 @@
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Shared drag/settle state machine for the issue boards (board-view, list-view).
*
* All three drag surfaces (board, list, swimlane) follow the same contract:
*
* - Local column state mirrors the TanStack Query cache *between* drags.
* - While dragging, or while a drop is *settling* (the move mutation is
* in flight), that mirror is frozen so an optimistic move isn't clobbered
* by a cache change that lands mid-flight.
* - On settle the lock releases and `settleVersion` bumps, forcing one resync
* from the now-reconciled cache.
*
* This hook owns that primitive so the surfaces can't drift apart (list-view
* once silently lost the optimistic-move half of it). The resync `useEffect`
* itself stays in each caller because its dependency list is data-source
* specific (workspace board vs. status-only list), but it reads `settleVersion`
* and the refs from here.
*
* `initialColumns` is only read once (useState initializer); callers drive
* subsequent updates through their own resync effect + `setColumns`.
*/
export function useDragSettle(
initialColumns: () => Record<string, string[]>,
) {
const isDraggingRef = useRef(false);
const isSettlingRef = useRef(false);
// Throttles onDragOver: set true after a local move, cleared one frame later.
const recentlyMovedRef = useRef(false);
const [settleVersion, setSettleVersion] = useState(0);
const [columns, setColumns] = useState<Record<string, string[]>>(
initialColumns,
);
const columnsRef = useRef(columns);
columnsRef.current = columns;
useEffect(() => {
const id = requestAnimationFrame(() => {
recentlyMovedRef.current = false;
});
return () => cancelAnimationFrame(id);
}, [columns]);
/**
* Engage the settle lock and return the `onSettled` callback to hand to the
* move mutation. The callback releases the lock and triggers a single resync.
*/
const beginSettle = useCallback(() => {
isSettlingRef.current = true;
return () => {
isSettlingRef.current = false;
setSettleVersion((v) => v + 1);
};
}, []);
return {
columns,
setColumns,
columnsRef,
isDraggingRef,
isSettlingRef,
recentlyMovedRef,
settleVersion,
beginSettle,
};
}