Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1245,4 +1245,149 @@ describe("AnimatePresence with custom components", () => {
})
})
})

test("Removes exiting children during rapid key switches with dynamic custom variants", async () => {
const variants: Variants = {
enter: (custom: string) => ({
...(custom === "fade"
? { opacity: 0 }
: { x: -100 }),
transition: { duration: 0.1 },
}),
center: {
opacity: 1,
x: 0,
transition: { duration: 0.1 },
},
exit: (custom: string) => ({
...(custom === "fade"
? { opacity: 0 }
: { x: 100 }),
transition: { duration: 0.1 },
}),
}

const items = [
{ id: "a", transition: "fade" },
{ id: "b", transition: "slide" },
{ id: "c", transition: "fade" },
{ id: "d", transition: "slide" },
]

const Component = ({ active }: { active: number }) => {
const item = items[active]
return (
<AnimatePresence custom={item.transition}>
<motion.div
key={item.id}
data-testid={item.id}
variants={variants}
initial="enter"
animate="center"
exit="exit"
custom={item.transition}
/>
</AnimatePresence>
)
}

const { container, rerender } = render(<Component active={0} />)
rerender(<Component active={0} />)

// Rapidly switch through all items
await act(async () => {
rerender(<Component active={1} />)
})
await act(async () => {
rerender(<Component active={2} />)
})
await act(async () => {
rerender(<Component active={3} />)
})

// Wait for all exit animations to complete
await new Promise((resolve) => setTimeout(resolve, 500))
await act(async () => {
await nextFrame()
await nextFrame()
})

// Only the last item should remain
expect(container.childElementCount).toBe(1)
})

test("Fires onExitComplete during rapid key switches with dynamic custom variants", async () => {
const variants: Variants = {
enter: (custom: string) => ({
...(custom === "fade"
? { opacity: 0 }
: { x: -100 }),
transition: { duration: 0.1 },
}),
center: {
opacity: 1,
x: 0,
transition: { duration: 0.1 },
},
exit: (custom: string) => ({
...(custom === "fade"
? { opacity: 0 }
: { x: 100 }),
transition: { duration: 0.1 },
}),
}

const items = [
{ id: "a", transition: "fade" },
{ id: "b", transition: "slide" },
{ id: "c", transition: "fade" },
{ id: "d", transition: "slide" },
]

let exitCompleteCount = 0

const Component = ({ active }: { active: number }) => {
const item = items[active]
return (
<AnimatePresence
custom={item.transition}
onExitComplete={() => {
exitCompleteCount++
}}
>
<motion.div
key={item.id}
variants={variants}
initial="enter"
animate="center"
exit="exit"
custom={item.transition}
/>
</AnimatePresence>
)
}

const { rerender } = render(<Component active={0} />)
rerender(<Component active={0} />)

// Rapidly switch through all items
await act(async () => {
rerender(<Component active={1} />)
})
await act(async () => {
rerender(<Component active={2} />)
})
await act(async () => {
rerender(<Component active={3} />)
})

// Wait for all exit animations to complete
await new Promise((resolve) => setTimeout(resolve, 500))
await act(async () => {
await nextFrame()
await nextFrame()
})

expect(exitCompleteCount).toBeGreaterThan(0)
})
})
17 changes: 17 additions & 0 deletions packages/motion-dom/src/render/utils/animation-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,23 @@ export function createAnimationState(visualElement: any): AnimationState {
continue
}

/**
* If exit is already active and wasn't just activated, skip
* re-processing to prevent interrupting running exit animations.
* Re-resolving exit with a changed custom value can start new
* value animations that stop the originals, leaving the exit
* animation promise unresolved and the component stuck in the DOM.
*/
if (type === "exit" && typeState.isActive && activeDelta !== true) {
if (typeState.prevResolvedValues) {
encounteredKeys = {
...encounteredKeys,
...typeState.prevResolvedValues,
}
}
continue
}

/**
* As we go look through the values defined on this type, if we detect
* a changed value or a value that was removed in a higher priority, we set
Expand Down