Skip to content

Commit

Permalink
Ensure AnimatePresence executes exiting animations in sequence (#2477)
Browse files Browse the repository at this point in the history
* Fix sequence bug in AnimatePresence (#2462)

* Fix and add tests

* Remove comment

* Remove console logs
  • Loading branch information
regexyl authored Jul 24, 2024
1 parent 892b626 commit f83739f
Show file tree
Hide file tree
Showing 3 changed files with 82 additions and 37 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ describe("AnimatePresence", () => {
key={i}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
transition={{ duration: 0.1 }}
/>
</AnimatePresence>
)
Expand All @@ -354,7 +354,7 @@ describe("AnimatePresence", () => {
rerender(<Component i={2} />)
rerender(<Component i={2} />)
resolve(container.childElementCount)
}, 150)
}, 200)
})

return await expect(promise).resolves.toBe(1)
Expand Down Expand Up @@ -419,8 +419,8 @@ describe("AnimatePresence", () => {
// wait for the exit animation to check the DOM again
setTimeout(() => {
resolve(getByTestId("2").textContent === "2")
}, 250)
}, 150)
}, 150)
}, 200)
})

return await expect(promise).resolves.toBeTruthy()
Expand Down Expand Up @@ -454,13 +454,67 @@ describe("AnimatePresence", () => {
// wait for the exit animation to check the DOM again
setTimeout(() => {
resolve(getByTestId("2").textContent === "2")
}, 250)
}, 150)
}, 150)
}, 200)
})

return await expect(promise).resolves.toBeTruthy()
})

test("Elements exit in sequence during fast renders", async () => {
const Component = ({ nums }: { nums: number[] }) => {
return (
<AnimatePresence>
{nums.map((i) => (
<motion.div
key={i}
data-testid={i}
exit={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.01 }}
>
{i}
</motion.div>
))}
</AnimatePresence>
)
}

const { rerender, getAllByTestId } = render(
<Component nums={[0, 1, 2, 3]} />
)

const getTextContents = () => {
return getAllByTestId(/./).flatMap((element) =>
element.textContent !== null
? parseInt(element.textContent)
: []
)
}

await new Promise<void>((resolve) => {
setTimeout(() => {
act(() => rerender(<Component nums={[1, 2, 3]} />))
setTimeout(() => {
expect(getTextContents()).toEqual([1, 2, 3])
}, 100)
}, 100)
setTimeout(() => {
act(() => rerender(<Component nums={[2, 3]} />))
setTimeout(() => {
expect(getTextContents()).toEqual([2, 3])
}, 100)
}, 250)
setTimeout(() => {
act(() => rerender(<Component nums={[3]} />))
setTimeout(() => {
expect(getTextContents()).toEqual([3])
resolve()
}, 100)
}, 400)
})
})

test("Exit variants are triggered with `AnimatePresence.custom`, not that of the element.", async () => {
const variants = {
enter: { x: 0, transition: { type: false } },
Expand Down
49 changes: 20 additions & 29 deletions packages/framer-motion/src/components/AnimatePresence/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,14 @@ export const AnimatePresence: React.FunctionComponent<
const isMounted = useIsMounted()

// Filter out any children that aren't ReactElements. We can only track ReactElements with a props.key
const filteredChildren = onlyElements(children)
let childrenToRender = filteredChildren
const filteredChildren = useRef(onlyElements(children))
filteredChildren.current = onlyElements(children)
let childrenToRender = filteredChildren.current

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
const presentChildren = useRef(childrenToRender)

// A lookup table to quickly reference components by key
const allChildren = useRef(
new Map<ComponentKey, ReactElement<any>>()
Expand All @@ -118,9 +115,7 @@ export const AnimatePresence: React.FunctionComponent<

useIsomorphicLayoutEffect(() => {
isInitialRender.current = false

updateChildLookup(filteredChildren, allChildren)
presentChildren.current = childrenToRender
updateChildLookup(filteredChildren.current, allChildren)
})

useUnmountEffect(() => {
Expand Down Expand Up @@ -152,8 +147,8 @@ export const AnimatePresence: React.FunctionComponent<

// Diff the keys of the currently-present and target children to update our
// exiting list.
const presentKeys = presentChildren.current.map(getChildKey)
const targetKeys = filteredChildren.map(getChildKey)
const presentKeys = Array.from(allChildren.keys())
const targetKeys = filteredChildren.current.map(getChildKey)

// Diff the present children with our target children and mark those that are exiting
const numPresent = presentKeys.length
Expand All @@ -173,12 +168,12 @@ export const AnimatePresence: React.FunctionComponent<

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

const child = allChildren.get(key)
if (!child) return
if (!child) continue

const insertionIndex = presentKeys.indexOf(key)

Expand All @@ -188,6 +183,16 @@ export const AnimatePresence: React.FunctionComponent<
// clean up the exiting children map
exitingChildren.delete(key)

// Accounts for the edge case where there are still exiting children when the
// children list is already empty from React's POV, which results in React not
// auto re-rendering
if (
filteredChildren.current.length === 0 &&
exitingChildren.size > 0
) {
forceRender()
}

// compute the keys of children that were rendered once but are no longer present
// this could happen in case of too many fast consequent renderings
// @link https://github.com/framer/motion/issues/2023
Expand All @@ -200,20 +205,6 @@ export const AnimatePresence: React.FunctionComponent<
allChildren.delete(leftOverKey)
)

// make sure to render only the children that are actually visible
presentChildren.current = filteredChildren.filter(
(presentChild) => {
const presentChildKey = getChildKey(presentChild)

return (
// filter out the node exiting
presentChildKey === key ||
// filter out the leftover children
leftOverKeys.includes(presentChildKey)
)
}
)

// Defer re-rendering until all exiting children have indeed left
if (!exitingChildren.size) {
if (isMounted.current === false) return
Expand All @@ -239,7 +230,7 @@ export const AnimatePresence: React.FunctionComponent<
}

childrenToRender.splice(insertionIndex, 0, exitingComponent)
})
}

// Add `MotionContext` even to children that don't need it to ensure we're rendering
// the same tree between renders
Expand Down
4 changes: 2 additions & 2 deletions packages/framer-motion/src/utils/use-force-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ export function useForceUpdate(): [VoidFunction, number] {
const [forcedRenderCount, setForcedRenderCount] = useState(0)

const forceRender = useCallback(() => {
isMounted.current && setForcedRenderCount(forcedRenderCount + 1)
}, [forcedRenderCount])
isMounted.current && setForcedRenderCount((count) => count + 1)
}, [isMounted])

/**
* Defer this to the end of the next animation frame in case there are multiple
Expand Down

0 comments on commit f83739f

Please sign in to comment.