Skip to content

Add support for coordinates on dragstart, and related drag_to adjustments to work with react-dnd's HTML5 implementation#2837

Open
ragesoss wants to merge 5 commits into
teamcapybara:masterfrom
ragesoss:drag-to-coordinates
Open

Add support for coordinates on dragstart, and related drag_to adjustments to work with react-dnd's HTML5 implementation#2837
ragesoss wants to merge 5 commits into
teamcapybara:masterfrom
ragesoss:drag-to-coordinates

Conversation

@ragesoss
Copy link
Copy Markdown

@ragesoss ragesoss commented Apr 10, 2026

I've had some drag-and-drop capybara specs in my test suite for https://github.com/WikiEducationFoundation/WikiEduDashboard that I had to disable years ago when we upgraded react-dnd to a version that uses HTML5. At the time, I couldn't find a way to make those specs work. I took a pass with Claude Code today to see if I could find a way to get the specs working, and ended up with a drag helper that worked, in place of Capybara's drag_to.

I'm happy to keep that, but I figured since I had a good test case, I would try implementing an upstream fix to make Capybara's drag_to compatible with how my app uses react-dnd. Here's where I ended up. (Happy to squash these if you'd prefer, but I thought it better to start with the full history of the AI-written commits to be as transparent about the process for getting here as possible.)

I've tested this change locally, and I'm able to successfully run my long-disabled drag-and-drop spec, as long as delay: 0 is added to the drag_to calls. It tool several iterations going between making changes to Capybara, making sure the old and new Capyabara specs still worked, and testing the changes against the spec in my project.

I think this fixes #2662, which was part of the root problem (but not all of it) for why my spec didn't work with HTML5 react-dnd.

Note the potential breaking change in the second commit: b305e29

I don't know enough about Capybara or drag-and-drop to be confident about whether this is safe or reasonable, but it seems to make sense and I've watched both my own tests and the affected parts of the Capybara test suite run successfully with these changes.

@ragesoss ragesoss marked this pull request as draft April 10, 2026 20:05
@ragesoss
Copy link
Copy Markdown
Author

Hmm... i see this didn't pass in CI, so i'll try to get that sorted.

ragesoss and others added 5 commits April 11, 2026 14:04
…raggables

