Skip to content

Fix AnimatePresence: apply object-form initial on re-entry#3662

Open
lennondotw wants to merge 4 commits intomotiondivision:mainfrom
lennondotw:fix/object-initial-on-exit-reentry
Open

Fix AnimatePresence: apply object-form initial on re-entry#3662
lennondotw wants to merge 4 commits intomotiondivision:mainfrom
lennondotw:fix/object-initial-on-exit-reentry

Conversation

@lennondotw
Copy link

Summary

When a child re-enters AnimatePresence after its exit animation completed, object-form initial values (e.g., initial={{ opacity: 0.5 }}) were not applied. The component would animate from the exit end value instead of jumping to the initial value first.

Root cause

The re-entry logic in ExitAnimationFeature.update() only handled string variant names:

if (typeof initial === "string") {

Object-form initial values were skipped, causing the enter animation to start from the wrong position.

Fix

Extend the condition to also handle object-form initial values:

if (typeof initial === "string" || typeof initial === "object") {

The resolveVariant function already supports both string and object forms, so no additional changes are needed.

Test plan

  • Added unit test: "Re-entering child with object-form initial resets to initial values when exit was complete"
  • All existing AnimatePresence tests pass (47/47)
  • Updated CHANGELOG.md

Made with Cursor

When a child re-enters AnimatePresence after its exit animation completed,
object-form initial values (e.g., `initial={{ opacity: 0.5 }}`) were not
applied. The component would animate from the exit end value instead of
jumping to the initial value first.

This happened because the re-entry logic only handled string variant names:
`if (typeof initial === "string")`. Object-form initial values were skipped,
causing the enter animation to start from the wrong position.

Fix: Extend the condition to also handle object-form initial values:
`if (typeof initial === "string" || typeof initial === "object")`

The `resolveVariant` function already supports both string and object forms,
so no additional changes are needed.

Made-with: Cursor
@greptile-apps
Copy link

greptile-apps bot commented Mar 23, 2026

Greptile Summary

This PR fixes a bug in AnimatePresence where a child re-entering after its exit animation completed would not snap to its initial values when initial was provided as an object (e.g. initial={{ opacity: 0.5 }}). The component would instead animate from the exit end-state, skipping the intended starting position.

Key changes:

  • exit.ts: Extends the re-entry condition from typeof initial === "string" to also include typeof initial === "object", so object-form initial values are passed through the existing resolveVariantjump() path, just like string variant labels.
  • New unit test validates the two-child pattern: child B exits instantly while child A is still exiting, then B re-enters — confirming its opacity resets to 0.5 before animating to 1.
  • CHANGELOG.md updated under the unreleased section.

Minor concerns:

  • The condition typeof initial === "object" also matches null (a well-known JS quirk) and string[] array variant labels. Both are handled safely — null is blocked by the if (resolved) guard; arrays produce a silent no-op since no motion values are keyed numerically — but a more defensive check (initial !== null && !Array.isArray(initial)) would be clearer.
  • The new test's assertion (toContain(0.5)) is weaker than its own comment claims; checking opacityChanges[0] would enforce that the reset happens before the animate phase.

Confidence Score: 5/5

  • Safe to merge — the fix is minimal, correct, and well-tested; the remaining notes are non-blocking style suggestions.
  • The one-line change is targeted and correct: resolveVariant already handles plain objects by returning them as-is, so extending the type check is all that was needed. The if (resolved) guard protects against edge cases like null. The test covers the exact scenario from the bug report. The two P2 comments (defensive null/array narrowing and assertion ordering) are purely stylistic and do not affect correctness or production behaviour.
  • No files require special attention.

Important Files Changed

Filename Overview
packages/framer-motion/src/motion/features/animation/exit.ts One-line condition fix extending the re-entry branch to handle object-form initial values. The logic is correct; minor defensive coding concern with typeof null === "object" and array variant labels being silently included.
packages/framer-motion/src/components/AnimatePresence/tests/AnimatePresence.test.tsx New test exercises the object-form initial re-entry path with a two-child pattern. The scenario correctly isolates a completed exit while another exit is pending. Assertion is slightly weaker than its own comment suggests (ordering not verified).
CHANGELOG.md Changelog entry added under the correct unreleased section, matching the style of adjacent entries.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[ExitAnimationFeature.update] --> B{presenceContext exists?}
    B -- No --> Z[return]
    B -- Yes --> C{isPresent changed?}
    C -- No --> Z
    C -- Yes --> D{isPresent && prevIsPresent === false\ni.e. re-entry?}
    D -- No --> E[setActive exit = !isPresent\nthen attach onExitComplete handler]
    D -- Yes --> F{isExitComplete?}
    F -- No --> G[setActive exit = false\ncancel exit animation]
    F -- Yes --> H[read initial & custom from props]
    H --> I{"typeof initial === 'string'\n|| typeof initial === 'object'\n★ NEW: object-form now included"}
    I -- No --> K[skip jump step]
    I -- Yes --> J[resolveVariant node, initial, custom]
    J --> L{resolved truthy?}
    L -- No --> K
    L -- Yes --> M["destructure: transition, transitionEnd, ...target\nfor each key → node.getValue(key)?.jump(value)"]
    M --> N[animationState.reset]
    K --> N
    N --> O[animationState.animateChanges]
    O --> P[isExitComplete = false]
Loading

Reviews (1): Last reviewed commit: "Fix AnimatePresence: apply object-form i..." | Re-trigger Greptile

const { initial, custom } = this.node.getProps()

if (typeof initial === "string") {
if (typeof initial === "string" || typeof initial === "object") {
Copy link

Choose a reason for hiding this comment

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

P2 typeof null === "object" edge case

In JavaScript, typeof null === "object" is true, so this condition also matches null. While the if (resolved) guard on line 34 would protect against a null initial prop (since resolveVariantFromProps returns its input unchanged for non-function, non-string values), and TypeScript types prevent null from being passed at compile time, it's worth being precise.

Similarly, typeof [] === "object" is true, so array variant labels (e.g. initial={["hidden", "visible"]}) will now enter the block. resolveVariant will return the array as-is (truthy), the destructuring will produce numeric-keyed target props ({ 0: "hidden", 1: "visible" }), and this.node.getValue("0") will return undefined, making the jump a silent no-op. Functionally harmless, but not the intended path.

A more defensive condition:

Suggested change
if (typeof initial === "string" || typeof initial === "object") {
if (typeof initial === "string" || (initial !== null && !Array.isArray(initial) && typeof initial === "object")) {

Comment on lines +1582 to +1585
// With fix: opacity should jump to 0.5 (object-form initial), then animate to 1
// The first value in opacityChanges after re-entry should be 0.5
// Without fix: opacity stays at 0 or goes straight to 1 without jumping to 0.5
expect(opacityChanges).toContain(0.5)
Copy link

Choose a reason for hiding this comment

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

P2 Assertion weaker than described comment

The inline comment says "The first value in opacityChanges after re-entry should be 0.5", but the assertion only checks that 0.5 appears somewhere in the array — not that it's the first change. If the animation somehow fires values in a different order, the test would still pass.

To match the stated intent and prevent regressions where the value is eventually reached but not as the reset step, consider:

Suggested change
// With fix: opacity should jump to 0.5 (object-form initial), then animate to 1
// The first value in opacityChanges after re-entry should be 0.5
// Without fix: opacity stays at 0 or goes straight to 1 without jumping to 0.5
expect(opacityChanges).toContain(0.5)
// With fix: opacity should jump to 0.5 (object-form initial), then animate to 1
// Without fix: opacity stays at 0 or goes straight to 1 without jumping to 0.5
expect(opacityChanges[0]).toBe(0.5)
expect(opacityChanges).toContain(1)

@lennondotw
Copy link
Author

/rerun

Made-with: Cursor
- Add explicit null and array guards to initial type check
- Use opacityChanges[0] assertion to verify reset happens first

Made-with: Cursor
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.

1 participant