From 7059105d78a5202ff304bebd3c3ab1e822fe54e8 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 17 Sep 2026 08:08:10 +0200 Subject: [PATCH] fix(board): stop every successful drag from snapping back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The effect that syncs server state re-ran whenever `busy` flipped, which it does at the end of every drag. It then called setCards(initialCards) with the snapshot it already had — the server had not re-rendered — discarding the optimistic move. The card visibly returned to its original column while the database held the correct value. Tracks the last adopted snapshot in a ref so the effect only acts on a genuinely new one, and calls router.refresh() after a successful move so a new snapshot actually arrives. Also hardens the timeline drag: the pointerup handler compared against `dragging` state, which updates asynchronously, so it could read the pre-drag value and return early with the drag never committing and no error anywhere. Reads a ref instead, and clears on pointercancel. Co-Authored-By: Claude Opus 5 --- src/components/board/board.tsx | 24 +++++++++++++++++++++++- src/components/roadmap/timeline-view.tsx | 24 +++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/components/board/board.tsx b/src/components/board/board.tsx index 2f30e4c..40f7b28 100644 --- a/src/components/board/board.tsx +++ b/src/components/board/board.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import { DndContext, DragOverlay, @@ -132,11 +133,27 @@ export function Board({ const activeCard = activeId ? cards.find((c) => c.id === activeId) : null; + const router = useRouter(); const busy = activeId !== null || saving; useRefreshOnFocus(busy); + /** + * The last server snapshot we adopted. + * + * Load-bearing. Without it this effect re-runs whenever `busy` flips — which + * it does at the end of every successful drag — and calls + * `setCards(initialCards)` with the snapshot we ALREADY had, because the + * server has not re-rendered yet. The optimistic move is discarded and the + * card visibly snaps back to where it started, on every single drag, while + * the database is perfectly correct. + */ + const lastSynced = useRef(initialCards); + useEffect(() => { + // Only adopt a genuinely new snapshot, never a re-run caused by `busy`. + if (initialCards === lastSynced.current) return; + lastSynced.current = initialCards; if (!busy) setCards(initialCards); }, [initialCards, busy]); @@ -202,6 +219,11 @@ export function Board({ if (!response.ok) { console.error("[board] move failed", response.status); setCards(previous); + } else { + // Pull the new server snapshot so `initialCards` reflects the move. + // Without this the optimistic state is the only record of it, and the + // next genuine refresh — a focus event, a navigation — would revert it. + router.refresh(); } } catch (error) { console.error("[board] move failed", error); diff --git a/src/components/roadmap/timeline-view.tsx b/src/components/roadmap/timeline-view.tsx index d0904af..ccb1b00 100644 --- a/src/components/roadmap/timeline-view.tsx +++ b/src/components/roadmap/timeline-view.tsx @@ -29,6 +29,8 @@ export function TimelineView({ }) { const trackRef = useRef(null); const [dragging, setDragging] = useState(null); + /** Read by the pointer handlers; `dragging` is only for the visual state. */ + const draggingRef = useRef(null); const dated = cards.filter((card) => card.targetDate); const undated = cards.filter((card) => !card.targetDate); @@ -157,7 +159,14 @@ export function TimelineView({ onPointerDown={ canReschedule ? (event) => { + event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); + // The ref, not the state, is what the pointerup + // handler reads. State updates are asynchronous, so + // a handler comparing against `dragging` can still + // see the pre-drag value and return early — the drag + // would then never commit, with no error anywhere. + draggingRef.current = item.id; setDragging(item.id); } : undefined @@ -165,8 +174,10 @@ export function TimelineView({ onPointerUp={ canReschedule ? (event) => { - if (dragging !== item.id) return; + if (draggingRef.current !== item.id) return; + draggingRef.current = null; setDragging(null); + const date = dateAt(event.clientX); if (date && date !== card.targetDate.slice(0, 10)) { onReschedule(item.id, date); @@ -174,6 +185,17 @@ export function TimelineView({ } : undefined } + onPointerCancel={ + canReschedule + ? () => { + // A cancelled pointer must clear the drag, or the + // bar stays visually held and the next click on it + // is swallowed. + draggingRef.current = null; + setDragging(null); + } + : undefined + } className={`absolute flex items-center overflow-hidden rounded-full px-2 text-[11px] ${ overdue ? "bg-accent text-accent-fg" : "bg-brand text-brand-fg" } ${canReschedule ? "cursor-ew-resize" : ""} ${