diff --git a/packages/framer-motion/src/motion/__tests__/component-svg.test.tsx b/packages/framer-motion/src/motion/__tests__/component-svg.test.tsx index 10c6f5d6f9..ce4b381965 100644 --- a/packages/framer-motion/src/motion/__tests__/component-svg.test.tsx +++ b/packages/framer-motion/src/motion/__tests__/component-svg.test.tsx @@ -144,6 +144,38 @@ describe("SVG", () => { expect(gElement).toHaveStyle("transform: translate(50, 50)") }) + test("spring with an unset stiffness prop never renders NaN points", async () => { + // An unset optional prop forwarded into the transition + const Component = ({ stiffness }: { stiffness?: number }) => ( + + + + ) + const { container } = render() + const polygon = container.querySelector("polygon")! + + const rendered: string[] = [] + const observer = new MutationObserver(() => + rendered.push(polygon.getAttribute("points")!) + ) + observer.observe(polygon, { attributeFilter: ["points"] }) + for (let i = 0; i < 10; i++) await nextFrame() + observer.disconnect() + + expect(rendered.length).toBeGreaterThan(1) + rendered.forEach((points) => + points + .split(/[ ,]/u) + .forEach((coord) => + expect(Number.isFinite(Number(coord))).toBe(true) + ) + ) + }) + test("animates viewBox", async () => { const Component = () => { return ( diff --git a/packages/motion-dom/src/animation/__tests__/JSAnimation.test.ts b/packages/motion-dom/src/animation/__tests__/JSAnimation.test.ts index 7130d0ca6e..bfd0f5b247 100644 --- a/packages/motion-dom/src/animation/__tests__/JSAnimation.test.ts +++ b/packages/motion-dom/src/animation/__tests__/JSAnimation.test.ts @@ -1422,4 +1422,27 @@ describe("JSAnimation", () => { expect(animation.sample(1000).value).toBe("90%") expect(animation.sample(1999).value).toBe("90%") }) + + test("Spring with undefined stiffness over polygon points never produces NaN", () => { + // An explicit `undefined` stiffness, as forwarded from an optional + // prop, must fall back to the default rather than emit NaN progress + // that the complex-value mixer turns into "NaN,NaN NaN,NaN" + const target = "150,5 50,180 250,180" + const animation = animateValue({ + keyframes: ["150,5 75,200 225,200", target], + type: "spring", + stiffness: undefined, + autoplay: false, + }) + + for (let t = 0; t <= 2000; t += 50) { + const coords = animation.sample(t).value.split(/[ ,]/u) + expect(coords).toHaveLength(6) + coords.forEach((coord) => + expect(Number.isFinite(Number(coord))).toBe(true) + ) + } + + expect(animation.sample(2000).value).toBe(target) + }) }) diff --git a/packages/motion-dom/src/animation/generators/__tests__/spring.test.ts b/packages/motion-dom/src/animation/generators/__tests__/spring.test.ts index 433a85fc0b..53df43bfe1 100644 --- a/packages/motion-dom/src/animation/generators/__tests__/spring.test.ts +++ b/packages/motion-dom/src/animation/generators/__tests__/spring.test.ts @@ -1,7 +1,10 @@ import { animateSync } from "../../__tests__/utils" import { ValueAnimationOptions } from "../../types" import { spring } from "../spring" -import { calcGeneratorDuration } from "../utils/calc-duration" +import { + calcGeneratorDuration, + maxGeneratorDuration, +} from "../utils/calc-duration" describe("spring", () => { test.each([ @@ -308,3 +311,148 @@ describe("toString", () => { ) }) }) + +describe("spring NaN guards", () => { + // These deliberately pass invalid physics, which warns + let warn: jest.SpyInstance + beforeEach(() => { + warn = jest.spyOn(console, "warn").mockImplementation(() => {}) + }) + afterEach(() => warn.mockRestore()) + + /** + * animateSync() can't be reused here — it loops `while (!done)`, and a + * spring resolving to NaN never sets done, so it would hang rather than + * fail. + */ + const sample = (options: ValueAnimationOptions) => { + const generator = spring(options) + return [0, 100, 300, 600, 1000].map((t) => generator.next(t).value) + } + + /** + * Every physics option is covered, for each way it can be invalid. An + * explicit `undefined`, e.g. a forwarded optional prop, is the most likely + * in practice. + */ + const physicsKeys = ["stiffness", "damping", "mass"] as const + const invalidValues = [0, -1, NaN, Infinity, -Infinity, undefined] + + for (const key of physicsKeys) { + for (const value of invalidValues) { + // damping of 0 is a valid, perpetually oscillating spring + if (key === "damping" && value === 0) continue + + test(`${key} of ${String(value)} falls back to the default`, () => { + expect(sample({ keyframes: [0, 100], [key]: value })).toEqual( + sample({ keyframes: [0, 100] }) + ) + }) + } + } + + test("damping of 0 is honoured as an undamped spring", () => { + const values = sample({ keyframes: [0, 100], damping: 0 }) + values.forEach((v) => expect(Number.isFinite(v)).toBe(true)) + // An undamped spring oscillates rather than settling on the target + expect(values[values.length - 1]).not.toBeCloseTo(100) + }) + + test("numeric string physics still coerce", () => { + expect( + sample({ + keyframes: [0, 100], + stiffness: "300", + damping: "20", + mass: "2", + } as any) + ).toEqual( + sample({ + keyframes: [0, 100], + stiffness: 300, + damping: 20, + mass: 2, + }) + ) + }) + + test.each(physicsKeys)( + "invalid %s does not discard a provided duration", + (key) => { + expect( + sample({ keyframes: [0, 100], duration: 500, [key]: NaN }) + ).toEqual(sample({ keyframes: [0, 100], duration: 500 })) + } + ) + + test("invalid physics does not discard a provided visualDuration", () => { + expect( + sample({ + keyframes: [0, 100], + visualDuration: 0.5, + bounce: 0.2, + mass: 0, + }) + ).toEqual( + sample({ keyframes: [0, 100], visualDuration: 0.5, bounce: 0.2 }) + ) + }) + + test.each([ + { duration: 500, bounce: NaN }, + { visualDuration: Infinity, bounce: 0.2 }, + ])("non-finite time options don't produce NaN: %o", (options) => { + const values = sample({ keyframes: [0, 100], ...options }) + values.forEach((v) => expect(Number.isFinite(v)).toBe(true)) + }) + + test("visualDuration of 0 falls back to duration-based resolution", () => { + expect( + sample({ keyframes: [0, 100], visualDuration: 0, bounce: 0.2 }) + ).toEqual(sample({ keyframes: [0, 100], bounce: 0.2 })) + }) + + test("retargeting treats invalid physics as absent", () => { + // Initial resolution treats this as a time-defined spring, which + // ignores inherited velocity, so retargeting must too + const generator = spring({ + keyframes: [0, 100], + duration: 600, + stiffness: 0, + }) + generator.next(16) + generator.retarget!([20, -50], 300) + const fresh = spring({ + keyframes: [20, -50], + duration: 600, + velocity: 300, + }) + for (const t of [0, 16, 100, 300, 1000]) { + expect(generator.next(t)).toEqual(fresh.next(t)) + } + }) + + test("invalid stiffness still resolves to a spring that completes", () => { + const generator = spring({ keyframes: [0, 100], stiffness: undefined }) + expect(calcGeneratorDuration(generator)).toBeLessThan( + maxGeneratorDuration + ) + }) + + test("invalid physics warns rather than failing silently", () => { + spring({ keyframes: [0, 100], stiffness: 0 }) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0][0]).toContain("spring-invalid-physics") + }) + + test("valid or explicitly undefined physics does not warn", () => { + spring({ keyframes: [0, 100], stiffness: 200, damping: 0, mass: 2 }) + spring({ + keyframes: [0, 100], + stiffness: undefined, + damping: undefined, + mass: undefined, + }) + expect(warn).not.toHaveBeenCalled() + }) +}) diff --git a/packages/motion-dom/src/animation/generators/spring.ts b/packages/motion-dom/src/animation/generators/spring.ts index 455af9595b..93be21b2a5 100644 --- a/packages/motion-dom/src/animation/generators/spring.ts +++ b/packages/motion-dom/src/animation/generators/spring.ts @@ -166,26 +166,63 @@ function findSpring({ } const durationKeys = ["duration", "bounce"] -const physicsKeys = ["stiffness", "damping", "mass"] function isSpringType(options: SpringOptions, keys: string[]) { return keys.some((key) => (options as any)[key] !== undefined) } +/** + * Spring physics must be finite. stiffness and mass are also divisors so must + * be positive, whereas a damping of 0 is a valid, perpetually oscillating + * spring. Relational rather than Number.isFinite so numeric strings still + * coerce. + */ +const isValidPhysics = (value: number | undefined, canBeZero?: boolean) => + (canBeZero ? value! >= 0 : value! > 0) && value! < Infinity + +/** + * Returns value if it's usable spring physics, otherwise undefined so callers + * fall back to the default. An explicit `undefined`, e.g. from a forwarded + * optional prop, must fall back too. Invalid physics would otherwise resolve + * to NaN spring values that never report done. + */ +function resolvePhysics(value: number | undefined, canBeZero?: boolean) { + if (isValidPhysics(value, canBeZero)) return value + + if (process.env.NODE_ENV !== "production") { + warning( + value === undefined, + "Spring stiffness and mass must be positive, damping 0 or greater", + "spring-invalid-physics" + ) + } + + return undefined +} + function getSpringOptions(options: SpringOptions) { + /** + * Resolve physics before choosing between physics- and duration-based + * resolution, so an invalid stiffness doesn't also silently discard a + * valid duration/bounce. + */ + const validStiffness = resolvePhysics(options.stiffness) + const validDamping = resolvePhysics(options.damping, true) + const validMass = resolvePhysics(options.mass) + let springOptions = { - velocity: springDefaults.velocity, - stiffness: springDefaults.stiffness, - damping: springDefaults.damping, - mass: springDefaults.mass, - isResolvedFromDuration: false, ...options, + stiffness: validStiffness ?? springDefaults.stiffness, + damping: validDamping ?? springDefaults.damping, + mass: validMass ?? springDefaults.mass, + isResolvedFromDuration: false, + // stiffness/damping/mass overrides duration/bounce + isTimeDefined: + (validStiffness ?? validDamping ?? validMass) === undefined && + isSpringType(options, durationKeys), } - // stiffness/damping/mass overrides duration/bounce - if ( - !isSpringType(options, physicsKeys) && - isSpringType(options, durationKeys) - ) { + + if (springOptions.isTimeDefined) { // Time-defined springs should ignore inherited velocity. // Velocity from interrupted animations can cause findSpring() // to compute wildly different spring parameters, leading to @@ -208,7 +245,7 @@ function getSpringOptions(options: SpringOptions) { damping, } } else { - const derived = findSpring({ ...options, velocity: 0 }) + const derived = findSpring(springOptions) springOptions = { ...springOptions, @@ -217,6 +254,20 @@ function getSpringOptions(options: SpringOptions) { } springOptions.isResolvedFromDuration = true } + + /** + * Non-finite time options degenerate: a NaN bounce gives a NaN + * damping, an infinite visualDuration a 0 stiffness. Replace the two + * together, so the relationship duration resolution establishes + * between them is never left half-overwritten. + */ + if ( + !isValidPhysics(springOptions.stiffness) || + !isValidPhysics(springOptions.damping, true) + ) { + springOptions.stiffness = springDefaults.stiffness + springOptions.damping = springDefaults.damping + } } return springOptions @@ -253,6 +304,7 @@ function spring( duration, velocity, isResolvedFromDuration, + isTimeDefined, } = getSpringOptions({ ...options, velocity: -millisecondsToSeconds(options.velocity || 0), @@ -401,13 +453,6 @@ function spring( update() - /** - * Time-defined springs ignore inherited velocity, see getSpringOptions. - */ - const ignoreVelocity = - !isSpringType(options, physicsKeys) && - isSpringType(options, durationKeys) - const calculatedDuration = isResolvedFromDuration ? duration || null : null const generator = { @@ -419,9 +464,8 @@ function spring( retarget: (keyframes: number[], newVelocity: number) => { s.target = keyframes[keyframes.length - 1] s.delta = s.target - keyframes[0] - s.velocity = ignoreVelocity - ? 0 - : -millisecondsToSeconds(newVelocity) + // Time-defined springs ignore inherited velocity, see getSpringOptions + s.velocity = isTimeDefined ? 0 : -millisecondsToSeconds(newVelocity) // Default thresholds depend on the scale of the new delta if (!(options.restSpeed && options.restDelta)) setRestThresholds() // Invalidate any duration lazily cached by JSAnimation