mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
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:
@@ -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 (
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
68
packages/views/issues/components/use-drag-settle.ts
Normal file
68
packages/views/issues/components/use-drag-settle.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user