fix(board): stop every successful drag from snapping back

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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-17 08:08:10 +02:00
parent 6afd419dd3
commit 7059105d78
2 changed files with 46 additions and 2 deletions

View file

@ -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);

View file

@ -29,6 +29,8 @@ export function TimelineView({
}) {
const trackRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<string | null>(null);
/** Read by the pointer handlers; `dragging` is only for the visual state. */
const draggingRef = useRef<string | null>(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" : ""} ${