Skip to content
Open
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
25 changes: 25 additions & 0 deletions packages/framer-motion/src/components/AnimatePresence/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ export const AnimatePresence = ({
*/
const exitComplete = useConstant(() => new Map<ComponentKey, boolean>())

/**
* Track components that are currently in the process of exiting.
* This prevents duplicate exit handling during rapid state changes.
*/
const exitingComponents = useRef(new Set<ComponentKey>())

/**
* Save children to render as React state. To ensure this component is concurrent-safe,
* we check for exiting children via an effect.
Expand Down Expand Up @@ -179,9 +185,22 @@ export const AnimatePresence = ({
presentKeys.includes(key)

const onExit = () => {
// Check if this component is already being processed
// This prevents duplicate exit handling during rapid state changes
// (e.g., when Radix UI dismissable-layer triggers multiple events)
if (exitingComponents.current.has(key)) {
return
}

// Mark this component as being processed
exitingComponents.current.add(key)

if (exitComplete.has(key)) {
exitComplete.set(key, true)
} else {
// Component was already removed from exitComplete
// (likely re-entered), clean up and return
exitingComponents.current.delete(key)
return
}

Expand All @@ -191,12 +210,18 @@ export const AnimatePresence = ({
})

if (isEveryExitComplete) {
// Clear all tracking states
exitingComponents.current.clear()

forceRender?.()
setRenderedChildren(pendingPresentChildren.current)

propagate && safeToRemove?.()

onExitComplete && onExitComplete()
} else {
// Remove from processing set as this component is done
exitingComponents.current.delete(key)
}
}

Expand Down