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