diff --git a/docs/decisions/PROG-119.md b/docs/decisions/PROG-119.md new file mode 100644 index 0000000..ef8dadc --- /dev/null +++ b/docs/decisions/PROG-119.md @@ -0,0 +1,31 @@ +### PROG-119 — Synchronous store notification (kill the drag-drop snap-back flash) + +**Context.** Dropping a dragged outline row showed a one-to-two-frame flash: the +row snapped back to its original slot, then jumped to where it was dropped. +Cause: dnd-kit clears its drag transforms synchronously inside the drop event, +but the store's new order arrived a frame or two later — React Query v5's +`notifyManager` defers all subscriber notification through `setTimeout(0)` +(`systemSetTimeoutZero`), so the browser paints the old order with the +transforms already cleared before the optimistic rank write reaches the +components. The board never showed this because PROG-59 keeps a local +`columns` state mirror updated synchronously in `onDragEnd`; the Outline +renders straight from the snapshot cache, so it ate the notification delay on +all three of its drag surfaces (action rows, arc sections, focus sections). + +**Decision.** Set `notifyManager.setScheduler((cb) => cb())` once in +`store.ts`, making cache-write notifications synchronous (React Query v4's +behavior). React 18 auto-batching still coalesces the setStates from a +multi-write drop (a tied-group container renumber issues N `updateContainer` +calls) into one commit, and every cache write in the codebase lives in +store.ts's event/async mutation helpers — nothing writes during render, which +is the hazard a sync scheduler would otherwise expose. + +**Alternative rejected:** board-style local order mirrors in the Outline. +Three surfaces × (state + reconcile effect) of code, and any future +cache-rendered drag surface would need the same dance; the scheduler fix makes +every optimistic write paint in the same frame as its event, which is what the +instant-UI requirement (SPEC §2.1) wants globally. + +**Evidence.** Headless Playwright driver sampling rAF frames after mouseup: +before — new order first visible at frame 2 (the flash); after — frame 0, no +old-order reappearance, no >2px positional jump, order persists across reload. diff --git a/src/client/store.ts b/src/client/store.ts index 08cc627..c29b232 100644 --- a/src/client/store.ts +++ b/src/client/store.ts @@ -4,7 +4,13 @@ // unchanged slices reference-stable so re-renders stay scoped. Per-action // timelines (comments + activity) are separate queries (D20). -import { keepPreviousData, QueryClient, useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { + keepPreviousData, + notifyManager, + QueryClient, + useInfiniteQuery, + useQuery, +} from "@tanstack/react-query"; import { useEffect, useMemo, useState } from "react"; import { DEFAULT_ACTION_STATUS, @@ -64,6 +70,19 @@ async function sendWithRetry( return null; } +// Notify cache subscribers SYNCHRONOUSLY (PROG-119). React Query v5 defers +// subscriber notification through setTimeout(0), which lets the browser paint +// between an optimistic write and the re-render it should cause. That gap is +// invisible on most mutations but breaks drag-drop reorder: dnd-kit clears its +// drag transforms in the drop event itself, while the store's new order lands +// a frame later — so the row visibly snaps back to its old slot, then jumps +// (the PROG-119 flash). A pass-through scheduler restores v4-style immediate +// notification; React 18 auto-batching still coalesces the setStates from a +// multi-write drop (e.g. a tied-group renumber) into one commit, and no code +// path writes the cache during render (all writes live in this file's +// event/async mutation helpers). +notifyManager.setScheduler((cb) => cb()); + export const queryClient = new QueryClient({ defaultOptions: { // Nothing goes stale on its own: this client is the only writer