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
51 changes: 51 additions & 0 deletions dev/react/src/tests/svg-style-on-mount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"use client"

import { motion, useMotionValue, useTransform } from "framer-motion"

/**
* Test: SVG styles should apply correctly on mount when using useTransform.
* Reproduction for #2949: SVG transform-origin and styles not applying on mount.
*
* The bug: SVG elements with transforms derived from useTransform would have
* incorrect transformOrigin and transformBox on initial mount, causing a visible
* jump when the visual element takes over rendering.
*/
export function App() {
const x = useMotionValue(50)

// Derived transform values via useTransform
const pathLength = useTransform(x, [0, 100], [0, 1])
const opacity = useTransform(x, [0, 100], [0, 1])
const fill = useTransform(x, [0, 100], ["#0000ff", "#ff0000"])

return (
<svg width="200" height="200" data-testid="svg">
{/* Path with useTransform-derived pathLength + opacity + CSS transform */}
<motion.path
id="path"
d="M 10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80"
fill="none"
stroke="black"
strokeWidth="2"
style={{ pathLength, opacity, x: 10, y: 10 }}
/>
{/* Circle with useTransform-derived fill */}
<motion.circle
id="circle"
cx="100"
cy="100"
r="40"
fill={fill}
/>
{/* Rect with static transform to test transformBox/transformOrigin */}
<motion.rect
id="rect"
x="10"
y="10"
width="50"
height="50"
style={{ rotate: 45 }}
/>
</svg>
)
}
82 changes: 82 additions & 0 deletions packages/framer-motion/cypress/integration/svg-style-on-mount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Tests for #2949: SVG styles not applying on mount.
*
* The bug: SVG elements using useTransform-derived values would have
* incorrect transformOrigin/transformBox on initial mount (before
* dimensions are measured), causing a visible "jump" when the visual
* element takes over.
*
* The fix: Always set transformBox to "fill-box" and transformOrigin
* to "50% 50%" on SVG elements with transforms, even before dimensions
* are measured.
*/
describe("SVG styles on mount (#2949)", () => {
it("Applies transform, transformBox, and transformOrigin on mount", () => {
cy.visit("?test=svg-style-on-mount")
.get("#path")
.then(([$path]: any) => {
// Transform should be applied immediately on mount
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 Use .should() instead of .then() for assertions

All five test cases use .then() for their assertions, but the rest of the codebase (e.g. svg.ts, animate-style.ts) consistently uses .should(). This is a meaningful difference in Cypress: .should() retries the callback until it passes (subject to the command timeout), giving Cypress's async retry-ability for style assertions that may not be synchronously available on the first tick. .then() runs the callback exactly once with no retry, making these tests potentially flaky if styles are applied asynchronously.

The same issue applies to all five it blocks (lines 18, 35, 49, 62, and 73).

Suggested change
// Transform should be applied immediately on mount
.should(([$path]: any) => {

expect($path.style.transform).to.contain("translateX(10px)")
expect($path.style.transform).to.contain("translateY(10px)")

// transformBox must be "fill-box" on initial mount
// (this was the core of the #2949 bug — it was missing)
expect($path.style.transformBox).to.equal("fill-box")

// transformOrigin must be set to prevent jumping
expect($path.style.transformOrigin).to.equal("50% 50%")
})
})

it("Applies useTransform-derived pathLength attributes on mount", () => {
cy.visit("?test=svg-style-on-mount")
.get("#path")
.then(([$path]: any) => {
// pathLength should be 0.5 (useTransform(50, [0,100], [0,1]))
// buildSVGPath normalizes the pathLength attribute to 1
expect($path.getAttribute("pathLength")).to.equal("1")

// stroke-dasharray should reflect pathLength=0.5
expect($path.getAttribute("stroke-dasharray")).to.equal(
"0.5 1"
)

// stroke-dashoffset should be 0 (pathOffset defaults to 0)
const dashoffset = $path.getAttribute("stroke-dashoffset")
expect(parseFloat(dashoffset)).to.equal(0)
})
})

it("Applies useTransform-derived opacity on SVG path on mount", () => {
cy.visit("?test=svg-style-on-mount")
.get("#path")
.then(([$path]: any) => {
// opacity should be 0.5 (useTransform(50, [0,100], [0,1]))
const opacity =
Comment on lines +52 to +55
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 window refers to spec-runner window, not the app window

Inside a Cypress .then() (or .should()) callback, the plain window global resolves to the Cypress spec-runner window, not the tested application's window. Calling window.getComputedStyle($path) on an element from the app iframe may return incorrect results or throw a cross-origin error.

The canonical Cypress pattern is to use cy.window() to obtain the application window:

it("Applies useTransform-derived opacity on SVG path on mount", () => {
    cy.visit("?test=svg-style-on-mount")
        .get("#path")
        .should(([$path]: any) => {
            const opacity = $path.getAttribute("opacity")
            // framer-motion sets opacity as a native SVG attribute,
            // so getAttribute should always return a value here.
            expect(parseFloat(opacity)).to.equal(0.5)
        })
})

If a fallback to computed style is genuinely needed, restructure with cy.window():

cy.window().then((win) => {
    const opacity =
        $path.getAttribute("opacity") ??
        win.getComputedStyle($path).opacity
    expect(parseFloat(opacity)).to.equal(0.5)
})

$path.getAttribute("opacity") ??
window.getComputedStyle($path).opacity
expect(parseFloat(opacity)).to.equal(0.5)
})
})

it("Applies useTransform-derived fill on SVG circle on mount", () => {
cy.visit("?test=svg-style-on-mount")
.get("#circle")
.then(([$circle]: any) => {
// fill should be interpolated (not null/empty/default)
const fill = $circle.getAttribute("fill")
expect(fill).to.not.be.null
expect(fill).to.not.equal("")
})
})

it("Applies transformBox and transformOrigin on SVG rect with static transform", () => {
cy.visit("?test=svg-style-on-mount")
.get("#rect")
.then(([$rect]: any) => {
expect($rect.style.transform).to.equal("rotate(45deg)")
expect($rect.style.transformBox).to.equal("fill-box")
expect($rect.style.transformOrigin).to.equal("50% 50%")
})
})
})