Fix AnimatePresence: apply object-form initial on re-entry#3662
Fix AnimatePresence: apply object-form initial on re-entry#3662lennondotw wants to merge 4 commits intomotiondivision:mainfrom
Conversation
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 SummaryThis PR fixes a bug in Key changes:
Minor concerns:
Confidence Score: 5/5
Important Files Changed
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]
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") { |
There was a problem hiding this comment.
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:
| if (typeof initial === "string" || typeof initial === "object") { | |
| if (typeof initial === "string" || (initial !== null && !Array.isArray(initial) && typeof initial === "object")) { |
| // 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) |
There was a problem hiding this comment.
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:
| // 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) |
|
/rerun |
Made-with: Cursor
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
Summary
When a child re-enters
AnimatePresenceafter its exit animation completed, object-forminitialvalues (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: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:
The
resolveVariantfunction already supports both string and object forms, so no additional changes are needed.Test plan
Made with Cursor