diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fd5279ed7..64665f2eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [12.33.1] 2026-02-06 + +### Fixed + +- `AnimatePresence`: Ensure exiting nodes are correctly removed when rapidly switching children. + ## [12.33.0] 2026-02-05 ### Added diff --git a/README.md b/README.md index 93444459d1..e3c68a2526 100644 --- a/README.md +++ b/README.md @@ -128,11 +128,12 @@ Motion drives the animations on the Cursor homepage, and is working with Cursor ### Platinum -Linear Figma Sanity Sanity +Linear Figma Sanity Sanity Clerk + ### Gold -Liveblocks Luma Notion LottieFiles +Liveblocks Luma LottieFiles ### Silver diff --git a/dev/html/package.json b/dev/html/package.json index 344e112257..3d815866f6 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.33.0", + "version": "12.33.1", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.33.0", - "motion": "^12.33.0", - "motion-dom": "^12.33.0", + "framer-motion": "^12.33.1", + "motion": "^12.33.1", + "motion-dom": "^12.33.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/next/package.json b/dev/next/package.json index 438de224b8..61da8f8a81 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.33.0", + "version": "12.33.1", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.33.0", + "motion": "^12.33.1", "next": "15.4.10", "react": "19.0.0", "react-dom": "19.0.0" diff --git a/dev/react-19/package.json b/dev/react-19/package.json index 54b73dc456..996031615a 100644 --- a/dev/react-19/package.json +++ b/dev/react-19/package.json @@ -1,7 +1,7 @@ { "name": "react-19-env", "private": true, - "version": "12.33.0", + "version": "12.33.1", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.33.0", + "motion": "^12.33.1", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index 39f30c7557..a8baa0f678 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.33.0", + "version": "12.33.1", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.33.0", + "framer-motion": "^12.33.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index cccd6e05fd..d1f0925b41 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.33.0", + "version": "12.33.1", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/README.md b/packages/framer-motion/README.md index e9e29ab30d..da283907b0 100644 --- a/packages/framer-motion/README.md +++ b/packages/framer-motion/README.md @@ -124,11 +124,12 @@ Motion drives the animations on the Cursor homepage, and is working with Cursor ### Platinum -Linear Figma Sanity Sanity +Linear Figma Sanity Sanity Clerk + ### Gold -Liveblocks Luma Notion LottieFiles +Liveblocks Luma LottieFiles ### Silver diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 59ac460c5d..6da9f86767 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.33.0", + "version": "12.33.1", "description": "A simple and powerful JavaScript animation library", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -88,7 +88,7 @@ "measure": "rollup -c ./rollup.size.config.mjs" }, "dependencies": { - "motion-dom": "^12.33.0", + "motion-dom": "^12.33.1", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, 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 130861ebdf..24143eb031 100644 --- a/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx @@ -1245,4 +1245,149 @@ describe("AnimatePresence with custom components", () => { }) }) }) + + test("Removes exiting children during rapid key switches with dynamic custom variants", async () => { + const variants: Variants = { + enter: (custom: string) => ({ + ...(custom === "fade" + ? { opacity: 0 } + : { x: -100 }), + transition: { duration: 0.1 }, + }), + center: { + opacity: 1, + x: 0, + transition: { duration: 0.1 }, + }, + exit: (custom: string) => ({ + ...(custom === "fade" + ? { opacity: 0 } + : { x: 100 }), + transition: { duration: 0.1 }, + }), + } + + const items = [ + { id: "a", transition: "fade" }, + { id: "b", transition: "slide" }, + { id: "c", transition: "fade" }, + { id: "d", transition: "slide" }, + ] + + const Component = ({ active }: { active: number }) => { + const item = items[active] + return ( + + + + ) + } + + const { container, rerender } = render() + rerender() + + // Rapidly switch through all items + await act(async () => { + rerender() + }) + await act(async () => { + rerender() + }) + await act(async () => { + rerender() + }) + + // Wait for all exit animations to complete + await new Promise((resolve) => setTimeout(resolve, 500)) + await act(async () => { + await nextFrame() + await nextFrame() + }) + + // Only the last item should remain + expect(container.childElementCount).toBe(1) + }) + + test("Fires onExitComplete during rapid key switches with dynamic custom variants", async () => { + const variants: Variants = { + enter: (custom: string) => ({ + ...(custom === "fade" + ? { opacity: 0 } + : { x: -100 }), + transition: { duration: 0.1 }, + }), + center: { + opacity: 1, + x: 0, + transition: { duration: 0.1 }, + }, + exit: (custom: string) => ({ + ...(custom === "fade" + ? { opacity: 0 } + : { x: 100 }), + transition: { duration: 0.1 }, + }), + } + + const items = [ + { id: "a", transition: "fade" }, + { id: "b", transition: "slide" }, + { id: "c", transition: "fade" }, + { id: "d", transition: "slide" }, + ] + + let exitCompleteCount = 0 + + const Component = ({ active }: { active: number }) => { + const item = items[active] + return ( + { + exitCompleteCount++ + }} + > + + + ) + } + + const { rerender } = render() + rerender() + + // Rapidly switch through all items + await act(async () => { + rerender() + }) + await act(async () => { + rerender() + }) + await act(async () => { + rerender() + }) + + // Wait for all exit animations to complete + await new Promise((resolve) => setTimeout(resolve, 500)) + await act(async () => { + await nextFrame() + await nextFrame() + }) + + expect(exitCompleteCount).toBeGreaterThan(0) + }) }) diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index 1cf9ede8ba..0bfb737ecf 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.33.0", + "version": "12.33.1", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion-dom/src/render/utils/animation-state.ts b/packages/motion-dom/src/render/utils/animation-state.ts index b1cc01bc74..ba8a926644 100644 --- a/packages/motion-dom/src/render/utils/animation-state.ts +++ b/packages/motion-dom/src/render/utils/animation-state.ts @@ -197,6 +197,23 @@ export function createAnimationState(visualElement: any): AnimationState { continue } + /** + * If exit is already active and wasn't just activated, skip + * re-processing to prevent interrupting running exit animations. + * Re-resolving exit with a changed custom value can start new + * value animations that stop the originals, leaving the exit + * animation promise unresolved and the component stuck in the DOM. + */ + if (type === "exit" && typeState.isActive && activeDelta !== true) { + if (typeState.prevResolvedValues) { + encounteredKeys = { + ...encounteredKeys, + ...typeState.prevResolvedValues, + } + } + continue + } + /** * As we go look through the values defined on this type, if we detect * a changed value or a value that was removed in a higher priority, we set diff --git a/packages/motion/README.md b/packages/motion/README.md index 3a9a0d9d3b..43d1dee2c8 100644 --- a/packages/motion/README.md +++ b/packages/motion/README.md @@ -128,11 +128,12 @@ Motion drives the animations on the Cursor homepage, and is working with Cursor ### Platinum -Linear Figma Sanity Sanity +Linear Figma Sanity Sanity Clerk + ### Gold -Liveblocks Luma Notion LottieFiles +Liveblocks Luma LottieFiles ### Silver diff --git a/packages/motion/package.json b/packages/motion/package.json index 3db1446f2e..2519b7dd05 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.33.0", + "version": "12.33.1", "description": "An animation library for JavaScript and React.", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -76,7 +76,7 @@ "postpublish": "git push --tags" }, "dependencies": { - "framer-motion": "^12.33.0", + "framer-motion": "^12.33.1", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 60d07fcb72..a86c263107 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7437,14 +7437,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.33.0, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.33.1, framer-motion@workspace:packages/framer-motion": version: 0.0.0-use.local resolution: "framer-motion@workspace:packages/framer-motion" dependencies: "@radix-ui/react-dialog": ^1.1.15 "@thednp/dommatrix": ^2.0.11 "@types/three": 0.137.0 - motion-dom: ^12.33.0 + motion-dom: ^12.33.1 motion-utils: ^12.29.2 three: 0.137.0 tslib: ^2.4.0 @@ -8209,9 +8209,9 @@ __metadata: version: 0.0.0-use.local resolution: "html-env@workspace:dev/html" dependencies: - framer-motion: ^12.33.0 - motion: ^12.33.0 - motion-dom: ^12.33.0 + framer-motion: ^12.33.1 + motion: ^12.33.1 + motion-dom: ^12.33.1 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 @@ -10953,7 +10953,7 @@ __metadata: languageName: node linkType: hard -"motion-dom@^12.33.0, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.33.1, motion-dom@workspace:packages/motion-dom": version: 0.0.0-use.local resolution: "motion-dom@workspace:packages/motion-dom" dependencies: @@ -11032,11 +11032,11 @@ __metadata: languageName: unknown linkType: soft -"motion@^12.33.0, motion@workspace:packages/motion": +"motion@^12.33.1, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.33.0 + framer-motion: ^12.33.1 tslib: ^2.4.0 peerDependencies: "@emotion/is-prop-valid": "*" @@ -11153,7 +11153,7 @@ __metadata: version: 0.0.0-use.local resolution: "next-env@workspace:dev/next" dependencies: - motion: ^12.33.0 + motion: ^12.33.1 next: 15.4.10 react: 19.0.0 react-dom: 19.0.0 @@ -12625,7 +12625,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - motion: ^12.33.0 + motion: ^12.33.1 react: ^19.0.0 react-dom: ^19.0.0 vite: ^5.2.0 @@ -12709,7 +12709,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - framer-motion: ^12.33.0 + framer-motion: ^12.33.1 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0