Skip to content

fix(AnimatePresence): Support dynamic mode changes#3531

Merged
mattgperry merged 1 commit intomainfrom
change-animate-presence-mode
Feb 5, 2026
Merged

fix(AnimatePresence): Support dynamic mode changes#3531
mattgperry merged 1 commit intomainfrom
change-animate-presence-mode

Conversation

@mattgperry
Copy link
Collaborator

Summary

  • Always render PopChild in the PresenceChild tree to keep the React component structure stable when AnimatePresence's mode prop changes dynamically (e.g., "wait" → "popLayout")
  • Add a pop prop to PopChild that controls whether measurement and CSS injection are active — when pop={false}, PopChild acts as a transparent passthrough
  • Skip cloneElement ref composition when pop={false} to avoid unnecessary ref interference

Fixes #1717

Test plan

  • Added test: switching mode from "wait" to "popLayout" renders both children during exit
  • Added test: switching mode from "popLayout" to "wait" renders only one child during exit
  • Existing popLayout tests pass (anchorY bottom positioning)
  • Existing nested AnimatePresence tests pass
  • Full AnimatePresence test suite passes (87 suites, 715 tests)

🤖 Generated with Claude Code

@claude
Copy link

claude bot commented Feb 4, 2026

PR Review

Thank you for this well-thought-out fix for dynamic mode changes in AnimatePresence! The solution is elegant and maintains backward compatibility while solving the issue described in #1717.

✅ Strengths

1. Clever Architecture Decision

The core insight—always rendering PopChild in the tree but controlling its behavior via a pop prop—is excellent. This keeps the React component tree stable during mode transitions, preventing unmount/remount cycles that would break animations.

// Old approach: conditionally wrap
if (mode === "popLayout") {
  children = <PopChild>{children}</PopChild>
}

// New approach: always wrap, control behavior
children = <PopChild pop={mode === "popLayout"}>{children}</PopChild>

This is a minimal change with maximum impact.

2. Comprehensive Guard Conditions

The implementation correctly guards all popLayout-specific behaviors when pop={false}:

  • PopChild.tsx:40 - Measurement logic skipped when pop !== false
  • PopChild.tsx:103 - CSS injection skipped when pop === false
  • PopChild.tsx:137-139 - Ref composition skipped when pop === false

This ensures PopChild acts as a transparent passthrough when inactive.

3. Excellent Test Coverage

The two new tests cover both transition directions:

  • wait → popLayout (should render both children during exit)
  • popLayout → wait (should render only one child during exit)

These directly validate the fix for the reported bug.

4. Backward Compatibility

No breaking changes—existing code continues to work identically since the pop prop defaults to the existing behavior.

🔍 Code Quality Observations

Minor: Potential Type Safety Enhancement

The pop prop is typed as pop?: boolean, which allows undefined. The guards use strict equality checks (pop !== false and pop === false), which means:

  • pop={true} → popLayout active ✅
  • pop={false} → popLayout inactive ✅
  • pop={undefined} → popLayout active ✅

This is correct and intentional, but consider adding a JSDoc comment to PopChild's pop prop to clarify that undefined is treated as true:

interface Props {
    // ... other props
    /**
     * Whether to enable popLayout behavior (measurement and positioning).
     * When false, PopChild acts as a transparent passthrough.
     * @default true
     */
    pop?: boolean
}

This would make the default behavior explicit in the type definition.

Minor: Test Assertion Could Be More Specific

In the new tests (lines 744 and 770), the assertions check childElementCount:

expect(container.childElementCount).toBe(2) // or .toBe(1)

While this validates the fix, consider also asserting that:

  1. The exiting element has the expected data-motion-pop-id attribute (for popLayout mode)
  2. Exit animations are actually running (opacity is transitioning)

However, the current assertions are sufficient for regression prevention.

🎯 Performance Considerations

Positive: The change adds negligible overhead:

  • One additional React component wrapper (always rendered)
  • One boolean prop check per render
  • No new state, effects, or DOM operations when pop={false}

The performance impact is essentially zero.

🔒 Security Considerations

No security concerns. The change:

  • Doesn't introduce XSS vectors
  • Doesn't modify CSS injection logic (still uses proper escaping via insertRule)
  • Doesn't add new user-controlled inputs

🐛 Potential Edge Cases

I considered several edge cases that appear to be handled correctly:

  1. Rapid mode toggling - Should work since PopChild remains mounted
  2. Nested AnimatePresence with different modes - Independent pop props per level ✅
  3. Mode changes during exit animations - The isPresent dependency in useInsertionEffect ensures CSS cleanup ✅
  4. React 18 vs React 19 ref handling - Already handled (lines 87-89) ✅

📝 Suggestions (Optional)

1. Consider an E2E Test

While the unit tests validate the fix, consider adding a Playwright test that:

  • Renders a component in wait mode
  • Switches to popLayout mode
  • Verifies the visual behavior matches expectations

This would catch any subtle layout shift or animation timing issues.

2. Documentation Update

If there's user-facing documentation about AnimatePresence modes, consider adding a note that dynamic mode switching is supported (this fixes a previously undocumented limitation).

✅ Final Verdict

