Pin correctParentTransform workaround for Reorder inside a scaled parent (#2750)#3759
Pin correctParentTransform workaround for Reorder inside a scaled parent (#2750)#3759mattgperry wants to merge 1 commit into
Conversation
Reorder inside an ancestor with a raw CSS `transform: scale()` (and a non-centre `transform-origin`) misbehaves: the dragged item translates faster/slower than the cursor and reorder thresholds fire at the wrong positions, so the list appears to "move around". Root cause is structural — the projection / drag measurement system only sees tracked motion values, so raw CSS transforms on ancestors are invisible (same blind spot as #3356). Layout boxes are measured in screen (scaled) space while the drag offset lives in local (unscaled) space. The supported fix already ships on main: `correctParentTransform(ref)` fed to `MotionConfig transformPagePoint`, which routes BOTH pan-session pointer points and projection viewport measurements through the inverse of the parent's computed matrix, putting gesture offsets and measured boxes back in the same space. This is a test-only change that pins that workaround: - dev/react/src/tests/reorder-scaled-parent.tsx — fixture with a broken baseline and a `?corrected=true` mode (`?scale=` / `?corrected=` params). - reorder-scaled-parent.ts — Cypress spec proving the corrected mode tracks the cursor and reorders + settles correctly. Passes on React 18 and 19. No projection/drag/Reorder source is changed. PR #3502's `treeScale` offset scaling was rejected because treeScale cannot see raw CSS scale; native engine awareness of computed ancestor transforms is gated behind the in-flight projection rewrites. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile SummaryThis is a test-only PR that pins the
Confidence Score: 4/5Safe to merge — no production code is touched and the spec correctly pins the workaround; two minor robustness gaps in the test itself are worth addressing before future CI churn. The change is test-only and the fixture/spec logic is sound. Two issues in the Cypress spec reduce robustness: the post-release settle assertion silently passes if the computed transform is matrix3d() (framer-motion's projection can emit this form), and the trigger coordinate calculation uses viewport-absolute values as element-relative offsets — a pattern that diverges from every other test in the repo and can cause the incremental drag loop's effective pointer delta to drift as the item moves between events. packages/framer-motion/cypress/integration/reorder-scaled-parent.ts — the settle check and the trigger coordinate approach both need a second look. Important Files Changed
Sequence DiagramsequenceDiagram
participant Cypress
participant ReorderItem as Reorder.Item (item-0)
participant MotionConfig
participant correctParentTransform as correctParentTransform(ref)
participant PanSession
Cypress->>ReorderItem: pointerdown (startMidX, startMidY)
ReorderItem->>PanSession: start pan session
Cypress->>ReorderItem: pointermove (+5px threshold)
Cypress->>ReorderItem: pointermove (+80px)
PanSession->>correctParentTransform: transformPagePoint(pointerCoords)
correctParentTransform-->>PanSession: inverse-matrix-corrected coords
PanSession->>ReorderItem: apply corrected drag offset
ReorderItem-->>Cypress: getBoundingClientRect() - screenDelta ~80px
Cypress->>ReorderItem: pointerup
Note over ReorderItem: settles: translateY to 0
Reviews (1): Last reviewed commit: "Pin correctParentTransform workaround fo..." | Re-trigger Greptile |
| ) | ||
| }) | ||
|
|
||
| // ... and the released item should settle flush in the list, i.e. no | ||
| // stranded drag transform (translateY ≈ 0 in local space). | ||
| cy.get("[data-testid='item-0']").then(($el: any) => { | ||
| const transform = getComputedStyle($el[0]).transform | ||
| if (transform && transform !== "none") { | ||
| const parts = transform.match(/matrix\(([^)]+)\)/) | ||
| if (parts) { | ||
| const translateY = parseFloat(parts[1].split(",")[5]) | ||
| expect(Math.abs(translateY)).to.be.lessThan(10) |
There was a problem hiding this comment.
Silent skip on matrix3d settle assertion
transform.match(/matrix\(([^)]+)\)/) only matches the 6-value 2D form. If the projection system produces a matrix3d(…) value (which it can when projection combines the layout correction with the drag offset), parts is null and the entire translate assertion is silently skipped — a non-settled item with a stranded matrix3d translateY would pass the test without any error. The fix is to also handle matrix3d (translateY is index 13 in the 16-element list), or simplify by parsing the translateY(…) shorthand that Framer Motion also emits, or using a permissive regex like /matrix3?d?\(([^)]+)\)/ and branching on value count.
| const startMidY = start.top + start.height / 2 | ||
|
|
||
| cy.wrap($el) | ||
| .trigger("pointerdown", startMidX, startMidY, { | ||
| force: true, | ||
| }) | ||
| .wait(50) | ||
| // Move past the drag threshold. | ||
| .trigger("pointermove", startMidX, startMidY + 5, { | ||
| force: true, | ||
| }) | ||
| .wait(50) | ||
| // Move the pointer 80px down the screen. | ||
| .trigger("pointermove", startMidX, startMidY + 80, { | ||
| force: true, | ||
| }) | ||
| .wait(80) | ||
| .then(([item]: any) => { | ||
| const moved = item.getBoundingClientRect() | ||
| const movedMidY = moved.top + moved.height / 2 | ||
| const screenDelta = movedMidY - startMidY | ||
|
|
||
| // The element must follow the cursor (~80px on screen), | ||
| // not 80 / scale (160px) as it would without the | ||
| // transformPagePoint correction. | ||
| expect(screenDelta).to.be.greaterThan(60) |
There was a problem hiding this comment.
Viewport-absolute coordinates used as element-relative trigger offsets
startMidX and startMidY are obtained from getBoundingClientRect() (viewport-absolute values), then passed directly as x, y to .trigger(). Cypress adds the element's current getBoundingClientRect().left/top to those values when computing clientX/clientY, so the resulting pointer events fire at 2 * element.left + width/2 rather than at the element's centre. The displacement in client space still works out to the intended 80 px (the same absolute offset is added to both pointerdown and pointermove), and the final screenDelta comparison uses the same absolute baseline, so the first test passes.
The concern is the second test's incremental loop: Cypress re-evaluates the element's bounding rect at each .trigger(), so if the item has moved between ticks, the effective client-space delta grows beyond the intended i * 14 px. The existing tests in this repo (e.g., drag-scaled-parent.ts) use hardcoded element-relative coordinates (10, 10, 110, 110) to avoid this. Using relative coordinates like $el[0].getBoundingClientRect().width / 2 at capture time and passing small fixed deltas would be more robust.
The bug
#2750 reports
Reorderitems "moving around non stop" when the parent is scaled and itstransform-originis changed. Concretely, with aReorder.Groupinside an element that has a raw CSStransform: scale():I reproduced the broken baseline in a browser: with
transform: scale(1.5); transform-origin: top leftthe dragged item moves ~1.5× the pointer distance instead of tracking it.Cause
This is a structural limitation, not a localised bug. The projection / drag measurement system only sees tracked motion values — raw CSS transforms on ancestors are invisible to
hasTransform/treeScale(the same blind spot as #3356). The result is a coordinate-space mismatch:Reorder.Itemmeasures its layout box viagetBoundingClientRect→ screen (scaled) space.point[axis]) lives in the element's local (unscaled) space.checkReorderthen comparesitem.layout.max + offsetagainst a neighbour's centre across two different spaces, so thresholds are wrong; and the drag offset itself is added to a local-space motion value, so the item tracks the cursor at1/scale.PR #3502 previously tried to patch this inside
Reorderby scaling the offset bytreeScale, and was closed —treeScalecan't see a raw CSS scale either. Making the projection engine aware of computed ancestor transforms is a deeper change that collides with the in-flight projection rewrites (#3748/#3749), so it is deliberately not attempted here.The supported fix (and what this PR pins)
Since #3132,
framer-motionships a public helper,correctParentTransform(ref), that feedsMotionConfig transformPagePointthe inverse of the parent's computed matrix. BecausetransformPagePointis applied to both the pan-session pointer points and the projection viewport measurements, it puts gesture offsets and measured boxes back in the same space — which makesReorderwork again inside a scaled parent:This PR is test-only — it does not change any projection / drag / Reorder source:
dev/react/src/tests/reorder-scaled-parent.tsx— fixture with the broken baseline and a?corrected=truemode (?scale=/?corrected=query params,transform-origin: top left).packages/framer-motion/cypress/integration/reorder-scaled-parent.ts— spec proving the corrected mode (1) tracks the cursor 1:1 underscale(0.5)and (2) reorders and settles flush. Pins the workaround so future drag/projection refactors can't silently regress it.Verification
drag-scaled-parent.ts(2/2) anddrag-to-reorder.ts(3/3) still pass on React 18; Reorder unit tests pass (3/3).yarn build(turbo) is incompatible with the installed turbo in a worktree (pipelinevstasks); packages were built individually via theirtsc + rollupscripts.Scope / known limitation
The static scaled-parent case is fixed by the workaround and is now regression-tested. The harshest variant in #2750 — a parent whose CSS
scale/transform-originare continuously animated — remains a documented projection limitation (every frame changes the items' screen boxes, which re-triggers layout measurement) and is gated behind the projection-engine rewrites; it isn't given an assertion here as a continuously-animating layout under a known limitation would be a flaky test. The supported pattern for animated parents is to drivescalewith a tracked motion value (<motion.div style={{ scale }}>), which the projection system can see.Fixes #2750
Refs #2449, #3132, #3356