Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/decisions/PROG-119.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 20 additions & 1 deletion src/client/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down