-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Add E2E tests for SVG styles on mount #3654
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> | ||
| ) | ||
| } |
| 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 | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Inside a Cypress The canonical Cypress pattern is to use 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().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%") | ||
| }) | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
.should()instead of.then()for assertionsAll 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
itblocks (lines 18, 35, 49, 62, and 73).