Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions dev/react/src/tests/reorder-scaled-parent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {
correctParentTransform,
MotionConfig,
Reorder,
useMotionValue,
} from "framer-motion"
import { ReactNode, useRef, useState } from "react"

/**
* Reproduction + supported-workaround fixture for the scaled-parent Reorder
* cluster (#2449 / #2750).
*
* A Reorder list sits inside a parent with a raw CSS `transform: scale()` and
* a non-centre `transform-origin`. Raw CSS transforms on ancestors are
* invisible to the projection / drag measurement system (it only tracks motion
* values), so by default the dragged item translates faster/slower than the
* cursor and reorder thresholds fire at the wrong positions — the list appears
* to "move around" relative to the pointer.
*
* The supported fix is `correctParentTransform(ref)` fed to
* `MotionConfig transformPagePoint`: it 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
* (local) space.
*
* - `?scale=` parent scale factor (default `0.5`)
* - `?corrected=` when `"true"`, applies `correctParentTransform`
*/
const initialItems = [0, 1, 2, 3]

const Item = ({ item }: { item: number }) => {
const y = useMotionValue(0)
const hue = item * 90

return (
<Reorder.Item
value={item}
id={`item-${item}`}
data-testid={`item-${item}`}
style={{
y,
height: 50,
marginBottom: 10,
borderRadius: 8,
listStyle: "none",
cursor: "grab",
background: `hsl(${hue}, 70%, 50%)`,
}}
/>
)
}

export const App = () => {
const params = new URLSearchParams(window.location.search)
const scale = parseFloat(params.get("scale") || "0.5")
const corrected = params.get("corrected") === "true"

const ref = useRef<HTMLDivElement>(null)
const [items, setItems] = useState(initialItems)

const group = (
<Reorder.Group
axis="y"
onReorder={setItems}
values={items}
id="reorder-group"
style={{ listStyle: "none", padding: 0, margin: 0, width: 280 }}
>
{items.map((item) => (
<Item key={item} item={item} />
))}
</Reorder.Group>
)

return (
<div
ref={ref}
id="scaled-parent"
style={{
position: "absolute",
top: 0,
left: 0,
width: 300,
height: 600,
padding: 10,
boxSizing: "border-box",
background: "#222",
transform: `scale(${scale})`,
transformOrigin: "top left",
}}
>
<Wrapper corrected={corrected} parentRef={ref}>
{group}
</Wrapper>
<style>{styles}</style>
</div>
)
}

const Wrapper = ({
corrected,
parentRef,
children,
}: {
corrected: boolean
parentRef: React.RefObject<HTMLDivElement | null>
children: ReactNode
}) =>
corrected ? (
<MotionConfig transformPagePoint={correctParentTransform(parentRef)}>
{children}
</MotionConfig>
) : (
<>{children}</>
)

const styles = `
body {
margin: 0;
padding: 0;
background: #ffaa00;
}
`
123 changes: 123 additions & 0 deletions packages/framer-motion/cypress/integration/reorder-scaled-parent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Reorder inside a parent with a raw CSS `transform: scale()` and a non-centre
* `transform-origin`.
*
* Issues: https://github.com/motiondivision/motion/issues/2449
* https://github.com/motiondivision/motion/issues/2750
*
* Raw CSS transforms on ancestors are invisible to the projection / drag
* measurement system, so without correction the dragged item does not track
* the cursor and reorder thresholds fire at the wrong positions. The supported
* workaround is `correctParentTransform(ref)` via `MotionConfig
* transformPagePoint`. These tests pin that workaround so future drag /
* projection refactors can't silently regress it.
*/
function domOrder(): Cypress.Chainable<string[]> {
return cy.get("#reorder-group [data-testid]").then(($items) =>
Cypress._.map($items, (el) => el.getAttribute("data-testid") as string)
)
}

describe("Reorder inside a scaled parent (#2449 / #2750)", () => {
it("dragged item tracks the cursor with correctParentTransform (scale 0.5)", () => {
cy.visit("?test=reorder-scaled-parent&corrected=true&scale=0.5")
.wait(300)
.get("[data-testid='item-0']")
.then(($el: any) => {
const start = $el[0].getBoundingClientRect()
const startMidX = start.left + start.width / 2
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)
Comment on lines +29 to +54

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 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.

expect(screenDelta).to.be.lessThan(100)
})
.trigger("pointerup", { force: true })
})
})

