Expected behavior
When a board is built from many small sibling <Droppable>s stacked vertically (e.g. a "slot board" where every slot is its own droppable containing at most one card), dragging a card downward over the slots should report the droppable that is visually under the pointer (snapshot.isDraggingOver / destination).
Actual behavior
While dragging downward through a column of stacked sibling droppables, the reported droppable is stuck one row behind the pointer. With slots A, B, C, D stacked top to bottom (each ~85px tall, each containing one card), dragging a card from A:
- hover
B → reported droppable is B (correct)
- move down to
C → reported droppable is still B
- move down to
D → reported droppable is C
The stale state is not a timing issue: it persists indefinitely while the pointer rests on the center of C (jiggling the pointer by a few px does not fix it). Moving sideways out of the column, or past the next row, "unsticks" it one row at a time. Dragging upward does not exhibit the problem.
This makes slot-board layouts (chess-like grids, versus-bracket slots, single-card cells) effectively unresponsive for downward drags.
Steps to reproduce
- Render one
<DragDropContext> with N vertically stacked sibling <Droppable>s (each its own 80px row, each containing exactly one <Draggable> card that fills the row).
- Start dragging the card of the top droppable.
- Move the pointer straight down, resting on the center of each subsequent card.
- Observe
snapshot.isDraggingOver (e.g. by coloring the hovered droppable): from the second transition on, the highlighted droppable is the one above the pointer.
Minimal repro component:
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
const SLOTS = ['s0', 's1', 's2', 's3', 's4'];
export default function App() {
return (
<DragDropContext onDragEnd={() => {}}>
{SLOTS.map((id, i) => (
<Droppable key={id} droppableId={id}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
style={{
height: 80,
marginBottom: 4,
border: '1px solid #888',
background: snapshot.isDraggingOver ? 'orange' : 'white',
}}
>
<Draggable draggableId={`card-${id}`} index={i}>
{(p) => (
<div
ref={p.innerRef}
{...p.draggableProps}
{...p.dragHandleProps}
style={{ height: 72, background: '#cde', ...p.draggableProps.style }}
>
{id}
</div>
)}
</Draggable>
{provided.placeholder}
</div>
)}
</Droppable>
))}
</DragDropContext>
);
}
Drag s0's card downward: when the pointer is on s2, s1 is highlighted; on s3, s2 is highlighted, etc.
Root cause analysis
I traced this through dist/dnd.esm.js (18.0.1):
- While dragging over a foreign droppable,
recomputePlaceholders → addPlaceholder grows that droppable's hit area (subject.active) by the placeholder size via subject.withPlaceholder (getRequiredGrowthForPlaceholder returns ~the dragged item's height because the slot has no spare space).
- For stacked sibling droppables this grown area overlaps the entire next sibling droppable below (slots are only one card tall).
- In
getDroppableOver, both the grown previous droppable and the droppable actually under the pointer become candidates. The tie-break getFurthestAway compares distances to patch(axis.line, pageBorderBox.center[axis.line], candidate.page.borderBox.center[axis.crossAxisLine]) — for same-column candidates the cross-axis centers are identical, so both distances are exactly equal. The stable sort then keeps registration order, i.e. the upper (grown) droppable always wins.
- Because the winning droppable keeps its placeholder, its area stays grown, so the state is self-sustaining — the drag stays "stuck" one row behind until the pointer leaves the grown area entirely.
mode="virtual" does not help (growth is unconditional there), and this cannot be worked around from userland CSS since the growth happens purely in the internal geometry model.
Suggested solution?
In getDroppableOver, before falling back to getFurthestAway, prefer a candidate whose un-grown subject contains the dragging item's center:
if (candidates.length > 1) {
const centerHits = candidates.filter(
(item) =>
!item.subject.withPlaceholder &&
isPositionInFrame(item.subject.active)(pageBorderBox.center),
);
if (centerHits.length === 1) {
return centerHits[0].descriptor.id;
}
}
This keeps existing behavior for all non-overlapping cases (single candidate paths are untouched) and only changes the resolution when a placeholder-grown area overlaps a sibling droppable that genuinely contains the pointer. The analysis above (grown subject.active + equal getFurthestAway distances for same-column candidates) was confirmed empirically in our app with instrumented drags; I have not run the patched build, so treat the snippet as a starting point.
Possibly related (different trigger, also overlap resolution): #743
What version of React are you using?
19.x
What version of @hello-pangea/dnd are you running?
18.0.1
What browser are you using?
Chrome 142 (also reproduced with Playwright Chromium)
Demo
The minimal component above reproduces it (drag the first card straight down and watch isDraggingOver). I can put it on CodeSandbox if that helps triage.
Expected behavior
When a board is built from many small sibling
<Droppable>s stacked vertically (e.g. a "slot board" where every slot is its own droppable containing at most one card), dragging a card downward over the slots should report the droppable that is visually under the pointer (snapshot.isDraggingOver/destination).Actual behavior
While dragging downward through a column of stacked sibling droppables, the reported droppable is stuck one row behind the pointer. With slots
A, B, C, Dstacked top to bottom (each ~85px tall, each containing one card), dragging a card fromA:B→ reported droppable isB(correct)C→ reported droppable is stillBD→ reported droppable isCThe stale state is not a timing issue: it persists indefinitely while the pointer rests on the center of
C(jiggling the pointer by a few px does not fix it). Moving sideways out of the column, or past the next row, "unsticks" it one row at a time. Dragging upward does not exhibit the problem.This makes slot-board layouts (chess-like grids, versus-bracket slots, single-card cells) effectively unresponsive for downward drags.
Steps to reproduce
<DragDropContext>with N vertically stacked sibling<Droppable>s (each its own 80px row, each containing exactly one<Draggable>card that fills the row).snapshot.isDraggingOver(e.g. by coloring the hovered droppable): from the second transition on, the highlighted droppable is the one above the pointer.Minimal repro component:
Drag
s0's card downward: when the pointer is ons2,s1is highlighted; ons3,s2is highlighted, etc.Root cause analysis
I traced this through
dist/dnd.esm.js(18.0.1):recomputePlaceholders→addPlaceholdergrows that droppable's hit area (subject.active) by the placeholder size viasubject.withPlaceholder(getRequiredGrowthForPlaceholderreturns ~the dragged item's height because the slot has no spare space).getDroppableOver, both the grown previous droppable and the droppable actually under the pointer become candidates. The tie-breakgetFurthestAwaycompares distances topatch(axis.line, pageBorderBox.center[axis.line], candidate.page.borderBox.center[axis.crossAxisLine])— for same-column candidates the cross-axis centers are identical, so both distances are exactly equal. The stable sort then keeps registration order, i.e. the upper (grown) droppable always wins.mode="virtual"does not help (growth is unconditional there), and this cannot be worked around from userland CSS since the growth happens purely in the internal geometry model.Suggested solution?
In
getDroppableOver, before falling back togetFurthestAway, prefer a candidate whose un-grown subject contains the dragging item's center:This keeps existing behavior for all non-overlapping cases (single candidate paths are untouched) and only changes the resolution when a placeholder-grown area overlaps a sibling droppable that genuinely contains the pointer. The analysis above (grown
subject.active+ equalgetFurthestAwaydistances for same-column candidates) was confirmed empirically in our app with instrumented drags; I have not run the patched build, so treat the snippet as a starting point.Possibly related (different trigger, also overlap resolution): #743
What version of
Reactare you using?19.x
What version of
@hello-pangea/dndare you running?18.0.1
What browser are you using?
Chrome 142 (also reproduced with Playwright Chromium)
Demo
The minimal component above reproduces it (drag the first card straight down and watch
isDraggingOver). I can put it on CodeSandbox if that helps triage.