fix: transitionEnd stuck with stale values after rapid variant switching#3532
fix: transitionEnd stuck with stale values after rapid variant switching#3532mattgperry merged 1 commit intomainfrom
Conversation
Pull Request Review: Fix transitionEnd Stuck with Stale ValuesSummaryThis PR addresses a race condition where Code Quality ✅Strengths
Potential Issues 🔍1. Missing Unit Tests for InstantAnimation ClassThe // Suggested test cases:
// - stop() prevents onUpdate/onComplete from firing
// - complete() immediately fires callbacks and resolves promise
// - cancel() prevents callbacks (verify difference from stop())
// - multiple stop() calls are safe
// - promise rejection handling (onReject parameter)Recommendation: Add unit tests at 2. Race Condition in ConstructorThere's a subtle timing issue in the constructor(
private onUpdate: VoidFunction,
private onComplete: VoidFunction
) {
super()
frame.update(() => {
if (this._isStopped) return // ✅ Good check
onUpdate()
this.notifyFinished()
onComplete()
})
}If
Issue: If Recommendation: Either:
3. Inconsistent cancel() vs stop() BehaviorBoth methods do the same thing (
The current implementation doesn't distinguish between these semantics. 4. complete() Method Doesn't Check _isStoppedcomplete() {
this.onUpdate()
this.notifyFinished()
this.onComplete()
}If the animation is already stopped, calling complete() {
if (this._isStopped) return
this._isStopped = true
this.onUpdate()
this.notifyFinished()
this.onComplete()
}5. Memory Leak Risk (Minor)If a component unmounts before the Recommendation: Verify that Performance Considerations ⚡
Security Concerns 🔒No security concerns identified. This is pure animation lifecycle management with no external inputs or DOM manipulation. Test Coverage 📊What's Tested ✅
What's Missing ❌
Style & Convention Compliance 📋✅ Uses Recommendations SummaryHigh Priority
Medium Priority
Low Priority
Overall Assessment ⭐Verdict: ✅ Approve with minor improvements This is a solid fix that correctly addresses the root cause. The code is clean, well-tested at the integration level, and follows repository conventions. The main gaps are around edge case testing and promise lifecycle management, but these don't block merging since the fix solves the reported issue and all existing tests pass. The implementation demonstrates a good understanding of the animation system's architecture and the fix is appropriately scoped. Nice work on the thorough investigation and minimal fix! The PR description clearly explains the problem and solution, making it easy to review. |
94bc9fb to
54eeb09
Compare
Code ReviewSummaryThis PR fixes a critical race condition in instant animations where ✅ Strengths1. Root Cause Analysis
2. Excellent Test Coverage
3. Clean Solution
4. Backwards Compatibility
🔍 Potential Concerns & Questions1. Performance Impact Impact analysis:
Question: Have you benchmarked the performance difference? For high-frequency instant animations (like Recommendation: Consider adding a micro-benchmark or stress test for instant animations to quantify any performance regression. If measurable, document it as an acceptable tradeoff for correctness. 2. Test Coverage Gap Missing scenarios:
Recommendation: Consider adding a parametrized test or additional test cases for these scenarios. 3. Code Deletion Confidence if (shouldSkip && \!isHandoff && value.get() \!== undefined) {
const finalKeyframe = getFinalKeyframe(...)
if (finalKeyframe \!== undefined) {
frame.update(() => {
options.onUpdate\!(finalKeyframe)
options.onComplete\!()
})
return
}
}Question: Was the Recommendation: Verify that handoff animations still work correctly, especially optimized-appear scenarios. 4. Alternative Approach
This would preserve performance while fixing the race condition. Example: let pendingInstantUpdate: (() => void) | undefined
value.stop = () => {
if (pendingInstantUpdate) {
frame.cancel(pendingInstantUpdate)
pendingInstantUpdate = undefined
}
// ... normal stop logic
}Question: Was this approach considered? If so, why was it rejected? 🐛 Potential Issues5. Edge Case: Zero-Duration vs Type False if ((options as any).type === false ||
(options.duration === 0 && \!options.repeatDelay)) {
makeAnimationInstant(options)
}But if (options.delay === 0) {
shouldSkip = true // old code
}Question: Is there a behavior difference for 📚 Documentation & Style6. Code Style ✅
7. Missing Context // Instant animations must still create animation objects to ensure
// proper cancellation semantics. Without this, rapid variant switches
// can cause stale transitionEnd values to override new variants.
// See: https://github.com/motiondivision/motion/issues/1668🧪 Testing Recommendations8. Additional Testing # Full test suite
yarn test
# Playwright E2E tests (especially instant transition scenarios)
make test-e2e
# Verify no build errors
yarn buildFrom the PR description, these have passed ✅, which is excellent. 9. Performance Testing # Run stress test with instant animations
# (if such a test exists in dev/html/public/benchmarks/)🎯 Final VerdictOverall: Strong approval with minor reservations This is a well-researched fix for a real user-facing bug. The solution is architecturally clean and the test coverage demonstrates understanding of the race condition. Approval contingent on:
Minor improvements:
The core fix is correct and should be merged after addressing the performance verification question. 💡 Positive Notes
Great work on tracking down this subtle race condition! 🎉 Rating: 8.5/10 - Solid fix with room for minor improvements in documentation and performance validation. |
When all animations are instant (shouldSkip), Promise.all([]) resolves as a microtask, deferring the transitionEnd frame.update callback until after the new variant's callbacks are already queued. The stale transitionEnd then fires last, overriding the new variant's values. Fix: when animations array is empty, queue transitionEnd synchronously via frame.update instead of through Promise.all, so it's ordered correctly in the same frame batch as the animation callbacks. Fixes #1668. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
54eeb09 to
c429439
Compare
Code ReviewSummaryThis PR fixes a critical race condition (issue #1668) where ✅ Strengths1. Excellent Root Cause Analysis
2. Minimal, Focused Fix
3. Comprehensive Test Coverage
4. Follows Repository Conventions
🔍 Code Quality Analysisvisual-element-target.ts (lines 134-145) if (transitionEnd) {
const applyTransitionEnd = () =>
frame.update(() => {
transitionEnd && setTarget(visualElement, transitionEnd)
})
if (animations.length) {
Promise.all(animations).then(applyTransitionEnd)
} else {
applyTransitionEnd()
}
}Observations:
Potential micro-optimization (optional, not blocking): if (transitionEnd) {
const applyTransitionEnd = () =>
frame.update(() => setTarget(visualElement, transitionEnd))
if (animations.length) {
Promise.all(animations).then(applyTransitionEnd)
} else {
applyTransitionEnd()
}
}🎯 Test QualityThe new test is production-grade:
Test structure follows best practices:
🔒 Security & PerformanceNo Security Concerns:
Performance Impact: Positive
🧪 Test CoverageVerified Coverage:
Edge Cases Covered:
📝 Suggestions1. Documentation (Optional) if (transitionEnd) {
const applyTransitionEnd = () =>
frame.update(() => setTarget(visualElement, transitionEnd))
if (animations.length) {
// Defer transitionEnd until all animations complete
Promise.all(animations).then(applyTransitionEnd)
} else {
// No animations means instant transition - apply synchronously
// to avoid microtask timing issues with rapid variant changes
applyTransitionEnd()
}
}2. Test Refinement (Optional) ✅ Final VerdictThis PR is ready to merge. The fix is:
The only suggestions above are minor polish items that don't block merging. Recommendation: APPROVE ✅ Great work identifying and fixing this subtle race condition. The microtask timing issue would have been very difficult to debug without the clear analysis provided. Review generated by Claude Code using repository conventions from CLAUDE.md |
Summary
transitionEndvalues override the new variant's values when rapidly switching variants with instant transitions (type: falseorduration: 0)Promise.all([])resolves as a microtask, deferring thetransitionEndframe.updatecallback until after the new variant's callbacks — so the staletransitionEndfires last and winsanimationsarray is empty, queuetransitionEndsynchronously viaframe.updateinstead of throughPromise.all, keeping it ordered correctly in the same frame batchTest plan
🤖 Generated with Claude Code