plans/issues/issue-2024.md
Executor instructions: Follow this plan step by step. Build the failing Cypress test FIRST. The reporter's sandbox is Cloudflare-blocked; the repro below is reconstructed from the issue text (which is specific:
Reorder.Item+dragConstraints+ scrollableReorder.Group). Honor STOP conditions. Update this issue's row inplans/issues/README.md.Drift check (run first):
gh api repos/motiondivision/motion/issues/2024 --jq .state→open.git diff --stat 42bfbe3ed..HEAD -- packages/framer-motion/src/gestures/drag/On changes toVisualElementDragControls.ts, re-verify excerpts. If the drag engine moved to motion-dom (plans 019/020 landed), STOP and re-localize.
resolveRefConstraints; whichever lands second re-runs the other's
spec)42bfbe3ed, 2026-06-11With ref dragConstraints on a Reorder.Item inside a scrollable
Reorder.Group: scroll the group, then drag an item — the item jumps by
roughly the scrolled distance (reporter's GIF; "the item should not move when
dragged"). Root cause is measurement staleness: both the item's cached
projection layout and the resolved constraints are computed at mount (scroll
= 0) and are never refreshed when a nested container scrolls. Commit
cfccb0300 (2026-05-12) fixed the analogous bug for ROOT scroll (#2829), but
(a) it only refreshes root scroll and (b) it only runs when constraints are
actually re-resolved — and they are cached. This bites every scrollable-list
packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts:
resolveConstraints() (lines 340–364), called from onStart (line 136) on
every drag start — but ref constraints are CACHED:
if (dragConstraints && isRefObject(dragConstraints)) {
if (!this.constraints) {
this.constraints = this.resolveRefConstraints()
}
}
The layout side is also cached: lines 343–347 only measure when
!projection.layout. The layout is otherwise from mount
(addListeners() lines 723–728) or from the last projection "measure"
event — nested-container scrolling fires neither.
resolveRefConstraints() (lines 395–456) measures the constraints element
LIVE via measurePageBox (correct: getBoundingClientRect + refreshed
root scroll — packages/motion-dom/src/projection/utils/measure.ts:17-32
plus the root-scroll refresh at lines 423–426), then compares against the
STALE projection.layout.layoutBox in calcViewportConstraints
(utils/constraints.ts:103-120: translate range = constraintsBox −
layoutBox). Scrolling a nested container shifts the item's true page box by
−scrollTop while the cached layoutBox keeps the old value → the resolved
min/max are offset by scrollTop → instant jump on drag start.
Why object constraints (e.g. { top: 0, bottom: 0 }) are NOT affected:
they get rebased to component-relative space via rebaseAxisConstraints
(lines 374–392), which subtracts the same stale layout.min, cancelling
the staleness. The repro must use a REF.
Reorder.Group does not set layoutScroll
(verified: grep -rn layoutScroll packages/framer-motion/src/components/Reorder/ → no hits), so projection's removeElementScroll
(create-projection-node.ts:1045-1076) doesn't change this picture.
Related but distinct: the issue's last comment ("weird offset when drag
scrolling even without dragConstraints") is the autoscroll-during-drag
family fixed by 5d53f132f (#1691) — mention in the closing comment, do
not chase here.
| Purpose | Command | Expected |
|---|---|---|
| Build | yarn build (repo root) | exit 0 |
| Cypress React 18/19 | CLAUDE.md recipe, --spec cypress/integration/drag-ref-constraints-nested-scroll.ts | pass after fix |
| Jest drag | npx jest --config packages/framer-motion/jest.config.json --testPathPattern="gestures/drag" | all pass |
In scope:
packages/framer-motion/src/gestures/drag/VisualElementDragControls.tsdev/react/src/tests/drag-ref-constraints-nested-scroll.tsx (create)packages/framer-motion/cypress/integration/drag-ref-constraints-nested-scroll.ts (create)Out of scope:
layoutScroll to Reorder.Group (changes layout-animation
semantics for all Reorder users; separate decision).PanSession scroll tracking, autoscroll behavior.fix/issue-2024-ref-constraints-nested-scrollgh pr create; body edits via gh api -X PATCH .../pulls/<n>.Keep the page generic (plain motion.div proves the root cause; a Reorder case is added as a second assertion-target on the same page):
dev/react/src/tests/drag-ref-constraints-nested-scroll.tsx — export App:
ref={scrollerRef}, 300×300, overflow-y: scroll,
at a known position.motion.div (data-testid="draggable",
100×100, drag, dragConstraints={scrollerRef}, dragElastic={0},
dragMomentum={false}) positioned 400px from the content top.useLayoutEffect (or button #scroll) sets
scrollerRef.current.scrollTop = 350 so the draggable is visible near the
container top after scrolling.Spec packages/framer-motion/cypress/integration/drag-ref-constraints-nested-scroll.ts
(model on drag-ref-constraints-absolute-scrolled.ts):
.wait(300); assert the scroller's scrollTop === 350.getBoundingClientRect() via .then().pointerdown center → pointermove +10,+10 → .wait(50) →
pointermove +20,+20 → pointerup (force: true)..wait(200), assert via .then(): the box moved by ≈ (+20, +20) from
step 2's rect (±10) — i.e. it tracked the pointer instead of jumping.
Before the fix the y-delta is contaminated by ~350 (clamped by the stale
constraint); record the observed value.Verify: spec FAILS at 42bfbe3ed on assertion 4 and/or 5 (record which
and by how much — expect ≈ scrollTop). If it does not fail after 2–3 page
variants (try scrolling via the button after mount instead of
useLayoutEffect — scroll BEFORE first measure may be masked by cfccb0300's
root-refresh path; the bug needs scroll AFTER the initial constraint
resolution), STOP and report per the no-repro rule.
In resolveConstraints() (lines 340–364):
Drop the ref-constraints cache so every drag start re-resolves:
if (dragConstraints && isRefObject(dragConstraints)) {
this.constraints = this.resolveRefConstraints()
}
Feed it a FRESH element measurement. In resolveRefConstraints(), after
the existing root-scroll refresh (lines 423–426), re-measure this
element's layout before using it:
projection.updateLayout()
and keep using projection.layout.layoutBox afterwards.
CAUTION — transform inclusion: updateLayout() uses measure(false)
(create-projection-node.ts:926), so the measured box includes the
current x/y transform, while calcViewportConstraints needs the
transform-free box (at mount the transform is 0, which is why the old
cache worked). Compensate by subtracting the current axis values, mirroring
the shape proposed in plans/issues/issue-2342.md Step 3: copy the
layoutBox and layoutBox[axis].min/max -= value for each axis where
getAxisMotionValue(axis).get() is a nonzero number. For a rested
Reorder.Item (dragSnapToOrigin) the values are 0 and this is a no-op.
Beware recursion: updateLayout() fires the projection "measure" event
(create-projection-node.ts:931), whose listener measureDragConstraints
(lines 701–714) calls resolveRefConstraints() again. Guard with a simple
reentrancy flag (private isMeasuringConstraints = false) around the
updateLayout call, or move the updateLayout() into resolveConstraints
before the resolveRefConstraints() call and accept one duplicate
resolution (measure listener runs synchronously — verify with a log,
then delete the log). Pick whichever keeps the diff smallest; assert no
infinite loop by running the spec.
Verify: yarn build; Step 1 spec passes on React 18.
Verify:
drag.ts, drag-ref-constraints-absolute-scrolled.ts
(the #2829 regression test — MUST stay green; it shares this code path),
drag-ref-constraints-element-resize.ts,
drag-ref-constraints-resize-handle.ts, drag-to-reorder.ts,
drag-layout-reorder-strict.ts → all pass (re-run once on flake;
twice-failing = real, STOP).npx jest --config packages/framer-motion/jest.config.json --testPathPattern="gestures/drag" → all pass.plans/issues/issue-2342.md was already executed: run its spec
(drag-ref-constraints-lazy-layoutid.ts) too.Reorder.Group/Reorder.Item
(axis="y", group as constraints ref, scroll group, drag item, assert no
jump) — matches the reporter's exact setup; model the page on
dev/react/src/tests/drag-to-reorder.tsx.drag-ref-constraints-absolute-scrolled.ts (#2829 gate) still passesplans/issues/README.md row updatedAPPROVED).drag-ref-constraints-absolute-scrolled.ts breaks and can't be restored
while keeping the new spec green — the two scroll spaces (root vs nested)
are then entangled; report with both failure modes.getBoundingClientRect + one element measure per
drag start — negligible, and it makes onMeasureDragConstraints fire per
drag start (check the docs wording; it arguably always should have).5d53f132f (#1691).drag() (plan 020) wants.