Skip to content

Fix race condition causing items to disappear after drag direction reversal#619

Open
chetstone wants to merge 1 commit intocomputerjazz:mainfrom
chetstone:fix/drag-reversal-race-condition
Open

Fix race condition causing items to disappear after drag direction reversal#619
chetstone wants to merge 1 commit intocomputerjazz:mainfrom
chetstone:fix/drag-reversal-race-condition

Conversation

@chetstone
Copy link
Copy Markdown

@chetstone chetstone commented Apr 9, 2026

Summary

Fixes a race condition where dragged items disappear and blank placeholder boxes appear when the user overshoots a drag target and reverses direction. The visual state is corrupted but the data is correct — any action that forces a re-render restores the list.

Relates to #350, #500.

This only reproduces on fast physical devices (iPad Pro M5, iPadOS 26.2), not on simulators or slower hardware, consistent with a timing-sensitive race.

Demo Images

The list before dragging:
IMG_1385

The list after dragging "Counter 1" in between 189 and 190, then reversing direction and dropping it between 193 and 194. Note the placeholder box stuck between 189 and 190, and the "Counter 1" item has disappeared.
IMG_1386

The list after tapping "Done", which exits draggable mode and rerenders the list with Counter 1 appearing between 193 and 194 as intended
IMG_1387

Root Cause

Three interrelated timing issues in the drag-end lifecycle:

1. Distributed spacerIndexAnim writes in useCellTranslate (primary)

Each cell's useDerivedValue worklet independently checks hover overlap and writes to spacerIndexAnim.value. During a direction reversal near the active cell's origin, both a before-active and an after-active cell can satisfy their overlap conditions in the same frame. The last worklet to execute wins nondeterministically, producing an incorrect spacer index that cascades into wrong placeholderOffset and wrong spring animation target.

2. Stale heldTranslate in CellRendererComponent (secondary)

heldTranslate preserves the last animated translation when activeKey goes null (to prevent flicker during reorder). It only resets to 0 in onCellLayout. If FlatList doesn't detect a geometry change for the affected cell, onCellLayout never fires, leaving the cell stuck at its wrong position.

3. dataHasChanged reset races with spring callback (tertiary)

When the consumer updates the data prop in onDragEnd, the dataHasChanged check fires InteractionManager.runAfterInteractions(() => reset()), which can clear spacerIndexAnim while the spring animation callback is still reading it.

Changes

  • useCellTranslate.tsx: Add a direction-aware guard to spacerIndexAnim writes. Only allow a cell to claim the spacer index if it's on the correct side relative to the hover direction (hovering below origin → only after-active cells can write; hovering above → only before-active). This eliminates the frame-order race during reversals.

  • CellRendererComponent.tsx: Reset heldTranslate via useEffect when activeKey transitions to null. By this point the spring animation has completed, so this is safe and ensures cells don't stay at stale positions when onCellLayout doesn't fire.

  • DraggableFlatList.tsx: Guard the dataHasChanged reset with !activeKey so reset() doesn't race with the spring animation callback during an active drag.

Testing

Tested on:

  • iPad Pro M5 (physical, iPadOS 26.2) — bug is fixed, including rapid multi-reversal drags and autoscroll-then-reverse scenarios
  • iPhone 11 Pro (physical) — no regression, reduced jank on drop
  • Android (physical) — no regression, improved overall smoothness

Note: A pre-existing issue on older Android devices where autoscroll occasionally stops during long drags is not affected by this change (reproduces on the unpatched version as well).

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant