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:
parent
6afd419dd3
commit
7059105d78
2 changed files with 46 additions and 2 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" : ""} ${
|
||||
|
|
|
|||
Loading…
Reference in a new issue