Skip to content
Closed
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
25 changes: 25 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,31 @@ motion (public API)
- **render/** - Rendering pipeline (HTML, SVG, DOM utilities)
- **value/** - Motion values and hooks (useMotionValue, useSpring, useScroll, useTransform)

## Bug Fixes: Test-First Approach

When fixing bugs, always follow a TDD workflow:

1. **Write a failing test first** - Create a test that reproduces the bug before writing any fix
2. **Verify the test fails** - Run the test to confirm it fails as expected
3. **Implement the fix** - Write the minimal code needed to make the test pass
4. **Verify the test passes** - Run the test again to confirm the fix works

This ensures:
- The bug is properly understood and captured
- The fix actually addresses the issue
- Regressions are prevented in the future

Example workflow for Cypress E2E tests:
```bash
# 1. Add test component to dev/react/src/tests/
# 2. Add test case to packages/framer-motion/cypress/integration/
# 3. Run the specific test to verify it fails:
yarn start-server-and-test "yarn dev-server" http://localhost:9990 \
"cd packages/framer-motion && npx cypress run --spec cypress/integration/your-test.ts"
# 4. Implement the fix
# 5. Run the test again to verify it passes
```

## Writing Tests

When waiting for the next frame in async tests:
Expand Down
32 changes: 32 additions & 0 deletions dev/react/src/tests/unit-conversion-var-to-simple.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { motion, useCycle } from "framer-motion"

/**
* Test for GitHub issue #3410
* When animating from a calc() expression containing CSS variables to a simple value,
* the animation should interpolate correctly and end at the target value.
*/
export const App = () => {
// Note: This test is the REVERSE direction of the existing unit-conversion test
// The existing test goes 0 → calc(3 * var(--width))
// This test goes calc(100% + var(--offset)) → 0
const [x, cycleX] = useCycle<number | string>(
"calc(100% + var(--offset))",
0
)

return (
<motion.div
initial={false}
animate={{ x }}
transition={{ duration: 0.05 }}
style={{
width: 100,
height: 100,
background: "#ffaa00",
"--offset": "50px",
}}
onClick={() => cycleX()}
id="box"
/>
)
}
25 changes: 25 additions & 0 deletions packages/framer-motion/cypress/integration/unit-conversion.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,29 @@
describe("Unit conversion", () => {
/**
* Test for GitHub issue #3410
* When animating from a calc() with CSS variables to a simple value,
* the animation should end at the target value, not preserve the calc structure.
*/
it("Animate x from calc with CSS variable to simple value", () => {
cy.visit("?test=unit-conversion-var-to-simple")
.wait(100)
.get("#box")
// First check the initial position (should be at x=calc(100%+50px)=150)
.should(([$box]: any) => {
const { left } = $box.getBoundingClientRect()
expect(left).to.equal(150)
})
.trigger("click")
.wait(200)
// After animation, should be at x=0
.should(([$box]: any) => {
const { left } = $box.getBoundingClientRect()
// The box should be at x=0, not at calc(0 + var(--offset))
// which would incorrectly be 50px due to --offset: 50px
expect(left).to.equal(0)
})
})

it("Animate x from 0 to calc", () => {
cy.visit("?test=unit-conversion")
.wait(100)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { MotionValue } from "../../value"
import { findDimensionValueType } from "../../value/types/dimensions"
import { AnyResolvedKeyframe } from "../types"
import { getVariableValue } from "../utils/css-variables-conversion"
import { isCSSVariableToken } from "../utils/is-css-variable"
import {
containsCSSVariable,
isCSSVariableToken,
} from "../utils/is-css-variable"
import {
KeyframeResolver,
OnKeyframesResolved,
Expand Down Expand Up @@ -87,7 +90,20 @@ export class DOMKeyframesResolver<
/**
* Either we don't recognise these value types or we can animate between them.
*/
if (originType === targetType) return
if (originType === targetType) {
/**
* Even if types match, if one contains embedded CSS variables (e.g. in calc())
* and the other doesn't, we need to measure to ensure proper interpolation.
* See GitHub issue #3410.
*/
if (
positionalValues[name] &&
containsCSSVariable(origin) !== containsCSSVariable(target)
) {
this.needsMeasurement = true
}
return
}

/**
* If both values are numbers or pixels, we can animate between them by
Expand Down
11 changes: 11 additions & 0 deletions packages/motion-dom/src/animation/utils/is-css-variable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,14 @@ export const isCSSVariableToken = (

const singleCssVariableRegex =
/var\(--(?:[\w-]+\s*|[\w-]+\s*,(?:\s*[^)(\s]|\s*\((?:[^)(]|\([^)(]*\))*\))+\s*)\)$/iu

/**
* Check if a value contains a CSS variable anywhere (e.g. inside calc()).
* Unlike isCSSVariableToken which checks if the value IS a var() token,
* this checks if the value CONTAINS var() somewhere in the string.
*/
export function containsCSSVariable(
value?: AnyResolvedKeyframe | null
): boolean {
return typeof value === "string" && value.includes("var(--")
}
17 changes: 17 additions & 0 deletions packages/motion-dom/src/utils/mix/__tests__/mix-complex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,20 @@ test("mixComplex will only interpolate values outside of CSS variables", () => {
expect(mixer(0.5)).toBe("rgba(180, 180, 180, 1) 0 var(--grey, 10px) 5px")
expect(mixer(1)).toBe("rgba(0, 0, 0, 1) 0 var(--grey, 10px) 0px")
})

test("mixComplex with mismatched var counts falls back to immediate", () => {
// GitHub issue #3410: when origin has var and target doesn't,
// should use mixImmediate (instant transition), not preserve calc structure
const mixer = mixComplex("calc(100% + var(--offset))", "0")
// At progress 0, should return origin
expect(mixer(0)).toBe("calc(100% + var(--offset))")
// At progress > 0, should instantly return target (mixImmediate behavior)
expect(mixer(0.5)).toBe("0")
expect(mixer(1)).toBe("0")

// Also test with numeric target (not string)
const mixerNumeric = mixComplex("calc(100% + var(--offset))", 0)
expect(mixerNumeric(0)).toBe("calc(100% + var(--offset))")
expect(mixerNumeric(0.5)).toBe(0)
expect(mixerNumeric(1)).toBe(0)
})