plans/030-spring-visual-duration-without-bounce.md
visualDuration work without bounce/durationExecutor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving to the next step. If anything in the "STOP conditions" section occurs, stop and report — do not improvise. When done, update the status row for this plan in
plans/README.md— unless a reviewer dispatched you and told you they maintain the index.Drift check (run first):
git diff --stat 42bfbe3ed..HEAD -- packages/motion-dom/src/animation/generators/spring.ts packages/motion-dom/src/animation/generators/__tests__/spring.test.tsIf any in-scope file changed since this plan was written, compare the "Current state" excerpts against the live code before proceeding; on a mismatch, treat it as a STOP condition.
spring.ts; execute 030 → 031 → 033)42bfbe3ed, 2026-06-11visualDuration is a headline public spring option (transition={{ type: "spring", visualDuration: 0.5 }}), but passing it without also passing bounce or duration has silently done nothing since the feature shipped (PR #2912, Nov 2024). The spring falls through to the default physics (stiffness 100 / damping 10) instead. Empirically verified against the built package: spring({ keyframes: [0, 1], visualDuration: 0.5 }) settles at 1100ms — byte-identical to a no-options spring — while the honored configuration (adding bounce: 0) settles at 900ms. The existing unit test asserting 1100ms passes vacuously, because 1100ms also happens to be the default-physics settle time. The spring(0.5) shorthand is unaffected (it injects a default bounce), which is why this went unnoticed.
packages/motion-dom/src/animation/generators/spring.ts — the spring keyframe generator. The bug is the gate at lines 164–168 plus its use at lines 181–184:// spring.ts:164-169
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.ts:180-191 (inside getSpringOptions)
// stiffness/damping/mass overrides duration/bounce
if (
!isSpringType(options, physicsKeys) &&
isSpringType(options, durationKeys)
) {
// Time-defined springs should ignore inherited velocity.
...
springOptions.velocity = 0
if (options.visualDuration) {
A visualDuration-only options object fails isSpringType(options, durationKeys), so the entire resolution block — including the if (options.visualDuration) branch that implements it — is skipped.
packages/motion-dom/src/animation/generators/__tests__/spring.test.ts — the vacuously-passing test at lines 236–242:describe("visualDuration", () => {
test("returns correct duration", () => {
const generator = spring({ keyframes: [0, 1], visualDuration: 0.5 })
expect(calcGeneratorDuration(generator)).toBe(1100)
})
interface over type, prefer concise patterns (library bundle size matters). Tests in this file use plain Jest expect, no snapshots — match that style.useSpring, springValue/followValue, JSAnimation (via replaceTransitionType), and the WAAPI path (spring.applyToOptions → createGeneratorEasing → same getSpringOptions).| Purpose | Command (from repo root) | Expected on success |
|---|---|---|
| Spring unit tests | npx jest --config packages/motion-dom/jest.config.json --testPathPattern="generators/__tests__/spring" | all pass (17 today; more after this plan) |
| Full motion-dom tests | npx jest --config packages/motion-dom/jest.config.json | pass (ignore pre-existing SSR TextEncoder failures if any appear — they are known) |
| Build all packages | yarn build | exit 0, bundlesize checks pass |
| Lint | yarn lint | exit 0 |
In scope (the only files you should modify):
packages/motion-dom/src/animation/generators/spring.tspackages/motion-dom/src/animation/generators/__tests__/spring.test.tsOut of scope (do NOT touch, even though they look related):
packages/framer-motion/src/value/use-spring.ts and use-follow-value.ts — they just forward options; no change needed.packages/motion-dom/src/animation/generators/utils/create-generator-easing.ts — the WAAPI path picks up the fix automatically.findSpring function and the generator branches in spring.ts — plans 033 and 031 own those regions.mass/stiffness/damping continuing to override visualDuration is existing, intended behavior.fix/spring-visual-duration-without-bounce off maingit log: short imperative summary, e.g. Fix visualDuration being ignored without bounce/durationIn packages/motion-dom/src/animation/generators/__tests__/spring.test.ts, inside the existing describe("visualDuration", ...) block, add:
test("visualDuration works without bounce", () => {
// A visualDuration-only spring must match the same spring with
// an explicit bounce: 0, not fall back to default physics.
expect(
spring({ keyframes: [0, 1], visualDuration: 0.5 }).toString()
).toEqual(
spring({ keyframes: [0, 1], visualDuration: 0.5, bounce: 0 }).toString()
)
})
Verify: npx jest --config packages/motion-dom/jest.config.json --testPathPattern="generators/__tests__/spring" → the new test FAILS (the left side currently resolves to the 1100ms default spring, the right to a 900ms visualDuration spring). All other tests still pass. If the new test passes before any source change, STOP — the premise is wrong.
In packages/motion-dom/src/animation/generators/spring.ts line 164, change:
const durationKeys = ["duration", "bounce"]
to:
const durationKeys = ["duration", "bounce", "visualDuration"]
No other source change. Inside the block, if (options.visualDuration) already routes to the visualDuration math; a visualDuration-only object now reaches it. Note this also means visualDuration-only springs now zero out inherited velocity (springOptions.velocity = 0) — that is intentional and matches how visualDuration + bounce already behaves today.
Verify: npx jest --config packages/motion-dom/jest.config.json --testPathPattern="generators/__tests__/spring" → the new test from Step 1 passes; the pre-existing returns correct duration test now FAILS with calcGeneratorDuration returning 900 instead of 1100. That failure is expected — proceed to Step 3.
Update the existing test at spring.test.ts:237–242: change the expectation from toBe(1100) to toBe(900). Add a one-line comment explaining the value guards the honored visualDuration, e.g.:
test("returns correct duration", () => {
const generator = spring({ keyframes: [0, 1], visualDuration: 0.5 })
// 900ms is the settle time of the visualDuration-derived spring;
// 1100ms would mean visualDuration was ignored (default physics).
expect(calcGeneratorDuration(generator)).toBe(900)
})
Verify: npx jest --config packages/motion-dom/jest.config.json --testPathPattern="generators/__tests__/spring" → ALL tests pass.
Run the full motion-dom suite, then build from the repo root.
Verify:
npx jest --config packages/motion-dom/jest.config.json → pass.yarn build → exit 0, bundlesize checks pass (this change is ~1 word; budgets unaffected).yarn lint → exit 0.If any framer-motion test elsewhere fails after yarn build + yarn test, inspect whether it was encoding the old (broken) behavior; if it asserts a specific duration/curve for a visualDuration-without-bounce spring, update it with a comment, otherwise STOP.
visualDuration works without bounce (Step 1) — the regression gate; fails on the bug, passes with the fix.returns correct duration now expects 900ms (Step 3).correctly resolves shorthand (visualDuration + bounce), Spring defined with bounce and duration is same as just bounce, Time-defined spring ignores velocity.Machine-checkable. ALL must hold:
npx jest --config packages/motion-dom/jest.config.json --testPathPattern="generators/__tests__/spring" exits 0npx jest --config packages/motion-dom/jest.config.json exits 0yarn build exits 0yarn lint exits 0grep -n 'durationKeys = \["duration", "bounce", "visualDuration"\]' packages/motion-dom/src/animation/generators/spring.ts returns one matchgit status)plans/README.md status row updatedStop and report back (do not improvise) if:
42bfbe3ed).calcGeneratorDuration for the visualDuration-only spring is neither 900 nor 1100 (the spring math changed underneath this plan — likely plans 031/033 landed first; re-derive the expected value by computing the same expression for the bounce: 0 twin and confirm both sides are equal, then proceed only if the Step 1 equality test passes).visualDuration without bounce has been silently getting the default spring; after this they get what they asked for. Worth a changelog entry under fixes.velocity = 0 zeroing now applying to visualDuration-only springs (intended; consistent with the visualDuration+bounce path).visualDuration: 0 is falsy, so it falls through to findSpring duration resolution inside the block — identical to how visualDuration: 0, bounce: x behaves today.