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:
parent
e419bc21db
commit
f082670f8a
1 changed files with 35 additions and 14 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue