Skip to content

Commit

Permalink
Use ref for exiting children (Fixes #1914) (#2113)
Browse files Browse the repository at this point in the history
* use ref for existingChildren

* add test which fails to repro

---------

Co-authored-by: Matt Perry <mattgperry@gmail.com>
  • Loading branch information
hluisson and mattgperry committed May 10, 2023
1 parent 6a94425 commit 46fbfd9
Show file tree
Hide file tree
Showing 2 changed files with 90 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,55 @@ describe("AnimatePresence", () => {
expect(container.firstChild).toBeFalsy()
})

test("Animates out all components when unmounted in close succession", async () => {
const keys = [0, 1, 2]

const Component = ({ visibleKeys }: { visibleKeys: number[] }) => {
return (
<AnimatePresence>
{visibleKeys.map((key) => {
return (
<motion.div
key={key}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
/>
)
})}
</AnimatePresence>
)
}

const { container, rerender } = render(<Component visibleKeys={keys} />)

// Remove the last element from the array and wait briefly
await act(async () => {
rerender(<Component visibleKeys={[0, 1]} />)
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve()
}, 100)
});
})

// Remove the second-to-last element from the array
await act(async () => {
rerender(<Component visibleKeys={[0]} />)
});

await act(async () => {
await new Promise<void>((resolve) => {
// Resolve after all animation is expected to have completed
setTimeout(() => {
resolve()
}, 1000)
})
})

// There should only be one element left
expect(container.childElementCount).toBe(1)
})

test("Allows nested exit animations", async () => {
const promise = new Promise((resolve) => {
const opacity = motionValue(0)
Expand Down
73 changes: 41 additions & 32 deletions packages/framer-motion/src/components/AnimatePresence/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export const AnimatePresence: React.FunctionComponent<
const filteredChildren = onlyElements(children)
let childrenToRender = filteredChildren

const exiting = new Set<ComponentKey>()
const exitingChildren = useRef(new Map<ComponentKey, ReactElement<any> | undefined>()).current

// Keep a living record of the children we're actually rendering so we
// can diff to figure out which are entering and exiting
Expand All @@ -124,7 +124,7 @@ export const AnimatePresence: React.FunctionComponent<
useUnmountEffect(() => {
isInitialRender.current = true
allChildren.clear()
exiting.clear()
exitingChildren.clear()
})

if (isInitialRender.current) {
Expand Down Expand Up @@ -158,20 +158,20 @@ export const AnimatePresence: React.FunctionComponent<
for (let i = 0; i < numPresent; i++) {
const key = presentKeys[i]

if (targetKeys.indexOf(key) === -1) {
exiting.add(key)
if (targetKeys.indexOf(key) === -1 && !exitingChildren.has(key)) {
exitingChildren.set(key, undefined)
}
}

// If we currently have exiting children, and we're deferring rendering incoming children
// until after all current children have exiting, empty the childrenToRender array
if (mode === "wait" && exiting.size) {
if (mode === "wait" && exitingChildren.size) {
childrenToRender = []
}

// Loop through all currently exiting components and clone them to overwrite `animate`
// with any `exit` prop they might have defined.
exiting.forEach((key) => {
exitingChildren.forEach((component, key) => {
// If this component is actually entering again, early return
if (targetKeys.indexOf(key) !== -1) return

Expand All @@ -180,48 +180,57 @@ export const AnimatePresence: React.FunctionComponent<

const insertionIndex = presentKeys.indexOf(key)

const onExit = () => {
allChildren.delete(key)
exiting.delete(key)
let exitingComponent = component
if (!exitingComponent) {
const onExit = () => {
allChildren.delete(key)
exitingChildren.delete(key)

// Remove this child from the present children
const removeIndex = presentChildren.current.findIndex(
(presentChild) => presentChild.key === key
)
presentChildren.current.splice(removeIndex, 1)
// Remove this child from the present children
const removeIndex = presentChildren.current.findIndex(
(presentChild) => presentChild.key === key
)
presentChildren.current.splice(removeIndex, 1)

// Defer re-rendering until all exiting children have indeed left
if (!exiting.size) {
presentChildren.current = filteredChildren
// Defer re-rendering until all exiting children have indeed left
if (!exitingChildren.size) {
presentChildren.current = filteredChildren

if (isMounted.current === false) return
if (isMounted.current === false) return

forceRender()
onExitComplete && onExitComplete()
forceRender()
onExitComplete && onExitComplete()
}
}

exitingComponent = (
<PresenceChild
key={getChildKey(child)}
isPresent={false}
onExitComplete={onExit}
custom={custom}
presenceAffectsLayout={presenceAffectsLayout}
mode={mode}
>
{child}
</PresenceChild>
)
exitingChildren.set(key, exitingComponent)

}

childrenToRender.splice(
insertionIndex,
0,
<PresenceChild
key={getChildKey(child)}
isPresent={false}
onExitComplete={onExit}
custom={custom}
presenceAffectsLayout={presenceAffectsLayout}
mode={mode}
>
{child}
</PresenceChild>
exitingComponent
)
})

// Add `MotionContext` even to children that don't need it to ensure we're rendering
// the same tree between renders
childrenToRender = childrenToRender.map((child) => {
const key = child.key as string | number
return exiting.has(key) ? (
return exitingChildren.has(key) ? (
child
) : (
<PresenceChild
Expand All @@ -247,7 +256,7 @@ export const AnimatePresence: React.FunctionComponent<

return (
<>
{exiting.size
{exitingChildren.size
? childrenToRender
: childrenToRender.map((child) => cloneElement(child))}
</>
Expand Down

0 comments on commit 46fbfd9

Please sign in to comment.