From ebccbf8656b2858f5a829311586d7b7a3b83beba Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 17 Mar 2026 07:33:39 +0100 Subject: [PATCH] Add Cypress tests for SVG styles on mount (Fixes #2949) The bug (SVG transform-origin jumping on initial mount) was fixed in PR #3154, which ensured transformBox: "fill-box" and transformOrigin: "50% 50%" are set before dimensions are measured. The fix was preserved during the motion-dom refactoring. This commit adds targeted E2E tests to prevent regression and closes the issue. Co-Authored-By: Claude Opus 4.6 --- dev/react/src/tests/svg-style-on-mount.tsx | 51 ++++++++++++ .../cypress/integration/svg-style-on-mount.ts | 82 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 dev/react/src/tests/svg-style-on-mount.tsx create mode 100644 packages/framer-motion/cypress/integration/svg-style-on-mount.ts diff --git a/dev/react/src/tests/svg-style-on-mount.tsx b/dev/react/src/tests/svg-style-on-mount.tsx new file mode 100644 index 0000000000..527545f0e0 --- /dev/null +++ b/dev/react/src/tests/svg-style-on-mount.tsx @@ -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 ( + + {/* Path with useTransform-derived pathLength + opacity + CSS transform */} + + {/* Circle with useTransform-derived fill */} + + {/* Rect with static transform to test transformBox/transformOrigin */} + + + ) +} diff --git a/packages/framer-motion/cypress/integration/svg-style-on-mount.ts b/packages/framer-motion/cypress/integration/svg-style-on-mount.ts new file mode 100644 index 0000000000..6b4629d302 --- /dev/null +++ b/packages/framer-motion/cypress/integration/svg-style-on-mount.ts @@ -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 + 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 = + $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%") + }) + }) +})