Fix drag transform stranded after same-row swap in React 19#3716
Conversation
Greptile SummaryFixes a drag-transform stranding bug (#3315) triggered by React 19's reorder reconciliation, which briefly unmounts and remounts the dragged component. The previous synchronous
Confidence Score: 4/5Safe 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
Sequence DiagramsequenceDiagram
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 ✓
Reviews (1): Last reviewed commit: "Fix stranded drag transform after layout..." | Re-trigger Greptile |
| .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) => { |
There was a problem hiding this comment.
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>
2503a12 to
0435141
Compare
Summary
motion.div drag dragSnapToOrigin layoutIdtiles swap horizontally in React 19, the drag transform was being stranded over the new layout position.VisualElement's motion-value unsubscribe callback was synchronously callingvalue.stop(), which killed the in-flightdragSnapToOriginanimation before remount could re-subscribe. The motion value froze mid-animation, leaving the transform stuck.value.stop()and rely onMotionValue.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-fixedmainand passes with the fix.Test plan
yarn test— all 793 unit tests pass.drag-snap-layout-id-swap.tspasses on React 19 with the fix.mainwithout the fix (expected 145.6 to be close to 110 ± 2), confirming it's a real regression gate.drag.ts,drag-to-reorder.ts,drag-layout-reorder-strict.ts,drag-momentum.ts) — 33 passing on React 19, 32 on React 18.layout-cancelled-finishes.ts,animate-layout-timing.ts,animate-presence-layout.ts) — all passing.🤖 Generated with Claude Code