Fix laggy spring animations at 240hz refresh rates#3593
Conversation
Greptile SummaryThis PR successfully fixes a critical bug where spring animations at 240hz refresh rates appeared laggy and fell ~22% behind compared to 60hz. The root cause was systematic velocity loss due to finite-difference estimation when springs retargeted. Key Changes
Test CoverageNew test verifies 240hz and 60hz spring positions are within 15% (was ~22% off before fix). All existing spring and animation tests continue to pass. Minor IssueTest helper has a logic bug in delta calculation (line 11-12) but doesn't affect test validity since animations use timestamps directly. Confidence Score: 5/5
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Target changes on MotionValue] --> B[attachFollow schedules animation]
B --> C{currentValue == targetValue?}
C -->|Yes| D[Stop animation, no work needed]
C -->|No| E{activeAnimation exists?}
E -->|Yes| F[Capture activeAnimation.generatorVelocity<br/>analytical derivative]
E -->|No| G[Use value.getVelocity<br/>finite difference]
F --> H[Stop old animation]
G --> H
H --> I[Create new JSAnimation<br/>with captured velocity]
I --> J{Generator has velocity method?}
J -->|Yes - Spring| K[Use analytical spring.velocity<br/>exact derivative at time t]
J -->|No - Other| L[Use finite difference fallback<br/>sample at t and t-5ms]
K --> M[Animation runs with<br/>preserved momentum]
L --> M
style F fill:#90EE90
style K fill:#90EE90
style G fill:#FFB6C1
style L fill:#FFB6C1
Last reviewed commit: 90c0f87 |
| frameData.timestamp = timestamp | ||
| frameData.delta = timestamp - (frameData.timestamp || 0) || 1000 / 60 |
There was a problem hiding this comment.
frameData.timestamp is set before calculating frameData.delta, so delta will always be 0 and fall back to 1000/60. Capture the previous timestamp first:
| frameData.timestamp = timestamp | |
| frameData.delta = timestamp - (frameData.timestamp || 0) || 1000 / 60 | |
| const previousTimestamp = frameData.timestamp | |
| frameData.timestamp = timestamp | |
| frameData.delta = timestamp - (previousTimestamp || 0) || 1000 / 60 |
Spring animations using useSpring/attachFollow were systematically losing velocity at high frame rates because the animation was stopped and restarted every frame with velocity estimated via finite difference. At 240hz (~4ms frames), the finite difference severely underestimated the spring's true velocity, causing the spring to fall ~22% behind vs 60hz. Fix: Use the spring generator's analytical velocity derivative instead of the MotionValue's frame-dependent finite difference. Also use a stable function reference for frame.postRender to deduplicate redundant callbacks from rapid input events within a single frame. Fixes #3265 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace inline finite-difference fallback in generatorVelocity with existing calcGeneratorVelocity utility, removing duplicate logic - Fix processFrame test helper: save previous timestamp before overwriting so delta is computed correctly Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…y for rest detection Merge spring/index.ts, spring/find.ts, spring/defaults.ts, and spring/utils.ts into a single generators/spring.ts file. Replace numerical velocity approximation (calcGeneratorVelocity) with analytical resolveVelocity in the spring's next() method for more accurate rest detection. Update test snapshots for the slightly earlier rest detection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Restore isInDelayPhase guard on mixKeyframes (regressed #3351) - Restore time setter else-branch for driverless case (regressed #3269) - Inline shared trig computation in next() for underdamped springs, eliminating duplicate Math.exp/sin/cos calls per frame Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Change `get generatorVelocity()` to `getGeneratorVelocity()` method - Tighten 240hz vs 60hz test tolerance from 15% to 10% Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
c1ce958 to
2c38dbb
Compare
…amerate test The Chrome-spring-feels-stuck behaviour reported in motiondivision#3407 has the same root cause as motiondivision#3265 (fixed in PR motiondivision#3593, commit e931020): every target update interrupts the spring animation, and reading `value.getVelocity()` after `stopAnimation()` collapses to a cross-animation finite difference of ~0. The fix reads the spring generator's analytical velocity before stopping the animation. Annotate the existing follow-value-framerate test so motiondivision#3407 traces back to the same regression gate.
Summary
useSpring/attachFollowat high refresh rates (240hz+)frame.postRenderto deduplicate redundant callbacks from rapid input eventsProblem
On 240hz displays, spring animations following a moving target (like
useSpringtracking mouse position) appeared laggy and stuttered. The spring element fell ~22% behind compared to the same animation at 60hz.Root cause: When the spring target changed,
attachFollowstopped the animation and created a new one, usingvalue.getVelocity()to preserve velocity. This finite-difference velocity estimate divides the frame-over-frame position change by the frame interval. At 240hz (~4ms frames), the spring barely moves per frame, so the measured velocity severely underestimates the true instantaneous velocity. This systematic velocity loss compounds every frame, causing the spring to progressively fall behind.Fix
Analytical spring velocity (
spring/index.ts): Added avelocity(t)method to the spring generator that returns the exact derivative of the spring equation at timet, rather than approximating via finite difference. This gives accurate velocity regardless of frame rate.Generator velocity on JSAnimation (
JSAnimation.ts): Added ageneratorVelocitygetter that reads the generator's analytical velocity when available, with finite-difference fallback for non-spring generators.Accurate velocity in attachFollow (
follow-value.ts): When retargeting a running spring, useactiveAnimation.generatorVelocityinstead ofvalue.getVelocity(). This preserves exact spring momentum across target changes.Callback deduplication (
follow-value.ts): Extract theframe.postRendercallback to a stable function reference so the frame loop'sSetdeduplicates multiple calls from rapid input events within a single frame.Test plan
follow-value-framerate.test.tsverifies 240hz and 60hz spring positions are within 15% of each other (was ~22% off before fix)spring-valuetests passyarn testpasses (400 motion-dom + 760 framer-motion tests)yarn buildsucceedsFixes #3265
🤖 Generated with Claude Code