Skip to content

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

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

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

Conversation

@lennondotw
Copy link
Copy Markdown

@lennondotw lennondotw commented Mar 23, 2026

Summary

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

Root cause

The re-entry logic added in 6a8d3ab and simplified in 3da9d09 only handles string variant labels:

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

Object-form initial values are skipped, so resolveVariant is never called for them.

Fix

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

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

resolveVariant already supports both string and object forms — it returns objects as-is. The null and Array.isArray guards handle the JS typeof null === "object" quirk and array variant labels respectively.

Test plan

  • Added unit test: "Re-entering child with object-form initial resets to initial values when exit was complete"
  • Updated CHANGELOG.md

@greptile-apps
Copy link
Copy Markdown

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
Copy Markdown

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
Copy Markdown

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
Copy Markdown
Author

/rerun

@lennondotw
Copy link
Copy Markdown
Author

Hi @mattgperry, friendly ping — this is a small one-line fix for object-form initial not being applied on AnimatePresence re-entry. CI is all green. Would appreciate a look when you have a moment!

The re-entry logic added in 6a8d3ab only handles string variant
labels. Object-form initial values (e.g., initial={{ opacity: 0.5 }})
are skipped, so the component animates from the exit end value
instead of jumping to the specified initial.

resolveVariant already supports both strings and objects, so extending
the type guard is all that's needed.
@lennondotw lennondotw force-pushed the fix/object-initial-on-exit-reentry branch from 5b837e3 to 3497306 Compare April 3, 2026 07:10
@lennondotw
Copy link
Copy Markdown
Author

Rebased on latest main (v12.38.0). The original PR was based on older code before 6a8d3ab and 3da9d09 refactored the re-entry logic — those two commits fixed the string variant case and introduced resolveVariant, but the typeof initial === "string" guard still skips object-form initial values.

This PR now applies cleanly on top of the current code — just extends the type guard on line 28 of exit.ts to also handle typeof initial === "object" (with null and array guards). resolveVariant already returns objects as-is, so no other changes needed.

@mattgperry would appreciate a look when you get a chance — it's a small extension to the fix you landed in 6a8d3ab.

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