Skip to content

Fix AnimatePresence not removing children when exit matches current values#3642

Merged
mattgperry merged 1 commit intomainfrom
worktree-fix-issue-3078
Mar 16, 2026
Merged

Fix AnimatePresence not removing children when exit matches current values#3642
mattgperry merged 1 commit intomainfrom
worktree-fix-issue-3078

Conversation

@mattgperry
Copy link
Collaborator

Summary

  • Fixed value.isAnimating (property access, always truthy) → value.isAnimating() (method call) in animateTarget
  • The skip check that prevents redundant animations when exit targets match current values was completely broken — it never fired because accessing a method as a property returns a truthy function reference
  • When the skip fires, we re-assert the value via frame.update to take precedence over any stale transitionEnd callbacks from previous animations

Bug

When a child motion component inside AnimatePresence has exit={{ opacity: 1, scale: 1 }} (matching its current animated values), the skip check in animateTarget should detect that no animation is needed and skip it. Instead, !value.isAnimating (property access) was always false, so the skip never fired, causing unnecessary exit animations that blocked element removal.

Fix

One-character fix: !value.isAnimating!value.isAnimating() in packages/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 stale transitionEnd callbacks from previous animation rounds.

Fixes #3078

Test plan

  • Unit test in AnimatePresence.test.tsx validates modal removal with exit matching current values
  • Cypress E2E test (animate-presence-exit-no-op) reproduces the exact bug scenario with createPortal, variants, and spring transitions
  • All 770 existing unit tests pass
  • Cypress passes on React 18
  • Cypress passes on React 19

🤖 Generated with Claude Code

…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-apps
Copy link

greptile-apps bot commented Mar 13, 2026

Greptile Summary

This PR fixes a longstanding bug (#3078) where AnimatePresence would fail to remove children whose exit values matched their current animated values. The root cause was a single-character typo: !value.isAnimating evaluated the method as a property reference (always truthy, so !value.isAnimating was always false), permanently preventing the skip-animation check from ever firing. The fix corrects this to !value.isAnimating(), and also defensively schedules a frame.update(() => value.set(valueTarget)) so that the asserted value takes precedence over any stale transitionEnd callbacks that may have been queued by prior animation rounds.

Changes:

  • visual-element-target.ts — Core one-character fix (isAnimatingisAnimating()) plus frame.update re-assertion of skipped values.
  • AnimatePresence.test.tsx — Unit test reproducing the exact failure scenario: nested variant children with exit={{ opacity: 1, scale: 1, transition: { duration: 100 } }} should be removed promptly rather than blocking on a 100-second tween.
  • animate-presence-exit-no-op.ts — Cypress E2E test covering portal-rendered modals with spring transitions and variant propagation.
  • animate-presence-exit-no-op.tsx — Companion dev fixture for the Cypress test.

Confidence Score: 5/5

  • This PR is safe to merge — the change is minimal, well-understood, and backed by both unit and E2E tests.
  • The fix is a single method-call correction (isAnimatingisAnimating()) with a clear, verifiable root cause. The supplementary frame.update re-assertion is a conservative defensive addition. Test coverage is thorough: a unit test with a deliberately 100-second blocking exit transition, and a full Cypress E2E test using portals, variants, and spring transitions. No existing behaviour is changed for cases where the skip check didn't previously fire.
  • No files require special attention.

Important Files Changed

Filename Overview
packages/motion-dom/src/animation/interfaces/visual-element-target.ts One-character bug fix: !value.isAnimating (property reference, always truthy) → !value.isAnimating() (method call). Also adds frame.update(() => value.set(valueTarget)) to override any stale transitionEnd callbacks from previous animation rounds when the skip fires.
packages/framer-motion/src/components/AnimatePresence/tests/AnimatePresence.test.tsx New unit test reproduces issue #3078 by setting exit={{ opacity: 1, scale: 1, transition: { duration: 100 } }} on nested motion.li items, verifying the element tree is removed promptly despite the intentionally long exit transition (which the skip check should prevent from ever starting).
packages/framer-motion/cypress/integration/animate-presence-exit-no-op.ts New Cypress E2E test for issue #3078: opens a modal with createPortal, waits 2s for enter animations, clicks cancel, and asserts the modal is removed within 1s. Tests the full integration path including portal rendering and variant propagation.
dev/react/src/tests/animate-presence-exit-no-op.tsx New dev/test page that creates a portal-based modal with spring-transition variant children using exit={{ opacity: 1, scale: 1 }} — the exact reproduction scenario for issue #3078.

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
Loading

Last reviewed commit: 90d8c53

Comment on lines +1710 to +1712
// 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)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
// 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.

@mattgperry mattgperry merged commit b5798e9 into main Mar 16, 2026
8 checks passed
@mattgperry mattgperry deleted the worktree-fix-issue-3078 branch March 16, 2026 14:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] AnimatePresence won't remove modal if a child animation has a defined exit or similar values

1 participant