fix(board): guard optimistic move against a refresh landing mid-save

activeId clears the instant the pointer is released, so gating the
focus-refresh on it alone left a window between drop and server
confirmation where a refresh could overwrite the optimistic position with
stale state and the card would appear to snap back. Tracks a separate
saving flag, and reverts on a thrown fetch as well as a non-ok response.

Also corrects a comment: sensors are created unconditionally; what keeps a
client manager from dragging is that they never render DndContext.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-16 10:51:35 +02:00
parent e419bc21db
commit f082670f8a

View file

@ -92,7 +92,18 @@ export function Board({
const [cards, setCards] = useState<CardRecord[]>(initialCards);
const [activeId, setActiveId] = useState<string | null>(null);
// A client manager gets no sensors, so @dnd-kit never starts a drag.
/**
* True from the moment a card is dropped until the server confirms the move.
*
* Separate from `activeId` on purpose. `activeId` is cleared the instant the
* pointer is released, so gating on it alone leaves a window where a focus
* event could refresh the page and overwrite the optimistic position with
* stale server state — the card would appear to snap back.
*/
const [saving, setSaving] = useState(false);
// A client manager never renders DndContext at all (see the early return
// below), so these sensors are only ever reached by the owner.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
);
@ -107,11 +118,13 @@ export function Board({
const activeCard = activeId ? cards.find((c) => c.id === activeId) : null;
useRefreshOnFocus(activeId !== null);
const busy = activeId !== null || saving;
useRefreshOnFocus(busy);
useEffect(() => {
if (activeId === null) setCards(initialCards);
}, [initialCards, activeId]);
if (!busy) setCards(initialCards);
}, [initialCards, busy]);
function onDragStart(event: DragStartEvent) {
setActiveId(String(event.active.id));
@ -160,19 +173,27 @@ export function Board({
c.id === card.id ? { ...c, column: destinationColumnId, position } : c
);
setCards(optimistic);
setSaving(true);
// Through the server route, not the browser SDK: the auth token lives in
// an httpOnly cookie that JavaScript cannot read, so the browser-side
// PocketBase instance has no credentials. See Task 10.
const response = await fetch(`/api/owner/cards/${card.id}/move`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ column: destinationColumnId, position }),
});
try {
// Through the server route, not the browser SDK: the auth token lives in
// an httpOnly cookie that JavaScript cannot read, so the browser-side
// PocketBase instance has no credentials. See Task 10.
const response = await fetch(`/api/owner/cards/${card.id}/move`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ column: destinationColumnId, position }),
});
if (!response.ok) {
console.error("[board] move failed", response.status);
if (!response.ok) {
console.error("[board] move failed", response.status);
setCards(previous);
}
} catch (error) {
console.error("[board] move failed", error);
setCards(previous);
} finally {
setSaving(false);
}
}