While fixing a WikiEduDashboard feature spec for react-dnd drag-and-drop
(teamcapybara#2662, react-dnd/react-dnd#1195), we found three
bugs in Capybara's HTML5 drag emulation that prevent it from working with
libraries like react-dnd that rely on DragEvent coordinates and modern
component wrapper patterns. A workaround helper (`html5_drag_to`) was
added downstream, but these fixes belong upstream so `drag_to` just works.

## Changes

- `lib/capybara/selenium/extensions/html5_drag.rb`:
  - (Fix A) Set `clientX`/`clientY` on `dragstart` from the source
    element's bounding rect center. Previously defaulted to 0,0.
  - (Fix B) Set `clientX`/`clientY` on `dragenter` using the entry point
    already computed for `dragover`. Fixes teamcapybara#2662.
  - (Fix C) Check descendants for `[draggable="true"]` before walking up
    ancestors, so selecting a wrapper element (e.g. `<li>` around a
    draggable `<div>`) works instead of crashing.
  - (Fix D) Same descendant check in `LEGACY_DRAG_CHECK` auto-detection,
    saving the original element reference to avoid sloppy-mode JS
    `arguments` aliasing (the `do/while` loop reassigns the parameter,
    which mutates `arguments[0]` in non-strict functions).

- `lib/capybara/spec/public/test.js`: Add `dragstart` coordinate logger,
  update `dragenter` logger to include coordinates, and extend all
  `dragstart`/`dragend` selectors to include `#drag_html5_wrapped`.

- `lib/capybara/spec/views/with_js.erb`: Add `#drag_html5_wrapper`
  containing `#drag_html5_wrapped` test fixture for descendant lookup.

- `lib/capybara/spec/session/node_spec.rb`: Three new specs —
  `dragstart` coordinates, `dragenter` coordinates, and wrapper-to-target
  drag — in the existing HTML5 context block.

## Process

Sage had previously worked with Claude to fix a pending drag-and-drop
spec in WikiEduDashboard by writing a custom `html5_drag_to` helper that
dispatches real DragEvents with coordinates and resolves draggable
descendants. That downstream fix revealed three upstream Capybara bugs.
In a new session, Claude applied the plan from the prior session (which
had been saved as a Claude Code plan file). All existing drag tests
passed on the first run. The new wrapper test failed because the
`LEGACY_DRAG_CHECK` IIFE used `arguments[0]` after a `do/while` loop
had reassigned the named parameter to null — in sloppy-mode JS,
`arguments` is a live alias to named parameters, so `arguments[0]` was
also null. Saving to `originalEl` before the loop fixed it. Second run:
24/24 drag tests green. Three user messages, all terse.

(Commit message written by Claude Code.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The dragLeave function unconditionally dispatched a dragleave event
before conditionally dispatching drop, producing the sequence
dragstart → dragenter → dragover → dragleave → drop → dragend. In real
browser HTML5 drag-and-drop, dragleave does not fire on the drop target
before drop — the correct sequence is dragstart → dragenter → dragover →
drop → dragend. dragleave only fires when dragging away without dropping.

This incorrect ordering breaks react-dnd, whose handleTopDragLeave
calls this.actions.hover([]) to clear all hover targets. By the time
the drop event fires, react-dnd has no target to drop onto, so the drop
is silently ignored. This was confirmed by instrumentation: dragover
events had defaultPrevented=true (react-dnd processing them), but the
subsequent drop had defaultPrevented=false (react-dnd had already
disengaged).

The fix makes dragleave and drop mutually exclusive: fire drop when the
target accepted the drag (dragover was defaultPrevented), fire dragleave
otherwise. The existing "preserve clientX/Y from last dragover event"
spec is updated to no longer assert on DragLeave during a successful
drop, matching the corrected behavior.

## Breaking change note

This changes the emulated event sequence to match real browser behavior.
Tests that assert on DragLeave appearing before Drop in a successful
drag-and-drop will need updating. This is unlikely to affect most users
since dragleave also fires when dragging away without dropping, making
it an unreliable signal for drop completion.

## Process

Sage tested the prior commit's coordinate and descendant fixes against
the WikiEduDashboard react-dnd timeline spec and found that drops were
still silently ignored. Instrumentation revealed the dragleave → drop
ordering as the cause. One Capybara spec failed after the fix (it
asserted on the old incorrect sequence); updated it. Second run: 24/24
drag tests green.

(Commit message written by Claude Code.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
In real browser drag-and-drop, drag events (dragenter, dragover, drop,
dragleave) fire on the element actually under the pointer, not the
top-level drop zone element. They then bubble up to ancestor listeners.
Capybara's emulation dispatched all target-side events directly on the
target argument, which meant events never reached listeners on
descendant elements — they bubbled up, not down.

This breaks react-dnd, which attaches dragover/drop listeners on the
specific component element (often a child of the Capybara-selected
wrapper). The dragover handler would call preventDefault() on the
component's own element, but since Capybara dispatched on the wrapper,
the component's listener never fired and the drop was rejected.

## Changes

- `lib/capybara/selenium/extensions/html5_drag.rb`: Add
  `dispatchOnElement(pt, event)` helper that uses
  `document.elementFromPoint(pt.x, pt.y)` to find the actual element
  under the pointer, falling back to `target` when the point is outside
  the target (e.g. at the exact edge of the bounding rect). Replace all
  five `target.dispatchEvent(...)` calls in `dragEnterTarget`,
  `dragOnTarget`, and `dragLeave` with `dispatchOnElement` calls.

## Process

Sage tested the prior two commits (coordinates + dragleave fix) against
the WikiEduDashboard react-dnd timeline spec from a parallel session.
The drag events had correct coordinates and the dragleave ordering was
fixed, but drops were still silently rejected. Instrumentation showed
dragover's defaultPrevented was false — react-dnd's listener on the
descendant element never received the event because it was dispatched
on the ancestor wrapper. The fix to use elementFromPoint was identified
in the other session. First attempt here dispatched on the raw
elementFromPoint result, which broke three existing specs: the entry
point coordinates land on the exact edge of the target rect, and
elementFromPoint at that pixel can return an element outside the target.
Adding the `target.contains(el)` guard fixed it. Second run: 24/24
drag tests green.

(Commit message written by Claude Code.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
react-dnd's handleTopDragStart defers source publication via
setTimeout(fn, 0) — `publishDragSource` triggers a React re-render
that transitions the source component to isDragging: true, which can
replace DOM nodes. Capybara's script scheduled dragEnterTarget with
setTimeout(fn, step_delay) directly after dragstart, so with the
default 50ms delay, publishDragSource fired during the gap and replaced
DOM nodes that the script still held references to. The dragend event
was dispatched on a detached node and never bubbled to the window.

This was confirmed by varying the delay parameter: delay: 0 passed
(all setTimeout(0) callbacks fire in FIFO order after publishDragSource),
while delay: 0.01 and the default 0.05 both failed.

## Changes

- `lib/capybara/selenium/extensions/html5_drag.rb`: After dispatching
  dragstart, yield once to the event loop with setTimeout(fn, 0) before
  scheduling the delayed dragEnterTarget. This ensures any library work
  deferred via setTimeout(0) after dragstart (like react-dnd's
  publishDragSource) completes before the drag sequence continues.

## Process

Sage tested the prior three commits against WikiEduDashboard's react-dnd
timeline spec from a parallel session. The drag initiated correctly but
the drop still failed intermittently. Instrumentation showed dragend
events disappearing from the trace — dispatched on a detached DOM node
after react-dnd's publishDragSource re-rendered the component tree
during the 50ms gap. The double-setTimeout pattern (yield once, then
schedule with delay) was identified as the fix. One run: 24/24 drag
tests green.

(Commit message written by Claude Code.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
react-dnd's scheduleHover uses requestAnimationFrame to batch hover
actions. Each dragover triggers RAF → React state update → re-render →
useEffect cleanup (removes drop target listeners) → useEffect re-attach.
With the default delay of 50ms between events, each gap allows RAF and
effects to run, so events arriving during listener reattachment miss
the per-target handlers entirely. With delay: 0, all events fire
synchronously in one burst before any RAF or effect runs, keeping old
listeners intact throughout the sequence.

Rather than changing the default (which could affect other use cases
that rely on the delay for testing intermediate drag states), this adds
documentation to the :delay option on drag_to noting that libraries
using requestAnimationFrame for hover scheduling may require delay: 0.

## Changes

- `lib/capybara/node/element.rb`: Expand the :delay YARD doc to note
  that react-dnd and other RAF-based libraries may require `delay: 0`
  to prevent listeners from being removed and reattached mid-sequence.

## Process

Sage tested the prior commits against WikiEduDashboard's react-dnd
timeline spec from a parallel session. With the default delay (0.05),
drops still failed — instrumentation showed RAF-scheduled hover actions
and React effects running between events, detaching and reattaching
listeners mid-sequence. With delay: 0, all five timeline specs passed.
With this full set of fixes and `delay: 0`, Capybara's built-in
`drag_to` works end-to-end with react-dnd — the downstream
`html5_drag_to` workaround helper is no longer needed.
Sage decided to document the workaround rather than change the default.

(Commit message written by Claude Code.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ragesoss ragesoss force-pushed the drag-to-coordinates branch from bf2fb16 to e7004af Compare April 11, 2026 21:05
@ragesoss
Copy link
Copy Markdown
Author

Okay! Here's a passing build that has this set of changes on top of the separate PR focused on making the build work: https://github.com/ragesoss/capybara/actions/runs/24291646486

And here's the build with just the changes from this PR, which fails but I think only for already-broken things: https://github.com/ragesoss/capybara/actions/runs/24291591529

@ragesoss ragesoss marked this pull request as ready for review April 11, 2026 21:41
@ragesoss
Copy link
Copy Markdown
Author

@simi as with the other commit, this one is all agentic code. i've tested it against my use case (and it doesn't break existing Capybara tests), but if it's not worth it to review, i completely understand; feel free to close.

@simi
Copy link
Copy Markdown
Contributor

simi commented Apr 19, 2026

@ragesoss I'll check, seems like a good feature.

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.

drag_to is broken in 3.39.0 due to dragenter event is dispatched without coordinates

2 participants