Skip to content

Fix drag transform stranded after same-row swap in React 19#3716

Merged
mattgperry merged 2 commits into
mainfrom
worktree-fix-issue-3315
May 12, 2026
Merged

Fix drag transform stranded after same-row swap in React 19#3716
mattgperry merged 2 commits into
mainfrom
worktree-fix-issue-3315

Conversation

@mattgperry
Copy link
Copy Markdown
Collaborator

Summary

  • Fixes [BUG] If element's layout position shifts horizontally post-drag, the drag offset remains #3315 — when motion.div drag dragSnapToOrigin layoutId tiles swap horizontally in React 19, the drag transform was being stranded over the new layout position.
  • Root cause: React 19's list-reorder reconciliation briefly unmounts/remounts the dragged component. VisualElement's motion-value unsubscribe callback was synchronously calling value.stop(), which killed the in-flight dragSnapToOrigin animation before remount could re-subscribe. The motion value froze mid-animation, leaving the transform stuck.
  • Fix: drop the synchronous value.stop() and rely on MotionValue.on("change")'s existing deferred auto-stop (runs on the next read frame), giving the remount a chance to re-subscribe so the animation runs to completion.

The bug only reproduces under React 19's reconciliation behaviour. The new Cypress spec is gated on cypress.react-19.json; it fails reliably against the un-fixed main and passes with the fix.

Test plan

  • yarn test — all 793 unit tests pass.
  • New Cypress regression spec drag-snap-layout-id-swap.ts passes on React 19 with the fix.
  • New spec fails against main without the fix (expected 145.6 to be close to 110 ± 2), confirming it's a real regression gate.
  • Existing drag suite (drag.ts, drag-to-reorder.ts, drag-layout-reorder-strict.ts, drag-momentum.ts) — 33 passing on React 19, 32 on React 18.
  • Spot-checked layout suite (layout-cancelled-finishes.ts, animate-layout-timing.ts, animate-presence-layout.ts) — all passing.

🤖 Generated with Claude Code

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 11, 2026

Greptile Summary

Fixes a drag-transform stranding bug (#3315) triggered by React 19's reorder reconciliation, which briefly unmounts and remounts the dragged component. The previous synchronous value.stop() in VisualElement's unsubscribe callback killed the in-flight dragSnapToOrigin animation before the remount could re-subscribe, leaving the motion value frozen at its mid-animation position.

  • Core fix (VisualElement.ts): removes the synchronous value.stop() on value unsubscription; the existing deferred auto-stop in MotionValue.on(\"change\") (frame.read → stop if no listeners remain) now handles cleanup, giving any remounting component a full frame to re-subscribe before the animation is cancelled.
  • New regression spec (drag-snap-layout-id-swap.ts + .tsx): an end-to-end Cypress test that drags a tile one column right in a 3×3 grid, confirms the state-level swap, then asserts the final getBoundingClientRect matches the swapped column — passing with the fix and failing against unpatched main.

Confidence Score: 4/5

Safe to merge — the change is a minimal, well-scoped removal of a synchronous stop call, backed by the existing deferred cleanup path that already handled the same scenario.

The one-line behavioral change in VisualElement.ts is well-justified: the deferred frame.read auto-stop in MotionValue already handles permanent-unmount cleanup correctly, so dropping the synchronous stop only widens the window for remounts to re-subscribe. The Cypress test confirms the regression is gated. The only concern is that the Cypress drag distance lands exactly on the 0.5 rounding boundary for triggering the swap, which could make that spec fragile under minor coordinate drift on CI.

The Cypress spec drag offset deserves a second look; the core VisualElement change is straightforward.

Important Files Changed

Filename Overview
packages/motion-dom/src/render/VisualElement.ts Removes synchronous value.stop() on value unsubscription; relies on MotionValue's existing deferred frame.read auto-stop so React 19 remounts can re-subscribe before the animation is cancelled.
packages/framer-motion/cypress/integration/drag-snap-layout-id-swap.ts New Cypress regression spec for issue #3315; drag offset is at the rounding boundary (30/60 = 0.5) which could make the swap trigger fragile under slight pointer-coordinate drift.
dev/react/src/tests/drag-snap-layout-id-swap.tsx New test harness: 3×3 absolutely-positioned draggable tile grid with dragSnapToOrigin + layoutId; correctly exposes the React 19 reorder/remount scenario.
CHANGELOG.md Changelog entry added for the drag transform fix.

Sequence Diagram

sequenceDiagram
    participant React19 as React 19 Reconciler
    participant VE as VisualElement (unmount)
    participant MV as MotionValue
    participant Frame as frame.read scheduler
    participant VE2 as VisualElement (remount)

    Note over React19: Same-row tile swap triggers reorder
    React19->>VE: unmount()
    VE->>MV: removeOnChange() [unsubscribe change listener]
    Note over MV: OLD: value.stop() called synchronously → animation killed
    MV->>Frame: schedule frame.read (deferred auto-stop check)
    React19->>VE2: mount() — remount
    VE2->>MV: on("change", ...) — re-subscribe
    Frame->>MV: "frame.read fires: getSize() > 0 → skip stop()"
    MV-->>VE2: dragSnapToOrigin animation continues ✓
Loading

Reviews (1): Last reviewed commit: "Fix stranded drag transform after layout..." | Re-trigger Greptile

Comment on lines +30 to +37
.trigger("pointermove", 10, 5, { force: true })
.wait(50)
.trigger("pointermove", 35, 5, { force: true })
.wait(50)
.trigger("pointerup", 35, 5, { force: true })
.wait(2000)
.get("#grid")
.should(([$grid]: any) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Drag offset sits exactly on the rounding boundary

The pointer travels from (5, 5) to (35, 5), giving a raw drag offset of ~30 px. Math.round(30 / 60) = Math.round(0.5) = 1 — just barely enough to trigger the swap. Framer Motion accumulates offset from the initial pointerdown position, and Cypress's synthetic pointer events can shift reported coordinates by a pixel or two depending on element position and scroll state. A drift to 29 px would give Math.round(0.483) = 0, skipping the swap entirely; the test would then vacuously fail on the data-tile-state assertion (not the drag-stranding assertion), masking the regression under an unrelated failure. Moving the final pointermove/pointerup to ~45, 5 would leave ~15 px of headroom before the rounding flips.

In React 19, list-reorder reconciliation briefly unmounts and remounts
draggable components when sibling positions change. The VisualElement's
per-motion-value unsubscribe callback was eagerly calling `value.stop()`,
which killed any in-flight motion animation (e.g. `dragSnapToOrigin`)
before remount could re-subscribe. The motion value froze at whatever
value it held mid-animation, leaving the drag transform stranded over
the new layout position.

Remove the synchronous `value.stop()` and rely on MotionValue's existing
deferred auto-stop in `on("change")` cleanup, which runs on the next
read frame — giving the remount a chance to re-subscribe so the animation
continues uninterrupted.

Fixes #3315

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@mattgperry mattgperry force-pushed the worktree-fix-issue-3315 branch from 2503a12 to 0435141 Compare May 11, 2026 11:34
@mattgperry mattgperry merged commit bd07642 into main May 12, 2026
1 check was pending
@mattgperry mattgperry deleted the worktree-fix-issue-3315 branch May 12, 2026 09:17
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.

[BUG] If element's layout position shifts horizontally post-drag, the drag offset remains

1 participant