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
HardikDewra
left a comment
There was a problem hiding this comment.
Nice catch on the object-form initial handling in AnimatePresence. The exit animation feature was only resolving initial values for string variant labels on re-entry, which meant object-form initial like { opacity: 0.5 } was silently ignored when a component re-entered after completing its exit. The fix extends the condition to also handle plain objects (with proper null and array guards), which is the right approach since resolveVariant already knows how to handle both forms. The test is well-structured - using two children where one exits instantly (type: false) while the other has a long exit duration creates a genuine re-entry scenario where isExitComplete is true for one child but not all.
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