it("reorders correctly and settles aligned with correctParentTransform", () => {
cy.visit("?test=reorder-scaled-parent&corrected=true&scale=0.5")
.wait(300)

domOrder().should("deep.equal", [
"item-0",
"item-1",
"item-2",
"item-3",
])

cy.get("[data-testid='item-0']").then(($el: any) => {
const start = $el[0].getBoundingClientRect()
const startMidX = start.left + start.width / 2
const startMidY = start.top + start.height / 2

let chain = cy
.wrap($el)
.trigger("pointerdown", startMidX, startMidY, { force: true })
.wait(50)
// Move past the drag threshold first.
.trigger("pointermove", startMidX, startMidY + 6, {
force: true,
})
.wait(50)

// Steady drag downward. With scale 0.5 the offset is corrected
// into local space, so the on-screen distance must be ~2x the
// local row pitch to cross item-1's centre. Incremental moves
// keep a non-zero velocity so checkReorder engages.
for (let i = 1; i <= 6; i++) {
chain = chain
.trigger("pointermove", startMidX, startMidY + i * 14, {
force: true,
})
.wait(60)
}

chain.trigger("pointerup", { force: true }).wait(700)
})

// item-0 should have moved down past item-1 (a swap occurred) ...
domOrder().then((order) => {
expect(order.indexOf("item-0")).to.be.greaterThan(
order.indexOf("item-1"),
`expected item-0 to sit below item-1, got ${order.join(",")}`
)
})

// ... 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)
Comment on lines +107 to +118

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 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.

}
}
})
})
})
2 changes: 1 addition & 1 deletion plans/issues/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ plan's own gate is satisfied). Classifications:

## Answer-and-close queue (INVALID/SUPPORT; all closes gated)

[issue-2449](issue-2449.md) + [issue-2750](issue-2750.md) (P2 — prove `correctParentTransform` workaround for scaled parents, then documented-limitation close) · [issue-2546](issue-2546.md) (pass `axis:"x"`) · [issue-2568](issue-2568.md) (hold-timer recipe) · [issue-2538](issue-2538.md) · [issue-2503](issue-2503.md) · [issue-2542](issue-2542.md) · [issue-2636](issue-2636.md) (dup of 1725) · [issue-2714](issue-2714.md) · [issue-2716](issue-2716.md) · [issue-2491](issue-2491.md) · [issue-2246](issue-2246.md) (maintainer already wontfixed in-thread; execute) · [issue-2250](issue-2250.md) (same) · [issue-2598](issue-2598.md) · [issue-2642](issue-2642.md) · [issue-2268](issue-2268.md) · [issue-2261](issue-2261.md) · [issue-2655](issue-2655.md) · [issue-2641](issue-2641.md) · [issue-2260](issue-2260.md) · [issue-2566](issue-2566.md) · [issue-2608](issue-2608.md) · [issue-2509](issue-2509.md) · [issue-2676](issue-2676.md) · [issue-2550](issue-2550.md) · [issue-3737](issue-3737.md) (**NO ACTION** — not this repo, maintainer already engaged)
[issue-2449](issue-2449.md) + [issue-2750](issue-2750.md) (P2 — VERIFIED-WORKAROUND, awaiting close approval: `correctParentTransform` regression-tested for scaled parents in `reorder-scaled-parent.ts` (React 18 + 19); static-scale case covered, animated CSS scale/origin remains the documented projection limitation) · [issue-2546](issue-2546.md) (pass `axis:"x"`) · [issue-2568](issue-2568.md) (hold-timer recipe) · [issue-2538](issue-2538.md) · [issue-2503](issue-2503.md) · [issue-2542](issue-2542.md) · [issue-2636](issue-2636.md) (dup of 1725) · [issue-2714](issue-2714.md) · [issue-2716](issue-2716.md) · [issue-2491](issue-2491.md) · [issue-2246](issue-2246.md) (maintainer already wontfixed in-thread; execute) · [issue-2250](issue-2250.md) (same) · [issue-2598](issue-2598.md) · [issue-2642](issue-2642.md) · [issue-2268](issue-2268.md) · [issue-2261](issue-2261.md) · [issue-2655](issue-2655.md) · [issue-2641](issue-2641.md) · [issue-2260](issue-2260.md) · [issue-2566](issue-2566.md) · [issue-2608](issue-2608.md) · [issue-2509](issue-2509.md) · [issue-2676](issue-2676.md) · [issue-2550](issue-2550.md) · [issue-3737](issue-3737.md) (**NO ACTION** — not this repo, maintainer already engaged)

## Covered (pointer plans — execute the owning plan)

Expand Down