Approved. This is a high-quality fix that:

  • ✅ Solves the reported issue elegantly
  • ✅ Maintains backward compatibility
  • ✅ Includes comprehensive tests
  • ✅ Has minimal performance impact
  • ✅ Follows the repository's code style (named exports, interfaces, arrow callbacks)

The implementation demonstrates a deep understanding of React's reconciliation and the existing AnimatePresence architecture. Great work! 🎉

🏷️ Minor Nitpick

Line 1162 in the test file adds an extra await nextFrame() to an existing test. While this might be fixing a flaky test, it's unrelated to the PR's main purpose. Consider documenting why this change was necessary (perhaps the mode change logic introduced a timing difference).


Recommendation: Merge after addressing the optional JSDoc suggestion if desired. The code is production-ready as-is.

…nimations

Always render PopChild wrapper in PresenceChild to keep the React tree
stable when AnimatePresence's mode prop changes dynamically (e.g.,
"wait" → "popLayout"). Add a `pop` prop to PopChild that controls
whether measurement and CSS injection are active, preventing tree
structure changes that cause React to unmount/remount inner components.

Fixes #1717

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@mattgperry mattgperry force-pushed the change-animate-presence-mode branch from b506645 to e3bf501 Compare February 5, 2026 09:08
@claude
Copy link

claude bot commented Feb 5, 2026

Code Review

Summary

This PR fixes a React component tree stability issue when dynamically switching AnimatePresence modes. The solution is elegant and well-tested. Overall, this is high-quality work that follows the repository's conventions.


✅ Strengths

  1. Smart architectural fix: Always rendering PopChild while controlling its behavior with a pop prop keeps the React component tree stable, preventing re-mounting issues when mode changes dynamically.

  2. Minimal changes: The fix touches only 3 files with a net reduction of 12 lines. This is a focused, surgical change that doesn't over-engineer.

  3. Excellent test coverage: Two new tests specifically verify the fix:

    • Switching from "wait" → "popLayout"
    • Switching from "popLayout" → "wait"

    Both tests verify that animations continue working correctly across mode changes.

  4. Code style compliance: Uses interface for type definitions, proper TypeScript conventions, and follows the repository's established patterns.

  5. Backwards compatible: The pop prop defaults to undefined, and the logic uses pop === false checks, so existing usage isn't affected.


🔍 Observations & Minor Considerations

1. Ref composition skip logic (PopChild.tsx:137-139)

{pop === false
    ? children
    : React.cloneElement(children as any, { ref: composedRef })}

Good: Skipping cloneElement when pop={false} avoids unnecessary ref interference.

Question: Is there any edge case where a parent component might expect the ref to still be composed even when pop is disabled? This seems unlikely but worth considering.


2. Guard conditions use strict equality (PopChild.tsx:40, 103)

this.props.pop \!== false  // Line 40
pop === false             // Line 103

Observation: Line 40 uses \!== false (defaults to true when pop is undefined), while line 103 uses === false (explicit false check). This is correct and intentional, but the asymmetry could be subtle.

Suggestion: Consider adding a brief comment explaining the default behavior, e.g.:

// When pop is undefined, default behavior is to measure/inject styles
if (element && prevProps.isPresent && \!this.props.isPresent && this.props.pop \!== false) {

3. Test file change (AnimatePresence.test.tsx:1162)

async function complete() {
    await nextFrame()
+   await nextFrame()  // Why was this added?

Question: This appears to be an unrelated fix. Was this test flaky before? If so, it might warrant a brief comment or a separate commit to explain why the extra frame wait is needed.


🎯 Performance Considerations

  • Positive: The PopChild component is always rendered now, but when pop={false}, it acts as a transparent passthrough without measurement, CSS injection, or ref composition. This minimizes overhead.
  • No regression: The conditional logic gates expensive operations (snapshot measurements, DOM manipulations) behind the pop \!== false checks.

🔒 Security Concerns

  • CSP nonce handling: The code properly respects the nonce from MotionConfigContext when injecting styles (PopChild.tsx:111). No concerns here.
  • No XSS risk: The injected CSS uses numeric values and ID-based selectors. No user input is interpolated unsafely.

🧪 Test Coverage

Strong coverage, but consider:

  1. Edge case: What happens if mode changes multiple times rapidly during an ongoing animation? The current tests use transition: { type: false } (instant animations). Testing with actual animated transitions could reveal timing issues.

  2. Nested AnimatePresence: The PR mentions "Existing nested AnimatePresence tests pass" — this is good, but does it test mode switching in nested contexts?


📋 Documentation

Minor suggestion: Consider adding a JSDoc comment to the pop prop explaining its purpose:

interface Props {
    children: React.ReactElement
    isPresent: boolean
    anchorX?: "left" | "right"
    anchorY?: "top" | "bottom"
    root?: HTMLElement | ShadowRoot
    /**
     * Controls whether PopChild actively measures and applies positioning.
     * When false, PopChild acts as a transparent wrapper.
     * @internal
     */
    pop?: boolean
}

✨ Recommendation

Approve with minor suggestions. This PR:

The minor suggestions above are non-blocking and can be addressed in a follow-up if desired.

Great work! 🚀

@mattgperry mattgperry merged commit f69a9ff into main Feb 5, 2026
5 checks passed
@mattgperry mattgperry deleted the change-animate-presence-mode branch February 5, 2026 09:56
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] changing mode of AnimatePresence does not work

1 participant