diff --git a/CHANGELOG.md b/CHANGELOG.md index bcca9c2b1f..60b4289a6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Framer Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [11.3.18] 2024-07-29 + +### Fixed + +- Improved correctness of `AnimatePresence` and made safe to use with concurrent rendering. + ## [11.3.17] 2024-07-24 ### Added diff --git a/dev/react/package.json b/dev/react/package.json index 5dc6f33938..7d10b006ac 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react", "private": true, - "version": "11.3.17", + "version": "11.3.18-alpha.0", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^11.3.17", + "framer-motion": "^11.3.18-alpha.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/dev/react/src/examples/AnimatePresence-siblings.tsx b/dev/react/src/examples/AnimatePresence-siblings.tsx index fa13956391..6dc42bfce3 100644 --- a/dev/react/src/examples/AnimatePresence-siblings.tsx +++ b/dev/react/src/examples/AnimatePresence-siblings.tsx @@ -17,16 +17,15 @@ const style = { function ExitComponent({ id }) { return ( - <> - - + ) } diff --git a/dev/react/src/examples/AnimatePresence-switch.tsx b/dev/react/src/examples/AnimatePresence-switch.tsx new file mode 100644 index 0000000000..d9f03d33ad --- /dev/null +++ b/dev/react/src/examples/AnimatePresence-switch.tsx @@ -0,0 +1,42 @@ +import { motion, AnimatePresence } from "framer-motion" +import { useState } from "react" + +/** + * An example of a single-child AnimatePresence animation + */ + +const style = { + width: 100, + height: 100, + background: "red", + opacity: 1, +} + +export const App = () => { + const [key, setKey] = useState("a") + + return ( +
{ + setKey(key === "a" ? "b" : "a") + }} + > + console.log("rest")} + > + + +
+ ) +} diff --git a/dev/react/src/examples/AnimatePresence-variants.tsx b/dev/react/src/examples/AnimatePresence-variants.tsx index ef67c0e0ea..9ae13eaa0d 100644 --- a/dev/react/src/examples/AnimatePresence-variants.tsx +++ b/dev/react/src/examples/AnimatePresence-variants.tsx @@ -1,5 +1,5 @@ import { motion, AnimatePresence } from "framer-motion" -import { useEffect, useState } from "react"; +import { useEffect, useState } from "react" /** * An example of AnimatePresence with exit defined as a variant through a tree. diff --git a/dev/react/src/examples/AnimatePresence-wait.tsx b/dev/react/src/examples/AnimatePresence-wait.tsx new file mode 100644 index 0000000000..50629e0d9d --- /dev/null +++ b/dev/react/src/examples/AnimatePresence-wait.tsx @@ -0,0 +1,43 @@ +import { motion, AnimatePresence } from "framer-motion" +import { useState } from "react" + +/** + * An example of a single-child AnimatePresence animation + */ + +const style = { + width: 100, + height: 100, + background: "red", + opacity: 1, +} + +export const App = () => { + const [key, setKey] = useState(0) + + return ( +
{ + setKey(key + 1) + }} + > + console.log("rest")} + > + + +
+ ) +} diff --git a/dev/react/src/examples/AnimatePresence.tsx b/dev/react/src/examples/AnimatePresence.tsx index 1cb178902c..d65cbdcadb 100644 --- a/dev/react/src/examples/AnimatePresence.tsx +++ b/dev/react/src/examples/AnimatePresence.tsx @@ -1,5 +1,5 @@ import { motion, AnimatePresence } from "framer-motion" -import { useEffect, useState } from "react"; +import { useState } from "react" /** * An example of a single-child AnimatePresence animation @@ -15,26 +15,23 @@ const style = { export const App = () => { const [isVisible, setVisible] = useState(true) - useEffect(() => { - setTimeout(() => { - setVisible(!isVisible) - }, 1500) - }) - return ( - console.log("rest")} - > - {isVisible && ( - - )} - +
setVisible(!isVisible)}> + console.log("rest")} + > + {isVisible && ( + + )} + +
) } diff --git a/dev/react/src/tests/animate-presence-switch-waapi.tsx b/dev/react/src/tests/animate-presence-switch-waapi.tsx new file mode 100644 index 0000000000..35a5ffa23a --- /dev/null +++ b/dev/react/src/tests/animate-presence-switch-waapi.tsx @@ -0,0 +1,37 @@ +import { AnimatePresence, motion, useMotionValue } from "framer-motion" +import { useState } from "react" + +export const App = () => { + const count = useMotionValue(0) + const [state, setState] = useState(0) + + return ( + <> + +
+ Animation count: {count} +
+ + count.set(count.get() + 1)} + > + {state} + + + + ) +} diff --git a/dev/react/src/tests/layout-exit.tsx b/dev/react/src/tests/layout-exit.tsx index 388382fb98..826f8d0454 100644 --- a/dev/react/src/tests/layout-exit.tsx +++ b/dev/react/src/tests/layout-exit.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect } from "react" import { motion, AnimatePresence } from "framer-motion" export const App = () => { diff --git a/lerna.json b/lerna.json index 5ea20f1c80..e81d54bbc2 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "11.3.17", + "version": "11.3.18-alpha.0", "packages": [ "packages/*" ], diff --git a/packages/framer-motion-3d/package.json b/packages/framer-motion-3d/package.json index 43bfda1336..3923198a0e 100644 --- a/packages/framer-motion-3d/package.json +++ b/packages/framer-motion-3d/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion-3d", - "version": "11.3.17", + "version": "11.3.18-alpha.0", "description": "A simple and powerful React animation library for @react-three/fiber", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -47,7 +47,7 @@ "postpublish": "git push --tags" }, "dependencies": { - "framer-motion": "^11.3.17", + "framer-motion": "^11.3.18-alpha.0", "react-merge-refs": "^2.0.1" }, "peerDependencies": { @@ -62,5 +62,5 @@ "@rollup/plugin-commonjs": "^22.0.1", "three": "^0.137.0" }, - "gitHead": "7ce78149a4f0587c409a660214131a04fca04c51" + "gitHead": "f3c9de1ba4eb913f36e1f3e816f9c86784714910" } diff --git a/packages/framer-motion/cypress/integration/animate-presence-switch-waapi.ts b/packages/framer-motion/cypress/integration/animate-presence-switch-waapi.ts new file mode 100644 index 0000000000..cb3177b2a8 --- /dev/null +++ b/packages/framer-motion/cypress/integration/animate-presence-switch-waapi.ts @@ -0,0 +1,60 @@ +describe("AnimatePresence with WAAPI animations", () => { + it("Interrupting exiting animation doesn't break exit", () => { + cy.visit("?test=animate-presence-switch-waapi") + .wait(50) + .get(".item") + .should((items: any) => { + expect(items.length).to.equal(1) + expect(items[0].textContent).to.equal("0") + }) + .get("#switch") + .trigger("click", 10, 10, { force: true }) + .wait(50) + .get(".item") + .should((items: any) => { + expect(items.length).to.equal(2) + expect(items[0].textContent).to.equal("0") + expect(items[1].textContent).to.equal("1") + }) + .wait(200) + .get(".item") + .should((items: any) => { + expect(items.length).to.equal(1) + expect(items[0].textContent).to.equal("1") + }) + .get("#switch") + .trigger("click", 10, 10, { force: true }) + .wait(20) + .get("#switch") + .trigger("click", 10, 10, { force: true }) + .wait(20) + .get("#switch") + .trigger("click", 10, 10, { force: true }) + .wait(300) + .get(".item") + .should((items: any) => { + expect(items.length).to.equal(1) + expect(items[0].textContent).to.equal("0") + }) + }) + + it("Interrupting exiting animation fire more animations than expected", () => { + cy.visit("?test=animate-presence-switch-waapi") + .wait(50) + .get(".item") + .should((items: any) => { + expect(items.length).to.equal(1) + expect(items[0].textContent).to.equal("0") + }) + .get("#switch") + .trigger("click", 10, 10, { force: true }) + .wait(20) + .get("#switch") + .trigger("click", 10, 10, { force: true }) + .wait(300) + .get("#count") + .should((count: any) => { + expect(count[0].textContent).to.equal("4") + }) + }) +}) diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 4533bc6849..23ebd75bed 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "11.3.17", + "version": "11.3.18-alpha.0", "description": "A simple and powerful JavaScript animation library", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -104,5 +104,5 @@ "maxSize": "18 kB" } ], - "gitHead": "7ce78149a4f0587c409a660214131a04fca04c51" + "gitHead": "f3c9de1ba4eb913f36e1f3e816f9c86784714910" } diff --git a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts index 05a443fe96..98843c7fe3 100644 --- a/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts +++ b/packages/framer-motion/src/animation/animators/AcceleratedAnimation.ts @@ -313,6 +313,9 @@ export class AcceleratedAnimation< this.isStopped = true if (this.state === "idle") return + this.resolveFinishedPromise() + this.updateFinishedPromise() + const { resolved } = this if (!resolved) return diff --git a/packages/framer-motion/src/animation/interfaces/visual-element.ts b/packages/framer-motion/src/animation/interfaces/visual-element.ts index 591a8ad351..8aeca4977e 100644 --- a/packages/framer-motion/src/animation/interfaces/visual-element.ts +++ b/packages/framer-motion/src/animation/interfaces/visual-element.ts @@ -1,4 +1,3 @@ -import { frame } from "../../frameloop" import { resolveVariant } from "../../render/utils/resolve-dynamic-variants" import { VisualElement } from "../../render/VisualElement" import { AnimationDefinition } from "../types" @@ -33,8 +32,6 @@ export function animateVisualElement( } return animation.then(() => { - frame.postRender(() => { - visualElement.notify("AnimationComplete", definition) - }) + visualElement.notify("AnimationComplete", definition) }) } 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 b05437c379..385c2679ed 100644 --- a/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx @@ -153,7 +153,7 @@ describe("AnimatePresence", () => { }) test("when: afterChildren fires correctly", async () => { - const child = await new Promise((resolve) => { + const child = await new Promise(async (resolve) => { const parentOpacityOutput: ResolvedValues[] = [] const variants = { @@ -191,6 +191,8 @@ describe("AnimatePresence", () => { const { rerender } = render() rerender() + await nextFrame() + await nextFrame() rerender() rerender() }) @@ -397,6 +399,7 @@ describe("AnimatePresence", () => { return ( { rerender() setTimeout(() => { rerender() - }, 50) - setTimeout(() => { - rerender() - // wait for the exit animation to check the DOM again + setTimeout(() => { - resolve(getByTestId("2").textContent === "2") - }, 150) - }, 200) + rerender() + // wait for the exit animation to check the DOM again + setTimeout(() => { + resolve(getByTestId("2").textContent === "2") + }, 250) + }, 50) + }, 50) }) return await expect(promise).resolves.toBeTruthy() diff --git a/packages/framer-motion/src/components/AnimatePresence/index.tsx b/packages/framer-motion/src/components/AnimatePresence/index.tsx index 108c36f14c..7479795c12 100644 --- a/packages/framer-motion/src/components/AnimatePresence/index.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/index.tsx @@ -1,46 +1,12 @@ -import { - useRef, - isValidElement, - cloneElement, - Children, - ReactElement, - ReactNode, - useContext, -} from "react" +import { useContext, useState, useMemo, useRef } from "react" import * as React from "react" import { AnimatePresenceProps } from "./types" -import { useForceUpdate } from "../../utils/use-force-update" -import { useIsMounted } from "../../utils/use-is-mounted" import { PresenceChild } from "./PresenceChild" import { LayoutGroupContext } from "../../context/LayoutGroupContext" -import { useIsomorphicLayoutEffect } from "../../utils/use-isomorphic-effect" -import { useUnmountEffect } from "../../utils/use-unmount-effect" import { invariant } from "../../utils/errors" - -type ComponentKey = string | number - -const getChildKey = (child: ReactElement): ComponentKey => child.key || "" - -function updateChildLookup( - children: ReactElement[], - allChildren: Map> -) { - children.forEach((child) => { - const key = getChildKey(child) - allChildren.set(key, child) - }) -} - -function onlyElements(children: ReactNode): ReactElement[] { - const filtered: ReactElement[] = [] - - // We use forEach here instead of map as map mutates the component key by preprending `.$` - Children.forEach(children, (child) => { - if (isValidElement(child)) filtered.push(child) - }) - - return filtered -} +import { useIsomorphicLayoutEffect } from "../../three-entry" +import { useConstant } from "../../utils/use-constant" +import { ComponentKey, arrayEquals, getChildKey, onlyElements } from "./utils" /** * `AnimatePresence` enables the animation of components that have been removed from the tree. @@ -79,201 +45,182 @@ export const AnimatePresence: React.FunctionComponent< React.PropsWithChildren > = ({ children, + exitBeforeEnter, custom, initial = true, onExitComplete, - exitBeforeEnter, presenceAffectsLayout = true, mode = "sync", }) => { invariant(!exitBeforeEnter, "Replace exitBeforeEnter with mode='wait'") - // We want to force a re-render once all exiting animations have finished. We - // either use a local forceRender function, or one from a parent context if it exists. - const forceRender = - useContext(LayoutGroupContext).forceRender || useForceUpdate()[0] - - 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 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>() - ).current - - // If this is the initial component render, just deal with logic surrounding whether - // we play onMount animations or not. + /** + * Filter any children that aren't ReactElements. We can only track components + * between renders with a props.key. + */ + const presentChildren = useMemo(() => onlyElements(children), [children]) + + /** + * Track the keys of the currently rendered children. This is used to + * determine which children are exiting. + */ + const presentKeys = presentChildren.map(getChildKey) + + /** + * If `initial={false}` we only want to pass this to components in the first render. + */ const isInitialRender = useRef(true) + /** + * A ref containing the currently present children. When all exit animations + * are complete, we use this to re-render the component with the latest children + * *committed* rather than the latest children *rendered*. + */ + const pendingPresentChildren = useRef(presentChildren) + + /** + * Track which exiting children have finished animating out. + */ + const exitComplete = useConstant(() => new Map()) + + /** + * Save children to render as React state. To ensure this component is concurrent-safe, + * we check for exiting children via an effect. + */ + const [diffedChildren, setDiffedChildren] = useState(presentChildren) + const [renderedChildren, setRenderedChildren] = useState(presentChildren) + useIsomorphicLayoutEffect(() => { isInitialRender.current = false + pendingPresentChildren.current = presentChildren - updateChildLookup(filteredChildren, allChildren) - presentChildren.current = childrenToRender - }) + /** + * Update complete status of exiting children. + */ + for (let i = 0; i < renderedChildren.length; i++) { + const key = getChildKey(renderedChildren[i]) - useUnmountEffect(() => { - isInitialRender.current = true - allChildren.clear() - exitingChildren.clear() - }) - - if (isInitialRender.current) { - return ( - <> - {childrenToRender.map((child) => ( - - {child} - - ))} - - ) - } - - // If this is a subsequent render, deal with entering and exiting children - childrenToRender = [...childrenToRender] - - // 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) - - // Diff the present children with our target children and mark those that are exiting - const numPresent = presentKeys.length - for (let i = 0; i < numPresent; i++) { - const key = presentKeys[i] - - if (targetKeys.indexOf(key) === -1 && !exitingChildren.has(key)) { - exitingChildren.set(key, undefined) + if (!presentKeys.includes(key)) { + if (exitComplete.get(key) !== true) { + exitComplete.set(key, false) + } + } else { + exitComplete.delete(key) + } } - } + }, [renderedChildren, presentKeys.length, presentKeys.join("-")]) - // 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" && exitingChildren.size) { - childrenToRender = [] - } - - // Loop through all currently exiting components and clone them to overwrite `animate` - // with any `exit` prop they might have defined. - exitingChildren.forEach((component, key) => { - // If this component is actually entering again, early return - if (targetKeys.indexOf(key) !== -1) return - - const child = allChildren.get(key) - if (!child) return - - const insertionIndex = presentKeys.indexOf(key) + const exitingChildren = [] - let exitingComponent = component - if (!exitingComponent) { - const onExit = () => { - // clean up the exiting children map - exitingChildren.delete(key) + if (presentChildren !== diffedChildren) { + let nextChildren = [...presentChildren] - // 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 - const leftOverKeys = Array.from(allChildren.keys()).filter( - (childKey) => !targetKeys.includes(childKey) - ) - - // clean up the all children map - leftOverKeys.forEach((leftOverKey) => - allChildren.delete(leftOverKey) - ) + /** + * Loop through all the currently rendered components and decide which + * are exiting. + */ + for (let i = 0; i < renderedChildren.length; i++) { + const child = renderedChildren[i] + const key = getChildKey(child) - // make sure to render only the children that are actually visible - presentChildren.current = filteredChildren.filter( - (presentChild) => { - const presentChildKey = getChildKey(presentChild) + if (!presentKeys.includes(key)) { + nextChildren.splice(i, 0, child) + exitingChildren.push(child) + } + } - return ( - // filter out the node exiting - presentChildKey === key || - // filter out the leftover children - leftOverKeys.includes(presentChildKey) - ) - } - ) + /** + * If we're in "wait" mode, and we have exiting children, we want to + * only render these until they've all exited. + */ + if (mode === "wait" && exitingChildren.length) { + nextChildren = exitingChildren + } - // Defer re-rendering until all exiting children have indeed left - if (!exitingChildren.size) { - if (isMounted.current === false) return + nextChildren = onlyElements(nextChildren) - forceRender() - onExitComplete && onExitComplete() - } - } + const childrenHaveChanged = !arrayEquals( + nextChildren.map(getChildKey), + renderedChildren.map(getChildKey) + ) - exitingComponent = ( - - {child} - - ) - exitingChildren.set(key, exitingComponent) + if (childrenHaveChanged) { + setRenderedChildren(nextChildren) } - childrenToRender.splice(insertionIndex, 0, exitingComponent) - }) + setDiffedChildren(presentChildren) - // 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 exitingChildren.has(key) ? ( - child - ) : ( - - {child} - - ) - }) + /** + * Early return to ensure once we've set state with the latest diffed + * children, we can immediately re-render. + */ + return + } if ( process.env.NODE_ENV !== "production" && mode === "wait" && - childrenToRender.length > 1 + renderedChildren.length > 1 ) { console.warn( `You're attempting to animate multiple children within AnimatePresence, but its mode is set to "wait". This will lead to odd visual behaviour.` ) } + /** + * If we've been provided a forceRender function by the LayoutGroupContext, + * we can use it to force a re-render amongst all surrounding components once + * all components have finished animating out. + */ + const { forceRender } = useContext(LayoutGroupContext) + return ( <> - {exitingChildren.size - ? childrenToRender - : childrenToRender.map((child) => cloneElement(child))} + {renderedChildren.map((child) => { + const key = getChildKey(child) + + const isPresent = + presentChildren === renderedChildren || + presentKeys.includes(key) + + const onExit = () => { + if (exitComplete.has(key)) { + exitComplete.set(key, true) + } else { + return + } + + let isEveryExitComplete = true + exitComplete.forEach((isExitComplete) => { + if (!isExitComplete) isEveryExitComplete = false + }) + + if (isEveryExitComplete) { + forceRender?.() + setRenderedChildren(pendingPresentChildren.current) + + onExitComplete && onExitComplete() + } + } + + return ( + + {child} + + ) + })} ) } diff --git a/packages/framer-motion/src/components/AnimatePresence/utils.ts b/packages/framer-motion/src/components/AnimatePresence/utils.ts new file mode 100644 index 0000000000..6e3630b347 --- /dev/null +++ b/packages/framer-motion/src/components/AnimatePresence/utils.ts @@ -0,0 +1,27 @@ +import { isValidElement, Children, ReactElement, ReactNode } from "react" + +export type ComponentKey = string | number + +export const getChildKey = (child: ReactElement): ComponentKey => + child.key || "" + +export function onlyElements(children: ReactNode): ReactElement[] { + const filtered: ReactElement[] = [] + + // We use forEach here instead of map as map mutates the component key by preprending `.$` + Children.forEach(children, (child) => { + if (isValidElement(child)) filtered.push(child) + }) + + return filtered +} + +export function arrayEquals(a: any[], b: any[]) { + if (a.length !== b.length) return false + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + + return true +} diff --git a/packages/framer-motion/src/gestures/__tests__/focus.test.tsx b/packages/framer-motion/src/gestures/__tests__/focus.test.tsx index 340021857d..79694bd489 100644 --- a/packages/framer-motion/src/gestures/__tests__/focus.test.tsx +++ b/packages/framer-motion/src/gestures/__tests__/focus.test.tsx @@ -1,6 +1,6 @@ import { focus, blur, render } from "../../../jest.setup" -import { createRef } from "react"; -import { motion, motionValue } from "../../" +import { createRef } from "react" +import { frame, motion, motionValue } from "../../" import { nextFrame } from "./utils" describe("focus", () => { @@ -142,7 +142,9 @@ describe("focus", () => { const opacity = motionValue(1) let blurred = false - const onComplete = () => blurred && resolve(opacity.get()) + const onComplete = () => { + frame.postRender(() => blurred && resolve(opacity.get())) + } const Component = ({ onAnimationComplete }: any) => ( { const opacity = motionValue(1) let hasMousedOut = false - const onComplete = () => hasMousedOut && resolve(opacity.get()) + const onComplete = () => { + frame.postRender(() => hasMousedOut && resolve(opacity.get())) + } const Component = ({ onAnimationComplete }: any) => ( { }, } const display = motionValue("block") - const onComplete = () => resolve(display.get()) + const onComplete = () => { + frame.postRender(() => resolve(display.get())) + } const Component = () => (