Skip to content

Stacked sibling droppables: drag sticks to the previous droppable one row behind the pointer (placeholder growth + equidistant tie-break) #1008

Description

@bboyrabi

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

  1. 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).
  2. Start dragging the card of the top droppable.
  3. Move the pointer straight down, resting on the center of each subsequent card.
  4. 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):

  1. While dragging over a foreign droppable, recomputePlaceholdersaddPlaceholder 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).
  2. For stacked sibling droppables this grown area overlaps the entire next sibling droppable below (slots are only one card tall).
  3. 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.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions