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 @@ -163,4 +163,145 @@ describe("usePresence", () => {

await promise
})

test("Calling safeToRemove multiple times only triggers exit once", async () => {
const promise = new Promise<void>((resolve) => {
let safeToRemoveRef: CB
let onExitCompleteCount = 0

const Child = () => {
const [isPresent, safeToRemove] = usePresence()

useEffect(() => {
if (safeToRemove) safeToRemoveRef = safeToRemove
}, [isPresent, safeToRemove])

return <div />
}

const Parent = ({ isVisible }: { isVisible: boolean }) => (
<AnimatePresence onExitComplete={() => onExitCompleteCount++}>
{isVisible && <Child />}
</AnimatePresence>
)

const { container, rerender } = render(<Parent isVisible />)

rerender(<Parent isVisible={false} />)

// Simulate rapid events calling safeToRemove multiple times
act(() => {
safeToRemoveRef()
safeToRemoveRef()
safeToRemoveRef()
})

setTimeout(() => {
// onExitComplete should only be called once
expect(onExitCompleteCount).toBe(1)
// Child should be removed
expect(container.firstChild).toBeFalsy()
resolve()
}, 150)
})

await promise
})

test("Rapid rerenders during exit only triggers exit once", async () => {
const promise = new Promise<void>((resolve) => {
let safeToRemoveRef: CB
let onExitCompleteCount = 0

const Child = () => {
const [isPresent, safeToRemove] = usePresence()

useEffect(() => {
if (safeToRemove) safeToRemoveRef = safeToRemove
}, [isPresent, safeToRemove])

return <div />
}

const Parent = ({ isVisible }: { isVisible: boolean }) => (
<AnimatePresence onExitComplete={() => onExitCompleteCount++}>
{isVisible && <Child />}
</AnimatePresence>
)

const { container, rerender } = render(<Parent isVisible />)

// Rapid re-renders with isVisible={false}
rerender(<Parent isVisible={false} />)
rerender(<Parent isVisible={false} />)
rerender(<Parent isVisible={false} />)

// Now call safeToRemove
act(() => safeToRemoveRef())

setTimeout(() => {
// onExitComplete should only be called once
expect(onExitCompleteCount).toBe(1)
// Child should be removed
expect(container.firstChild).toBeFalsy()
resolve()
}, 150)
})

await promise
})

test("Component can exit again after re-entering", async () => {
const promise = new Promise<void>((resolve) => {
let safeToRemoveRef: CB
let onExitCompleteCount = 0

const Child = () => {
const [isPresent, safeToRemove] = usePresence()

useEffect(() => {
if (safeToRemove) safeToRemoveRef = safeToRemove
}, [isPresent, safeToRemove])

return <div />
}

const Parent = ({ isVisible }: { isVisible: boolean }) => (
<AnimatePresence onExitComplete={() => onExitCompleteCount++}>
{isVisible && <Child />}
</AnimatePresence>
)

const { container, rerender } = render(<Parent isVisible />)

// First exit
rerender(<Parent isVisible={false} />)
act(() => safeToRemoveRef())

setTimeout(() => {
expect(onExitCompleteCount).toBe(1)
expect(container.firstChild).toBeFalsy()

// Re-enter
rerender(<Parent isVisible />)

setTimeout(() => {
expect(container.firstChild).toBeTruthy()

// Second exit
rerender(<Parent isVisible={false} />)
act(() => safeToRemoveRef())

setTimeout(() => {
// onExitComplete should be called twice (once per exit cycle)
expect(onExitCompleteCount).toBe(2)
expect(container.firstChild).toBeFalsy()
resolve()
}, 150)
}, 150)
}, 150)
})

await promise
})
})
11 changes: 11 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,11 @@ export const AnimatePresence = ({
*/
const exitComplete = useConstant(() => new Map<ComponentKey, boolean>())

/**
* Track which components are currently processing exit to prevent duplicate processing.
*/
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 All @@ -109,6 +114,7 @@ export const AnimatePresence = ({
}
} else {
exitComplete.delete(key)
exitingComponents.current.delete(key)
}
}
}, [renderedChildren, presentKeys.length, presentKeys.join("-")])
Expand Down Expand Up @@ -179,6 +185,11 @@ export const AnimatePresence = ({
presentKeys.includes(key)

const onExit = () => {
if (exitingComponents.current.has(key)) {
return
}
exitingComponents.current.add(key)

if (exitComplete.has(key)) {
exitComplete.set(key, true)
} else {
Expand Down