#1836·puck

Dropping a Drawer item into a nested slot inserts into the parent zone while the pointer moves (onDragOver cancels the area-change debounce)

Author: ShokksoCreated Sep 11, 2026Updated Sep 17, 2026
Labelstype: bug 🐛in triage

Description

A component with a slot field (e.g. col2: { type: "slot", allow: [...] }) rendered inside the preview iframe. Dragging an item from the Drawer into the empty slot and releasing while the pointer is still moving (or shortly after it stopped) inserts the item into the parent zone (root:default-zone) instead of the slot – even though the pointer is inside the slot's rectangle and Puck's own hit test has already identified the slot as the deepest zone.

If the pointer stays motionless for ~200 ms before release, the drop lands in the slot every time.

Environment

  • Puck version: 0.23.0 (@puckeditor/core; @dnd-kit/* 0.4.0 as pinned by Puck). The relevant code is unchanged in the current 0.24.0-canary source (AREA_CHANGE_DEBOUNCE_MS unchanged since 0.21).
  • Browser version: Chromium 140 (desktop, Playwright); also reproduced with a real mouse in Chrome.
  • Additional environment info: Next.js 16 app, iframe: { enabled: true }, custom layout with <Puck.Preview /> (no Canvas zoom involved), macOS/Linux.

Steps to reproduce

  1. Config with one component Free that has a slot field col2 (allow set to some block components) and one block component Quote; iframe: { enabled: true }.
  2. Page data: [ { type: "Free", props: { id: "free", col2: [] } } ].
  3. Drag Quote from the Drawer over the empty col2 drop zone and release immediately (Playwright: mouse.move(…, { steps: 16 }) followed by mouse.up()), or release while jittering the pointer by ±1 px.
  4. Observe appState.data.content: the Quote sits at the root (content[1]), not in content[0].props.col2. Puck also selects it (ui.itemSelector = { index: 1, zone: "root:default-zone" }).

Measured (Chromium, 1280×720 / 1920×1080, three runs each): release right after the last move → 0/3 correct; after a 200 ms motionless pause → 3/3 correct in both geometries; after 400 ms → 3/3. The line placeholder ([data-puck-line-placeholder]) inside the slot correlates 1:1 with the outcome (27/27 runs).

Expected behavior

The item is inserted into the slot the pointer is over at pointerup – the same zone Puck's hit test reports as the deepest candidate.

Actual behavior

The item is inserted into the parent zone (root:default-zone) and selected there. If the pointer stays motionless for ~200 ms before release, the drop lands in the slot every time.

Cause (from the 0.23.0 source)

packages/core/lib/dnd/NestedDroppablePlugin.ts:

  • pointermove is throttled to 50 ms (throttle(handleMove, 50)).
  • When the pointer enters a new area, the switch to the deeper zone is debounced: useDebouncedCallback(setDeepestAndCollide, AREA_CHANGE_DEBOUNCE_MS) with AREA_CHANGE_DEBOUNCE_MS = 100, and setDeepestAndCollide calls collisionObserver.forceUpdate(true) only after a further setTimeout(…, 50).
  • packages/core/components/DragDropContext/index.tsx onDragOver calls cancelDb() on every move. Any pointer movement inside the target area therefore restarts the 100 ms debounce; while it never elapses, the slot's DropZone stays disabled (isEnabled = zoneDepthIndex[zoneCompound] ?? false), so the only enabled droppable under the pointer is the parent component. onDragEnd reads event.operation.target without re-resolving, and for a component target targetZone = targetData.zone – the parent's zone.

Instrumented collision list at the moment of release (1920×1080): pointer inside free:col2, deepest zone per findDeepestCandidate = free:col2, but free:col2 droppable disabled, no rectangle; only collision = component free (dynamic detector, value 0.0014) → target zone root:default-zone. With a 200 ms pause: free:col2 enabled, pointerIntersection hit, target free:col2.

Impact

Nested slots in an iframe are unreliable to drop into for anyone who releases quickly or has a slightly unsteady hand; the item silently ends up in the parent zone. There is no DropZone/dnd option that affects this timing (collisionAxis, minEmptyHeight, dnd.behavior do not). Related: #1710 asks for a configurable dwell delay and names AREA_CHANGE_DEBOUNCE_MS; #1779 (closed as not reproducible) may be the same mechanism seen from a different angle.

Suggested fix

Either resolve the deepest zone synchronously in onDragEnd (run setDeepestAndCollide for the last known pointer position before reading the target), or stop cancelling the pending area-change debounce on moves that stay within the same area (only cancel when params change). A configurable areaChangeDebounceMs (per #1710) would help but does not remove the race for values > 0.

We are currently shipping the second variant as a local pnpm patch (removing the unconditional cancelDb() in onDragOver). Measured: 6/6 correct drops into the empty slot (previously 3/6 in the same geometries), and no regression for filled slots (insert index correct), root drops (still the root, as expected), root reordering by drag, or leaving the area mid-drag (a pending debounce does not fire late – Puck's own area-change handling re-arms it with the new params). Patch against the published dist bundle:

diff
diff --git a/dist/chunk-55V3NZVF.mjs b/dist/chunk-55V3NZVF.mjs
index 01fc32e03adc11d014291a99cc8dbee1806880f8..48a9133adbf0e03c2ed5044f920b60d48cce7221 100644
--- a/dist/chunk-55V3NZVF.mjs
+++ b/dist/chunk-55V3NZVF.mjs
@@ -4489,7 +4489,7 @@ var DragDropContextClient = ({
             event.preventDefault();
             const draggedItem = (_a = zoneStore.getState()) == null ? void 0 : _a.draggedItem;
             if (!draggedItem) return;
-            cancelDb();
+            /* local patch: do not cancel the pending area-change debounce on every pointer move (see this issue) */
             const { source, target } = event.operation;
             if (!target || !source || target.type === "void") return;
             const [sourceId] = source.id.split(":");

Happy to open a PR against packages/core/components/DragDropContext/index.tsx if this direction is acceptable.