Fix AnimatePresence not removing children when exit matches current values#3642
Fix AnimatePresence not removing children when exit matches current values#3642mattgperry merged 1 commit intomainfrom
Conversation
…alues The skip check in animateTarget used `value.isAnimating` (property access) instead of `value.isAnimating()` (method call). Since isAnimating is a method, the property access always returns a truthy function reference, making `!value.isAnimating` always false. This meant the skip check never fired, causing unnecessary animations even when values were already at their exit targets. When the skip fires, we re-assert the value via frame.update to ensure it takes precedence over any stale transitionEnd callbacks from previous animations. Fixes #3078 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR fixes a longstanding bug (#3078) where Changes:
Confidence Score: 5/5
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["animateTarget called\n(e.g. exit animation triggered)"] --> B["for each key in target"]
B --> C["value = visualElement.getValue(key)"]
C --> D{"valueTarget undefined\nor blocked?"}
D -- Yes --> E["continue (skip key)"]
D -- No --> F["currentValue = value.get()"]
F --> G{"currentValue !== undefined\n&& !value.isAnimating()\n&& valueTarget === currentValue\n&& !velocity"}
G -- Yes\n'FIXED: was always false\nbefore this PR' --> H["frame.update: value.set(valueTarget)\n(override stale transitionEnd)"]
H --> E
G -- No --> I["value.start(animateMotionValue(...))"]
I --> J["animations.push(animation)"]
J --> B
B -- loop done --> K{"transitionEnd?"}
K -- Yes, animations.length > 0 --> L["Promise.all(animations).then(applyTransitionEnd)"]
K -- Yes, animations empty --> M["applyTransitionEnd() immediately"]
K -- No --> N["return animations"]
L --> N
M --> N
Last reviewed commit: 90d8c53 |
| // Wait for enter animations to complete (type: "tween", duration: 100s | ||
| // applies to enter too, but the enter target values use variants | ||
| // which have their own transition, not the component's transition prop) |
There was a problem hiding this comment.
Inaccurate comment on enter transition
The inline comment says "type: tween, duration: 100s applies to enter too", but the motion.li components carry transition={{ type: false }} (instant) as their component-level transition prop, and the transition: { duration: 100 } lives inside the exit object — it is only applied during the exit phase. The enter animation is instant (type: false) and not a 100-second tween. The 200 ms wait is simply a comfortable margin for a zero-duration transition to settle, not because any 100-second timer is involved.
Consider replacing the comment with something like:
| // Wait for enter animations to complete (type: "tween", duration: 100s | |
| // applies to enter too, but the enter target values use variants | |
| // which have their own transition, not the component's transition prop) | |
| // Wait for instant enter animations to settle (transition={{ type: false }} = zero duration). | |
| // The exit prop has transition: { duration: 100 } (100 s) which should be skipped by | |
| // the isAnimating() check; we only wait here to ensure enter is complete. |
Summary
value.isAnimating(property access, always truthy) →value.isAnimating()(method call) inanimateTargetframe.updateto take precedence over any staletransitionEndcallbacks from previous animationsBug
When a child
motioncomponent insideAnimatePresencehasexit={{ opacity: 1, scale: 1 }}(matching its current animated values), the skip check inanimateTargetshould detect that no animation is needed and skip it. Instead,!value.isAnimating(property access) was alwaysfalse, so the skip never fired, causing unnecessary exit animations that blocked element removal.Fix
One-character fix:
!value.isAnimating→!value.isAnimating()inpackages/motion-dom/src/animation/interfaces/visual-element-target.ts.Additionally, when the skip fires, we schedule
frame.update(() => value.set(valueTarget))to ensure the value takes precedence over any staletransitionEndcallbacks from previous animation rounds.Fixes #3078
Test plan
AnimatePresence.test.tsxvalidates modal removal with exit matching current valuesanimate-presence-exit-no-op) reproduces the exact bug scenario withcreatePortal, variants, and spring transitions🤖 Generated with Claude Code