diff --git a/packages/views/issues/components/board-view.tsx b/packages/views/issues/components/board-view.tsx index aa4d147f0..3748f9676 100644 --- a/packages/views/issues/components/board-view.tsx +++ b/packages/views/issues/components/board-view.tsx @@ -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(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>(() => - 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 ( diff --git a/packages/views/issues/components/list-view.tsx b/packages/views/issues/components/list-view.tsx index f8ca05735..1f12ed61b 100644 --- a/packages/views/issues/components/list-view.tsx +++ b/packages/views/issues/components/list-view.tsx @@ -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(null); - const isDraggingRef = useRef(false); - const isSettlingRef = useRef(false); - const [settleVersion, setSettleVersion] = useState(0); - - const [columns, setColumns] = useState>(() => - 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(); @@ -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 = ( diff --git a/packages/views/issues/components/use-drag-settle.ts b/packages/views/issues/components/use-drag-settle.ts new file mode 100644 index 000000000..01141b134 --- /dev/null +++ b/packages/views/issues/components/use-drag-settle.ts @@ -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, +) { + 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>( + 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, + }; +}