Skip to content

fix(abstract): prevent drop target oscillation caused by layout shifts#2101

Open
clauderic wants to merge 1 commit into
mainfrom
fix/collision-notifier-oscillation
Open

fix(abstract): prevent drop target oscillation caused by layout shifts#2101
clauderic wants to merge 1 commit into
mainfrom
fix/collision-notifier-oscillation

Conversation

@clauderic

Copy link
Copy Markdown
Owner

Problem

The winner of collision detection is a function of the current layout, but setting a drop target mutates the layout: setDropTargetdragoverOptimisticSortingPlugin re-orders the DOM → droppable shapes shift. When the pointer sits in a region where winner(layout A) = B but winner(layout B) = A, the drop target enters a limit cycle.

The existing #previousCoordinates guard in CollisionObserver only protects a perfectly still pointer — every pixel of hand jitter recomputes collisions against the shifted layout and flips the target again. In a multi-column board this manifests as an item flickering between two columns non-stop:

  [ 1 ]       [ 4 ]
  [ 2 ]

       [ 3 ]

Fix

CollisionNotifier now keeps a small history of recent drop targets ({id, coordinates at switch}) and refuses to re-target any of them until the pointer has traveled at least CollisionNotifier.hysteresis px (default 10, now exported from @dnd-kit/abstract and tunable) from where the switch happened. Entries prune themselves once the pointer travels past the threshold, so deliberate movement back is honored after ~10px, while jitter-scale oscillation locks after at most one visible flip.

Design notes:

  • Both the abandoned target and the new destination are recorded. The destination half is load-bearing: OptimisticSortingPlugin re-targets to the source after every cross-container reorder, so the oscillation manifests as the winning collision alternating between two other droppables while the target snaps back to the source each time. Matching only against the abandoned target would never fire.
  • Clearing the drop target is never suppressed and null is never recorded — otherwise the target could stick to a droppable while the pointer visibly hovers empty space.
  • While a switch is suppressed, the isEqual early-return is bypassed so pointer travel keeps being re-evaluated on every collision update; otherwise the collision id-order can stay identical indefinitely and the pending switch would never be honored.
  • History resets on drag init; a consistent state (winner === target) clears the pending suppression; keyboard flows are unaffected (SortableKeyboardPlugin bypasses the notifier).

Verification

Deterministic repro driving 20 half-pixel jitter movements through a scripted dragover-reacts-to-target feedback loop:

drop target changes
before (threshold 0) 20col-2 → col-1 → col-2 → … forever
after (threshold 10) 2 — locks after the first flip
  • New test suite collision-notifier-hysteresis.test.ts (5 tests): plain A↔B cycle, target clearing, optimistic re-target-to-source cycle, per-drag state reset, threshold configurability.
  • All suites pass: abstract 31, dom 40, helpers 58, state 7. All packages build.

Behavior notes

  • Slowly dragging through a wide contested band now flips at most once per 10px of travel instead of every pixel. A possible future refinement is an escalating per-pair threshold once a pair has flipped repeatedly.
  • Deliberate reversals cost up to 10px of pointer travel before re-targeting — imperceptible in practice, and configurable via CollisionNotifier.hysteresis.

🤖 Generated with Claude Code

Setting a drop target can shift the layout (e.g. optimistic re-ordering
across columns), which can flip the outcome of collision detection at the
same pointer position and immediately re-target the previous droppable,
oscillating between the two on every micro pointer movement.

CollisionNotifier now tracks recently set or cleared drop targets along
with the coordinates at which the change happened, and ignores collisions
with them until the pointer has traveled a minimum distance
(CollisionNotifier.hysteresis, 10px by default). Recording both the
abandoned target and the new destination also catches cycles where
OptimisticSortingPlugin re-targets the source between switches. Clearing
the drop target is never suppressed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9be9b23

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@dnd-kit/abstract Patch
@dnd-kit/collision Patch
@dnd-kit/dom Patch
@dnd-kit/geometry Patch
@dnd-kit/helpers Patch
@dnd-kit/react Patch
@dnd-kit/state Patch
@dnd-kit/vue Patch
@dnd-kit/solid Patch
@dnd-kit/svelte Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

@dnd-kit/abstract

npm i https://pkg.pr.new/@dnd-kit/abstract@2101

@dnd-kit/collision

npm i https://pkg.pr.new/@dnd-kit/collision@2101

@dnd-kit/dom

npm i https://pkg.pr.new/@dnd-kit/dom@2101

@dnd-kit/geometry

npm i https://pkg.pr.new/@dnd-kit/geometry@2101

@dnd-kit/helpers

npm i https://pkg.pr.new/@dnd-kit/helpers@2101

@dnd-kit/react

npm i https://pkg.pr.new/@dnd-kit/react@2101

@dnd-kit/solid

npm i https://pkg.pr.new/@dnd-kit/solid@2101

@dnd-kit/state

npm i https://pkg.pr.new/@dnd-kit/state@2101

@dnd-kit/svelte

npm i https://pkg.pr.new/@dnd-kit/svelte@2101

@dnd-kit/vue

npm i https://pkg.pr.new/@dnd-kit/vue@2101

commit: 9be9b23

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