Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use ref for exiting children (Fixes #1914) #2113

Merged
merged 3 commits into from
May 10, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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