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

Ensure AnimatePresence executes exiting animations in sequence #2477

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 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 @@ -11,6 +11,7 @@ import {
} from "../../.."
import { motionValue } from "../../../value"
import { ResolvedValues } from "../../../render/types"
import { prettyDOM } from "@testing-library/dom"

describe("AnimatePresence", () => {
test("Allows initial animation if no `initial` prop defined", async () => {
Expand Down Expand Up @@ -329,7 +330,7 @@ describe("AnimatePresence", () => {
key={i}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
transition={{ duration: 0.1 }}
/>
</AnimatePresence>
)
Expand All @@ -345,7 +346,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 @@ -410,8 +411,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 @@ -445,13 +446,76 @@ 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, container, 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(() => {
const textContents = getTextContents()
console.log(textContents)
console.log(prettyDOM(container))
expect(getTextContents()).toEqual([1, 2, 3])
}, 100)
}, 100)
setTimeout(() => {
act(() => rerender(<Component nums={[2, 3]} />))
setTimeout(() => {
const textContents = getTextContents()
console.log(textContents)
console.log(prettyDOM(container))
expect(getTextContents()).toEqual([2, 3])
}, 100)
}, 250)
setTimeout(() => {
act(() => rerender(<Component nums={[3]} />))
setTimeout(() => {
const textContents = getTextContents()
console.log(textContents)
console.log(prettyDOM(container))
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)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed presentChildren for clarity - filteredChildren, exitingChildren and allChildren are enough to maintain the status quo


// 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