diff --git a/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx b/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx index eb39373019..0bf3b62958 100644 --- a/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx @@ -329,7 +329,7 @@ describe("AnimatePresence", () => { key={i} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - transition={{ duration: 0.5 }} + transition={{ duration: 0.1 }} /> ) @@ -345,7 +345,7 @@ describe("AnimatePresence", () => { rerender() rerender() resolve(container.childElementCount) - }, 150) + }, 200) }) return await expect(promise).resolves.toBe(1) @@ -410,8 +410,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() @@ -445,13 +445,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 ( + + {nums.map((i) => ( + + {i} + + ))} + + ) + } + + const { rerender, getAllByTestId } = render( + + ) + + const getTextContents = () => { + return getAllByTestId(/./).flatMap((element) => + element.textContent !== null + ? parseInt(element.textContent) + : [] + ) + } + + await new Promise((resolve) => { + setTimeout(() => { + act(() => rerender()) + setTimeout(() => { + expect(getTextContents()).toEqual([1, 2, 3]) + }, 100) + }, 100) + setTimeout(() => { + act(() => rerender()) + setTimeout(() => { + expect(getTextContents()).toEqual([2, 3]) + }, 100) + }, 250) + setTimeout(() => { + act(() => rerender()) + 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 } }, diff --git a/packages/framer-motion/src/components/AnimatePresence/index.tsx b/packages/framer-motion/src/components/AnimatePresence/index.tsx index 108c36f14c..7507ceb9af 100644 --- a/packages/framer-motion/src/components/AnimatePresence/index.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/index.tsx @@ -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 | 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>() @@ -118,9 +115,7 @@ export const AnimatePresence: React.FunctionComponent< useIsomorphicLayoutEffect(() => { isInitialRender.current = false - - updateChildLookup(filteredChildren, allChildren) - presentChildren.current = childrenToRender + updateChildLookup(filteredChildren.current, allChildren) }) useUnmountEffect(() => { @@ -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 @@ -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) @@ -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 @@ -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 @@ -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 diff --git a/packages/framer-motion/src/utils/use-force-update.ts b/packages/framer-motion/src/utils/use-force-update.ts index 28b7f0d5d8..2d08fc477b 100644 --- a/packages/framer-motion/src/utils/use-force-update.ts +++ b/packages/framer-motion/src/utils/use-force-update.ts @@ -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