diff --git a/.circleci/config.yml b/.circleci/config.yml index 246405bf55..fbc00c5ae0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,19 +1,16 @@ # Check https://circleci.com/docs/2.0/language-javascript/ for more details # jobs: - test: + setup: docker: - image: cimg/node:20.11.1-browsers working_directory: ~/repo resource_class: large + environment: + CYPRESS_CACHE_FOLDER: ~/repo/.cache/Cypress steps: - checkout - - run: - name: Yarn version - command: yarn --version - - # Download and cache dependencies (Yarn artifacts, not node_modules) - restore_cache: keys: - v3-yarn-{{ checksum "yarn.lock" }} @@ -29,6 +26,24 @@ jobs: - .yarn/patches key: v3-yarn-{{ checksum "yarn.lock" }} + - run: + name: Build + command: yarn build + + - persist_to_workspace: + root: . + paths: + - . + + test: + docker: + - image: cimg/node:20.11.1-browsers + working_directory: ~/repo + resource_class: large + steps: + - attach_workspace: + at: ~/repo + - run: name: Test Jest command: make test-jest @@ -40,29 +55,13 @@ jobs: docker: - image: cimg/node:20.11.1-browsers working_directory: ~/repo - parallelism: 6 + parallelism: 4 resource_class: large + environment: + CYPRESS_CACHE_FOLDER: ~/repo/.cache/Cypress steps: - - checkout - - - run: - name: Yarn version - command: yarn --version - - - restore_cache: - keys: - - v3-yarn-{{ checksum "yarn.lock" }} - - v3-yarn- - - run: - name: Install dependencies (immutable) - command: yarn install --immutable - - save_cache: - paths: - - .yarn/cache - - .yarn/releases - - .yarn/plugins - - .yarn/patches - key: v3-yarn-{{ checksum "yarn.lock" }} + - attach_workspace: + at: ~/repo - run: name: React tests @@ -77,32 +76,16 @@ jobs: docker: - image: cimg/node:20.11.1-browsers working_directory: ~/repo - parallelism: 6 + parallelism: 4 resource_class: large + environment: + CYPRESS_CACHE_FOLDER: ~/repo/.cache/Cypress steps: - - checkout - - - run: - name: Yarn version - command: yarn --version - - - restore_cache: - keys: - - v3-yarn-{{ checksum "yarn.lock" }} - - v3-yarn- - - run: - name: Install dependencies (immutable) - command: yarn install --immutable - - save_cache: - paths: - - .yarn/cache - - .yarn/releases - - .yarn/plugins - - .yarn/patches - key: v3-yarn-{{ checksum "yarn.lock" }} + - attach_workspace: + at: ~/repo - run: - name: React tests + name: React 19 tests command: make test-react-19 environment: JEST_JUNIT_OUTPUT: test_reports/framer-motion-react-19.xml @@ -115,27 +98,11 @@ jobs: - image: cimg/node:20.11.1-browsers resource_class: large working_directory: ~/repo + environment: + CYPRESS_CACHE_FOLDER: ~/repo/.cache/Cypress steps: - - checkout - - - run: - name: Yarn version - command: yarn --version - - - restore_cache: - keys: - - v3-yarn-{{ checksum "yarn.lock" }} - - v3-yarn- - - run: - name: Install dependencies (immutable) - command: yarn install --immutable - - save_cache: - paths: - - .yarn/cache - - .yarn/releases - - .yarn/plugins - - .yarn/patches - key: v3-yarn-{{ checksum "yarn.lock" }} + - attach_workspace: + at: ~/repo - run: name: HTML tests @@ -152,47 +119,20 @@ jobs: - store_test_results: path: test_reports - test-playwright: - docker: - - image: mcr.microsoft.com/playwright:v1.51.1-noble - resource_class: large - working_directory: ~/repo - steps: - - checkout - - - run: - name: Yarn version - command: yarn --version - - - restore_cache: - keys: - - v3-yarn-{{ checksum "yarn.lock" }} - - v3-yarn- - - run: - name: Install dependencies (immutable) - command: yarn install --immutable - - save_cache: - paths: - - .yarn/cache - - .yarn/releases - - .yarn/plugins - - .yarn/patches - key: v3-yarn-{{ checksum "yarn.lock" }} - - - run: - name: Playwright tests - command: yarn test-playwright - environment: - JEST_JUNIT_OUTPUT: test_reports/framer-motion-playwright.xml - - - store_test_results: - path: test_reports - workflows: version: 2 build: jobs: - - test - - test-react - - test-react-19 - - test-html + - setup + - test: + requires: + - setup + - test-react: + requires: + - setup + - test-react-19: + requires: + - setup + - test-html: + requires: + - setup diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf5e7f851..3fd5279ed7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [12.33.0] 2026-02-05 + +### Added + +- ``: New `propagate.tap` prop prevents tap gestures from propagating to parents. + +## [12.32.0] 2026-02-05 + +### Added + +- `transition.inherit`: When `true`, inherit transition values from less-specific transitions. + +## [12.31.3] 2026-02-05 + +### Fixed + +- ``: Ensure animation state is reset after being re-suspended. +- Prevent stale values when mixing `transitionEnd` and `transition.type: false`. +- Drag: Fix "sticky" throw velocity on initial interaciton. +- Drag: Ensure catching a thrown element kills its velocity. + +## [12.31.2] 2026-02-05 + +### Fixed + +- `onHoverStart` and `onHoverEnd` first argument now correctly typed as `PointerEvent`. +- `whileHover`: No longer persists after drag end. +- `AnimatePresence`: Allow changing `mode` prop. + ## [12.31.1] 2026-02-04 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 744954ab64..6eede4bf4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,8 @@ async function nextFrame() { ## Code Style +- **Prioritise small file size** — this is a library shipped to end users. Prefer concise patterns that minimise output bytes. +- Prefer optional chaining (`value?.jump()`) over explicit `if` statements - Use `interface` for type definitions (enforced by ESLint) - No default exports (use named exports) - Prefer arrow callbacks diff --git a/Makefile b/Makefile index ad1c34dc0a..9305f08cc7 100644 --- a/Makefile +++ b/Makefile @@ -95,7 +95,7 @@ test-e2e: test-nextjs test-html test-react test-react-19 yarn test-playwright test-single: build test-mkdir - yarn start-server-and-test "yarn dev-server" http://localhost:9991 "cd packages/framer-motion && cypress run --config-file=cypress.react.json --headed --spec cypress/integration/drag-nested.ts" + yarn start-server-and-test "yarn dev-server" http://localhost:9990 "cd packages/framer-motion && cypress run --config-file=cypress.json --headed --spec cypress/integration/drag-momentum.ts" lint: bootstrap yarn lint diff --git a/dev/html/package.json b/dev/html/package.json index 80376f9be9..344e112257 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.31.1", + "version": "12.33.0", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.31.1", - "motion": "^12.31.1", - "motion-dom": "^12.30.1", + "framer-motion": "^12.33.0", + "motion": "^12.33.0", + "motion-dom": "^12.33.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/html/public/playwright/gestures/press.html b/dev/html/public/playwright/gestures/press.html index 68ad250c98..ba5369916c 100644 --- a/dev/html/public/playwright/gestures/press.html +++ b/dev/html/public/playwright/gestures/press.html @@ -33,6 +33,10 @@
+
+
child
+
+ diff --git a/dev/next/package.json b/dev/next/package.json index fe8974c42b..438de224b8 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.31.1", + "version": "12.33.0", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.31.1", + "motion": "^12.33.0", "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 3848ec2ad5..54b73dc456 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.31.1", + "version": "12.33.0", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.31.1", + "motion": "^12.33.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index 9689faee8a..39f30c7557 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.31.1", + "version": "12.33.0", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.31.1", + "framer-motion": "^12.33.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/react/src/tests/drag-momentum.tsx b/dev/react/src/tests/drag-momentum.tsx new file mode 100644 index 0000000000..c99e43f2a3 --- /dev/null +++ b/dev/react/src/tests/drag-momentum.tsx @@ -0,0 +1,21 @@ +import { motion } from "framer-motion" + +export const App = () => { + return ( +
+ +
+ ) +} diff --git a/dev/react/src/tests/layout-shared-fragment.tsx b/dev/react/src/tests/layout-shared-fragment.tsx new file mode 100644 index 0000000000..06bc2f5e5d --- /dev/null +++ b/dev/react/src/tests/layout-shared-fragment.tsx @@ -0,0 +1,62 @@ +import { motion } from "framer-motion" +import { Fragment, useState } from "react" + +const box: React.CSSProperties = { + position: "absolute", + left: 0, + background: "red", +} + +const a: React.CSSProperties = { + ...box, + top: 100, + width: 100, + height: 100, +} + +const b: React.CSSProperties = { + ...box, + top: 300, + width: 100, + height: 100, +} + +function A({ onClick }: { onClick: () => void }) { + return ( + + 0.5 }} + /> + + ) +} + +function B({ onClick }: { onClick: () => void }) { + return ( + + 0.5 }} + /> + + ) +} + +export const App = () => { + const [state, setState] = useState(true) + + return state ? ( + setState(false)} /> + ) : ( + setState(true)} /> + ) +} diff --git a/dev/react/src/tests/suspense-animation-resume.tsx b/dev/react/src/tests/suspense-animation-resume.tsx new file mode 100644 index 0000000000..382ac38da4 --- /dev/null +++ b/dev/react/src/tests/suspense-animation-resume.tsx @@ -0,0 +1,62 @@ +import { Suspense, useEffect, useRef, useState } from "react" +import { motion } from "framer-motion" + +/** + * Test component that verifies motion values are reset to initial values + * after a Suspense unmount/remount cycle (issue #2269). + * + * Timeline: + * 0ms - Animation starts (opacity 0 → 1, scale 0.5 → 2) + * 400ms - Component suspends mid-animation + * 900ms - Component resumes, values should reset to initial + */ +const SuspendingChild = () => { + const [promise, setPromise] = useState>(null) + const hasSuspended = useRef(false) + + useEffect(() => { + if (hasSuspended.current) return + + const suspendTimeout = setTimeout(() => { + hasSuspended.current = true + setPromise( + new Promise((resolve) => { + setTimeout(() => { + resolve() + setPromise(null) + }, 500) + }) + ) + }, 400) + + return () => { + clearTimeout(suspendTimeout) + } + }, []) + + if (promise) { + throw promise + } + + return ( + + ) +} + +export function App() { + return ( + Suspended}> + + + ) +} diff --git a/lerna.json b/lerna.json index c5e22ac709..cccd6e05fd 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.31.1", + "version": "12.33.0", "packages": [ "packages/*", "dev/*" diff --git a/package.json b/package.json index fef50eb2c6..acd10bd2d3 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,9 @@ "version": "yarn install && git stage yarn.lock", "prepare": "turbo run build measure", "generate-changelog-csv": "node scripts/generate-changelog.js", - "new": "yarn generate-changelog-csv && yarn test-playwright && lerna publish from-package && yarn notify-slack", + "new": "yarn generate-changelog-csv && yarn test-playwright && lerna publish from-package && yarn notify-slack && yarn push-to-site", "new-alpha": "turbo run build && lerna publish from-package --canary --preid alpha", + "push-to-site": "node scripts/push-to-site.js", "notify-slack": "NODE_ENV=production node scripts/notify-slack.js", "test-notify-slack": "NODE_ENV=development node scripts/notify-slack.js" }, @@ -57,6 +58,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-redos-detector": "^2.4.0", "eslint-plugin-regexp": "^2.2.0", + "framer-api": "^0.1.0", "gsap": "^3.12.5", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", @@ -64,6 +66,7 @@ "jest-watch-typeahead": "^2.2.2", "lerna": "^4.0.0", "lint-staged": "^8.0.4", + "papaparse": "^5.5.3", "path-browserify": "^1.0.1", "prettier": "^2.5.1", "react": "^18.3.1", diff --git a/packages/framer-motion/cypress/integration/drag-momentum.ts b/packages/framer-motion/cypress/integration/drag-momentum.ts new file mode 100644 index 0000000000..5904115a66 --- /dev/null +++ b/packages/framer-motion/cypress/integration/drag-momentum.ts @@ -0,0 +1,63 @@ +describe("Drag Momentum", () => { + it("Fast flick after hold produces momentum", () => { + cy.visit("?test=drag-momentum") + .wait(200) + .get("[data-testid='draggable']") + .wait(200) + .trigger("pointerdown", 25, 900, { force: true }) + .wait(300) // Simulate holding before flick + .trigger("pointermove", 25, 895, { force: true }) // Cross distance threshold + .wait(50) + .trigger("pointermove", 25, 800, { force: true }) // Quick flick upward + .wait(50) + .trigger("pointerup", { force: true }) + .wait(500) // Wait for momentum to carry element + .should(($draggable: any) => { + const draggable = $draggable[0] as HTMLDivElement + const { top } = draggable.getBoundingClientRect() + + // Element should have carried well past the release point + // due to momentum. Without the fix, velocity is diluted by + // the stale pointer-down point and momentum is minimal. + expect(top).to.be.lessThan(-200) + }) + }) + + it("Catch-and-release stops momentum", () => { + cy.visit("?test=drag-momentum") + .wait(200) + .get("[data-testid='draggable']") + .wait(200) + // Perform a drag-and-throw upward + .trigger("pointerdown", 25, 900, { force: true }) + .trigger("pointermove", 25, 895, { force: true }) // Cross distance threshold + .wait(50) + .trigger("pointermove", 25, 700, { force: true }) + .wait(50) + .trigger("pointerup", { force: true }) + // Wait for momentum to start + .wait(100) + // Record position, then catch and release + .then(($draggable: any) => { + const draggable = $draggable[0] as HTMLDivElement + const { top } = draggable.getBoundingClientRect() + $draggable.attr("data-caught-top", Math.round(top)) + }) + .trigger("pointerdown", 25, 500, { force: true }) + .wait(50) + .trigger("pointerup", { force: true }) + .wait(500) // Wait to see if element continues moving + .should(($draggable: any) => { + const draggable = $draggable[0] as HTMLDivElement + const { top } = draggable.getBoundingClientRect() + const caughtTop = parseInt( + $draggable.attr("data-caught-top"), + 10 + ) + + // Element should stay near where it was caught, + // not continue with old momentum. + expect(Math.abs(top - caughtTop)).to.be.lessThan(50) + }) + }) +}) diff --git a/packages/framer-motion/cypress/integration/layout-shared-fragment.ts b/packages/framer-motion/cypress/integration/layout-shared-fragment.ts new file mode 100644 index 0000000000..ec824c3964 --- /dev/null +++ b/packages/framer-motion/cypress/integration/layout-shared-fragment.ts @@ -0,0 +1,41 @@ +interface BoundingBox { + top: number + left: number + width: number + height: number +} + +function expectBbox(element: HTMLElement, expectedBbox: BoundingBox) { + const bbox = element.getBoundingClientRect() + expect(bbox.left).to.equal(expectedBbox.left) + expect(bbox.top).to.equal(expectedBbox.top) + expect(bbox.width).to.equal(expectedBbox.width) + expect(bbox.height).to.equal(expectedBbox.height) +} + +describe("Shared layout: Fragment", () => { + it("Elements with layoutId inside a Fragment should animate from the correct starting position", () => { + cy.visit("?test=layout-shared-fragment") + .wait(50) + .get("#box") + .should(([$box]: any) => { + expectBbox($box, { + top: 100, + left: 0, + width: 100, + height: 100, + }) + }) + .trigger("click") + .wait(200) + .get("#box") + .should(([$box]: any) => { + // At ease: () => 0.5, the element should be halfway + // between top: 100 and top: 300, i.e. top: 200. + // If the bug is present, it will start from top: 0 + // and be at top: 150 instead. + const bbox = $box.getBoundingClientRect() + expect(bbox.top).to.equal(200) + }) + }) +}) diff --git a/packages/framer-motion/cypress/integration/suspense-animation-resume.ts b/packages/framer-motion/cypress/integration/suspense-animation-resume.ts new file mode 100644 index 0000000000..85e394904d --- /dev/null +++ b/packages/framer-motion/cypress/integration/suspense-animation-resume.ts @@ -0,0 +1,39 @@ +/** + * Test that motion values are reset to initial values after a React Suspense + * unmount/remount cycle. Verifies the fix for issue #2269. + * + * Without the fix, scale gets stuck at an intermediate animation value + * and opacity appears incorrectly reset after Suspense remount. + * + * Timeline of the test component: + * 0ms - Animation starts (opacity 0 → 1, scale 0.5 → 2, duration 10s) + * 400ms - Component suspends + * 900ms - Component resumes, values should reset to initial + */ +describe("Animation resume after Suspense", () => { + it("resets values to initial after Suspense remount", () => { + cy.visit("?test=suspense-animation-resume") + .wait(50) + // Element should exist and be animating + .get("#target") + .should("exist") + + // Wait for suspend — fallback should appear + .get("#fallback", { timeout: 2000 }) + .should("exist") + .should("contain", "Suspended") + + // Wait for resume — target should reappear + .get("#target", { timeout: 2000 }) + .should("exist") + .should(([$element]: any) => { + // Right after remount, opacity should be reset to initial (0), + // not stuck at an intermediate value from before suspension. + // Use getComputedStyle since the inline style may not be set yet. + const opacity = parseFloat( + window.getComputedStyle($element).opacity + ) + expect(opacity).to.be.lessThan(0.3) + }) + }) +}) diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index c4394f1d10..59ac460c5d 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.31.1", + "version": "12.33.0", "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.30.1", + "motion-dom": "^12.33.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, diff --git a/packages/framer-motion/src/animation/__tests__/get-value-transition.test.ts b/packages/framer-motion/src/animation/__tests__/get-value-transition.test.ts new file mode 100644 index 0000000000..0365f44a3c --- /dev/null +++ b/packages/framer-motion/src/animation/__tests__/get-value-transition.test.ts @@ -0,0 +1,67 @@ +import { getValueTransition } from "motion-dom" + +describe("getValueTransition", () => { + it("returns value-specific transition as-is without inherit", () => { + const transition = { + duration: 1, + opacity: { duration: 2 }, + } + const result = getValueTransition(transition, "opacity") + expect(result).toEqual({ duration: 2 }) + }) + + it("returns base transition when no value-specific key exists", () => { + const transition = { duration: 1, ease: "easeIn" } + const result = getValueTransition(transition, "opacity") + expect(result).toEqual({ duration: 1, ease: "easeIn" }) + }) + + it("falls back to default key", () => { + const transition = { + duration: 1, + default: { duration: 3 }, + } + const result = getValueTransition(transition, "opacity") + expect(result).toEqual({ duration: 3 }) + }) + + it("merges value-specific with base transition when inherit is true", () => { + const transition = { + duration: 1, + ease: "easeIn" as const, + opacity: { inherit: true, duration: 2 }, + } + const result = getValueTransition(transition, "opacity") + expect(result.duration).toBe(2) + expect(result.ease).toBe("easeIn") + }) + + it("strips inherit key from merged result", () => { + const transition = { + duration: 1, + opacity: { inherit: true, duration: 2 }, + } + const result = getValueTransition(transition, "opacity") + expect(result).not.toHaveProperty("inherit") + }) + + it("inner keys win when merging with inherit", () => { + const transition = { + duration: 1, + ease: "easeIn" as const, + opacity: { inherit: true, duration: 2, ease: "easeOut" as const }, + } + const result = getValueTransition(transition, "opacity") + expect(result.duration).toBe(2) + expect(result.ease).toBe("easeOut") + }) + + it("does not merge when inherit is on the base transition itself (fallback case)", () => { + const transition = { + inherit: true, + duration: 1, + } + const result = getValueTransition(transition, "opacity") + expect(result).toBe(transition) + }) +}) diff --git a/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx b/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx index ba10491929..7b9852c1aa 100644 --- a/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx @@ -22,6 +22,7 @@ interface Props { anchorX?: "left" | "right" anchorY?: "top" | "bottom" root?: HTMLElement | ShadowRoot + pop?: boolean } interface MeasureProps extends Props { @@ -36,7 +37,7 @@ interface MeasureProps extends Props { class PopChildMeasure extends React.Component { getSnapshotBeforeUpdate(prevProps: MeasureProps) { const element = this.props.childRef.current - if (element && prevProps.isPresent && !this.props.isPresent) { + if (element && prevProps.isPresent && !this.props.isPresent && this.props.pop !== false) { const parent = element.offsetParent const parentWidth = isHTMLElement(parent) ? parent.offsetWidth || 0 @@ -67,7 +68,7 @@ class PopChildMeasure extends React.Component { } } -export function PopChild({ children, isPresent, anchorX, anchorY, root }: Props) { +export function PopChild({ children, isPresent, anchorX, anchorY, root, pop }: Props) { const id = useId() const ref = useRef(null) const size = useRef({ @@ -99,7 +100,7 @@ export function PopChild({ children, isPresent, anchorX, anchorY, root }: Props) */ useInsertionEffect(() => { const { width, height, top, left, right, bottom } = size.current - if (isPresent || !ref.current || !width || !height) return + if (isPresent || pop === false || !ref.current || !width || !height) return const x = anchorX === "left" ? `left: ${left}` : `right: ${right}` const y = anchorY === "bottom" ? `bottom: ${bottom}` : `top: ${top}` @@ -132,8 +133,10 @@ export function PopChild({ children, isPresent, anchorX, anchorY, root }: Props) }, [isPresent]) return ( - - {React.cloneElement(children as any, { ref: composedRef })} + + {pop === false + ? children + : React.cloneElement(children as any, { ref: composedRef })} ) } diff --git a/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx b/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx index 5b933bd86f..8befa0690c 100644 --- a/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx @@ -86,13 +86,11 @@ export const PresenceChild = ({ onExitComplete() }, [isPresent]) - if (mode === "popLayout") { - children = ( - - {children} - - ) - } + children = ( + + {children} + + ) return ( 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 00071173fe..130861ebdf 100644 --- a/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx @@ -717,6 +717,58 @@ describe("AnimatePresence", () => { // The bottom position should be preserved (approximately 0) expect(initialBottom).toBeLessThanOrEqual(1) }) + + test("Switching mode from wait to popLayout doesn't break animations", async () => { + const opacity = motionValue(0) + const Component = ({ mode }: { mode: "wait" | "popLayout" }) => ( + + + + ) + + const { rerender } = render() + rerender() + await nextFrame() + + expect(opacity.get()).toBe(1) + + rerender() + rerender() + await nextFrame() + + expect(opacity.get()).toBe(1) + }) + + test("Switching mode from popLayout to wait doesn't break animations", async () => { + const opacity = motionValue(0) + const Component = ({ mode }: { mode: "wait" | "popLayout" }) => ( + + + + ) + + const { rerender } = render() + rerender() + await nextFrame() + + expect(opacity.get()).toBe(1) + + rerender() + rerender() + await nextFrame() + + expect(opacity.get()).toBe(1) + }) }) describe("AnimatePresence with custom components", () => { @@ -1107,6 +1159,7 @@ describe("AnimatePresence with custom components", () => { await new Promise(async (resolve) => { async function complete() { await nextFrame() + await nextFrame() expect(outerOpacity.get()).toBe(0) expect(innerOpacity.get()).toBe(1) diff --git a/packages/framer-motion/src/components/MotionConfig/__tests__/MotionConfig.test.tsx b/packages/framer-motion/src/components/MotionConfig/__tests__/MotionConfig.test.tsx index c6fd10f174..1a351f0f47 100644 --- a/packages/framer-motion/src/components/MotionConfig/__tests__/MotionConfig.test.tsx +++ b/packages/framer-motion/src/components/MotionConfig/__tests__/MotionConfig.test.tsx @@ -1,5 +1,5 @@ import { render } from "@testing-library/react" -import { AnimationGeneratorName } from "motion-dom" +import { AnimationGeneratorName, Transition } from "motion-dom" import { useContext } from "react" import { MotionConfig } from ".." import { MotionConfigContext } from "../../../context/MotionConfigContext" @@ -13,6 +13,15 @@ const Consumer = () => { ) } +const TransitionConsumer = () => { + const value = useContext(MotionConfigContext) + return ( +
+ {JSON.stringify(value.transition)} +
+ ) +} + const App = ({ type }: { type: AnimationGeneratorName }) => ( @@ -31,3 +40,93 @@ it("Passes down transition changes", () => { expect(getByTestId(consumerId).textContent).toBe("tween") }) + +it("Nested MotionConfig without inherit fully replaces parent transition", () => { + const { getByTestId } = render( + + + + + + ) + + const transition: Transition = JSON.parse( + getByTestId(consumerId).textContent! + ) + expect(transition.delay).toBe(0.5) + expect(transition.type).toBeUndefined() + expect(transition.duration).toBeUndefined() +}) + +it("Nested MotionConfig with inherit shallow-merges with parent transition", () => { + const { getByTestId } = render( + + + + + + ) + + const transition: Transition = JSON.parse( + getByTestId(consumerId).textContent! + ) + expect(transition.type).toBe("spring") + expect(transition.duration).toBe(1) + expect(transition.delay).toBe(0.5) +}) + +it("inherit key is stripped from resulting transition", () => { + const { getByTestId } = render( + + + + + + ) + + const transition: Transition = JSON.parse( + getByTestId(consumerId).textContent! + ) + expect(transition).not.toHaveProperty("inherit") +}) + +it("inherit inner keys win over parent keys", () => { + const { getByTestId } = render( + + + + + + ) + + const transition: Transition = JSON.parse( + getByTestId(consumerId).textContent! + ) + expect(transition.type).toBe("spring") + expect(transition.duration).toBe(2) + expect(transition.delay).toBe(0.5) +}) + +it("inherit cascades through deeply nested MotionConfigs", () => { + const { getByTestId } = render( + + + + + + + + ) + + const transition: Transition = JSON.parse( + getByTestId(consumerId).textContent! + ) + expect(transition.type).toBe("spring") + expect(transition.duration).toBe(1) + expect(transition.delay).toBe(0.5) + expect(transition.ease).toBe("easeIn") +}) diff --git a/packages/framer-motion/src/components/MotionConfig/index.tsx b/packages/framer-motion/src/components/MotionConfig/index.tsx index ead6133680..82559d2809 100644 --- a/packages/framer-motion/src/components/MotionConfig/index.tsx +++ b/packages/framer-motion/src/components/MotionConfig/index.tsx @@ -2,6 +2,7 @@ import * as React from "react" import { useContext, useMemo } from "react" +import { resolveTransition } from "motion-dom" import { MotionConfigContext } from "../../context/MotionConfigContext" import { loadExternalIsValidProp, @@ -41,7 +42,13 @@ export function MotionConfig({ /** * Inherit props from any parent MotionConfig components */ - config = { ...useContext(MotionConfigContext), ...config } + const parentConfig = useContext(MotionConfigContext) + config = { ...parentConfig, ...config } + + config.transition = resolveTransition( + config.transition, + parentConfig.transition + ) /** * Don't allow isStatic to change between renders as it affects how many hooks diff --git a/packages/framer-motion/src/context/MotionConfigContext.tsx b/packages/framer-motion/src/context/MotionConfigContext.tsx index a527e48f72..2aa7652d9e 100644 --- a/packages/framer-motion/src/context/MotionConfigContext.tsx +++ b/packages/framer-motion/src/context/MotionConfigContext.tsx @@ -52,6 +52,7 @@ export interface MotionConfigContext { * @public */ skipAnimations?: boolean + } /** diff --git a/packages/framer-motion/src/gestures/__tests__/hover.test.tsx b/packages/framer-motion/src/gestures/__tests__/hover.test.tsx index 4f1fc11641..8c83d2e2a2 100644 --- a/packages/framer-motion/src/gestures/__tests__/hover.test.tsx +++ b/packages/framer-motion/src/gestures/__tests__/hover.test.tsx @@ -1,9 +1,10 @@ -import { motionValue, Variants } from "motion-dom" +import { isDragging, motionValue, Variants } from "motion-dom" import { frame, motion } from "../../" import { pointerDown, pointerEnter, pointerLeave, + pointerUp, render, } from "../../jest.setup" import { nextFrame } from "./utils" @@ -212,6 +213,136 @@ describe("hover", () => { return expect(promise).resolves.toBe(0.9) }) + test("whileHover is unapplied after drag ends when pointer left element during drag", async () => { + const opacity = motionValue(1) + const Component = () => ( + + ) + + const { container } = render() + const element = container.firstChild as Element + + pointerEnter(element) + await nextFrame() + expect(opacity.get()).toBe(0.5) + + // Press and start drag + pointerDown(element) + isDragging.x = true + + // pointerLeave during drag is deferred + pointerLeave(element) + await nextFrame() + expect(opacity.get()).toBe(0.5) + + // End drag, then release pointer + isDragging.x = false + pointerUp(element) + await nextFrame() + expect(opacity.get()).toBe(1) + }) + + test("whileHover remains active when pointer is over element after drag ends", async () => { + const opacity = motionValue(1) + const Component = () => ( + + ) + + const { container } = render() + const element = container.firstChild as Element + + pointerEnter(element) + await nextFrame() + expect(opacity.get()).toBe(0.5) + + // Press and start drag + pointerDown(element) + isDragging.x = true + + // End drag without pointerLeave (pointer still over element) + isDragging.x = false + pointerUp(element) + + await nextFrame() + // Hover should still be active since pointer never left + expect(opacity.get()).toBe(0.5) + }) + + test("whileHover stays active during press and deactivates on release outside element", async () => { + const opacity = motionValue(1) + const Component = () => ( + + ) + + const { container } = render() + const element = container.firstChild as Element + + pointerEnter(element) + await nextFrame() + expect(opacity.get()).toBe(0.5) + + // Press down + pointerDown(element) + + // Pointer leaves while pressed (no drag involved) + pointerLeave(element) + await nextFrame() + // Hover should stay active because pointer is still pressed + expect(opacity.get()).toBe(0.5) + + // Release pointer (outside element) + pointerUp(element) + await nextFrame() + // Now hover should deactivate + expect(opacity.get()).toBe(1) + }) + + test("whileHover stays active during press when pointer leaves before drag starts", async () => { + const opacity = motionValue(1) + const Component = () => ( + + ) + + const { container } = render() + const element = container.firstChild as Element + + pointerEnter(element) + await nextFrame() + expect(opacity.get()).toBe(0.5) + + // Press down (drag hasn't started yet — needs movement threshold) + pointerDown(element) + + // Pointer leaves before drag starts + pointerLeave(element) + await nextFrame() + // Hover should stay active because pointer is pressed + expect(opacity.get()).toBe(0.5) + + // Release pointer + pointerUp(element) + await nextFrame() + // Now hover should deactivate + expect(opacity.get()).toBe(1) + }) + test("whileHover only animates values that aren't being controlled by a higher-priority gesture ", () => { const promise = new Promise(async (resolve) => { const variant = { diff --git a/packages/framer-motion/src/gestures/__tests__/press.test.tsx b/packages/framer-motion/src/gestures/__tests__/press.test.tsx index 144f394e75..77975ad6b5 100644 --- a/packages/framer-motion/src/gestures/__tests__/press.test.tsx +++ b/packages/framer-motion/src/gestures/__tests__/press.test.tsx @@ -767,6 +767,130 @@ describe("press", () => { ]) }) + test("propagate={{ tap: false }} prevents parent onTap from firing", async () => { + const parentTap = jest.fn() + const childTap = jest.fn() + const Component = () => ( + parentTap()}> + childTap()} + propagate={{ tap: false }} + /> + + ) + + const { getByTestId, rerender } = render() + rerender() + + pointerDown(getByTestId("child")) + pointerUp(getByTestId("child")) + await nextFrame() + + expect(childTap).toBeCalledTimes(1) + expect(parentTap).toBeCalledTimes(0) + }) + + test("without propagate both parent and child onTap fire", async () => { + const parentTap = jest.fn() + const childTap = jest.fn() + const Component = () => ( + parentTap()}> + childTap()} + /> + + ) + + const { getByTestId, rerender } = render() + rerender() + + pointerDown(getByTestId("child")) + pointerUp(getByTestId("child")) + await nextFrame() + + expect(childTap).toBeCalledTimes(1) + expect(parentTap).toBeCalledTimes(1) + }) + + test("propagate={{ tap: false }} isolates whileTap to child only", () => { + const promise = new Promise(async (resolve) => { + const parentOpacityHistory: number[] = [] + const childOpacityHistory: number[] = [] + const parentOpacity = motionValue(0.5) + const childOpacity = motionValue(0.5) + const logOpacities = () => { + parentOpacityHistory.push(parentOpacity.get()) + childOpacityHistory.push(childOpacity.get()) + } + const Component = () => ( + + + + ) + + const { getByTestId } = render() + await nextFrame() + logOpacities() // both 0.5 + + pointerDown(getByTestId("child")) + await nextFrame() + logOpacities() // child 1, parent 0.5 + + pointerUp(getByTestId("child")) + await nextFrame() + logOpacities() // both 0.5 + + resolve({ parentOpacityHistory, childOpacityHistory }) + }) + + return expect(promise).resolves.toEqual({ + parentOpacityHistory: [0.5, 0.5, 0.5], + childOpacityHistory: [0.5, 1, 0.5], + }) + }) + + test("propagate={{ tap: false }} prevents all ancestor onTap handlers (three levels)", async () => { + const grandparentTap = jest.fn() + const parentTap = jest.fn() + const childTap = jest.fn() + const Component = () => ( + grandparentTap()}> + parentTap()}> + childTap()} + propagate={{ tap: false }} + /> + + + ) + + const { getByTestId, rerender } = render() + rerender() + + pointerDown(getByTestId("child")) + pointerUp(getByTestId("child")) + await nextFrame() + + expect(childTap).toBeCalledTimes(1) + expect(parentTap).toBeCalledTimes(0) + expect(grandparentTap).toBeCalledTimes(0) + }) + test("ignore press event when button is disabled", async () => { const press = jest.fn() const Component = () => press()} disabled /> diff --git a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts index 53b0284dca..749cc1b6f0 100644 --- a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts +++ b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts @@ -108,22 +108,13 @@ export class VisualElementDragControls { if (presenceContext && presenceContext.isPresent === false) return const onSessionStart = (event: PointerEvent) => { - // Stop or pause animations based on context: - // - snapToCursor: stop because we'll set new position values - // - otherwise: pause to allow resume if no drag starts (for constraint animations) if (snapToCursor) { - this.stopAnimation() this.snapToCursor(extractEventInfo(event).point) - } else { - this.pauseAnimation() } + this.stopAnimation() } const onStart = (event: PointerEvent, info: PanInfo) => { - // Stop any paused animation so motion values reflect true current position - // (pauseAnimation was called in onSessionStart to allow resume if no drag started) - this.stopAnimation() - // Attempt to grab the global drag gesture lock - maybe make this part of PanSession const { drag, dragPropagation, onDragStart } = this.getProps() @@ -243,12 +234,12 @@ export class VisualElementDragControls { this.latestPanInfo = null } - const resumeAnimation = () => - eachAxis( - (axis) => - this.getAnimationState(axis) === "paused" && - this.getAxisMotionValue(axis).animation?.play() - ) + const resumeAnimation = () => { + const { dragSnapToOrigin: snap } = this.getProps() + if (snap || this.constraints) { + this.startAnimation({ x: 0, y: 0 }) + } + } const { dragSnapToOrigin } = this.getProps() this.panSession = new PanSession( @@ -525,14 +516,6 @@ export class VisualElementDragControls { eachAxis((axis) => this.getAxisMotionValue(axis).stop()) } - private pauseAnimation() { - eachAxis((axis) => this.getAxisMotionValue(axis).animation?.pause()) - } - - private getAnimationState(axis: DragDirection) { - return this.getAxisMotionValue(axis).animation?.state - } - /** * Drag works differently depending on which props are provided. * diff --git a/packages/framer-motion/src/gestures/pan/PanSession.ts b/packages/framer-motion/src/gestures/pan/PanSession.ts index e9b01d8320..006b7c065f 100644 --- a/packages/framer-motion/src/gestures/pan/PanSession.ts +++ b/packages/framer-motion/src/gestures/pan/PanSession.ts @@ -382,6 +382,22 @@ function getVelocity(history: TimestampedPoint[], timeDelta: number): Point { return { x: 0, y: 0 } } + /** + * If the selected point is the pointer-down origin (history[0]), + * there are better movement points available, and the time gap + * is suspiciously large (>2x timeDelta), use the next point instead. + * This prevents stale pointer-down points from diluting velocity + * in hold-then-flick gestures. + */ + if ( + timestampedPoint === history[0] && + history.length > 2 && + lastPoint.timestamp - timestampedPoint.timestamp > + secondsToMilliseconds(timeDelta) * 2 + ) { + timestampedPoint = history[1] + } + const time = millisecondsToSeconds( lastPoint.timestamp - timestampedPoint.timestamp ) diff --git a/packages/framer-motion/src/gestures/press.ts b/packages/framer-motion/src/gestures/press.ts index e5d2650896..f75f7b8a2f 100644 --- a/packages/framer-motion/src/gestures/press.ts +++ b/packages/framer-motion/src/gestures/press.ts @@ -32,6 +32,8 @@ export class PressGesture extends Feature { const { current } = this.node if (!current) return + const { globalTapTarget, propagate } = this.node.props + this.unmount = press( current, (_element, startEvent) => { @@ -44,7 +46,10 @@ export class PressGesture extends Feature { success ? "End" : "Cancel" ) }, - { useGlobalTarget: this.node.props.globalTapTarget } + { + useGlobalTarget: globalTapTarget, + stopPropagation: propagate?.tap === false, + } ) } diff --git a/packages/framer-motion/src/motion/__tests__/animate-prop.test.tsx b/packages/framer-motion/src/motion/__tests__/animate-prop.test.tsx index 2f976ba2b4..a653144fb9 100644 --- a/packages/framer-motion/src/motion/__tests__/animate-prop.test.tsx +++ b/packages/framer-motion/src/motion/__tests__/animate-prop.test.tsx @@ -1,4 +1,4 @@ -import { createRef, useRef } from "react" +import { createRef, Suspense, useRef, useState } from "react" import { frame, motion, @@ -9,6 +9,7 @@ import { } from "../../" import { nextFrame } from "../../gestures/__tests__/utils" import { render } from "../../jest.setup" +import { act } from "react" describe("animate prop as object", () => { test("animates to set prop", async () => { @@ -1308,4 +1309,66 @@ describe("animate prop as object", () => { return expect(result).toBe(true) }) + + test("Resets motion values to initial after Suspense remount", async () => { + const opacity = motionValue(1) + const scale = motionValue(1) + + let triggerSuspense: () => void + let resolveSuspense: () => void + + // A component that suspends when triggered + const SuspendingChild = () => { + const [suspended, setSuspended] = useState(false) + triggerSuspense = () => setSuspended(true) + + if (suspended) { + throw new Promise((resolve) => { + resolveSuspense = () => { + setSuspended(false) + resolve() + } + }) + } + + return ( + + ) + } + + const Component = () => ( + Loading...}> + + + ) + + const { rerender } = render() + rerender() + + // Wait for initial animation to progress + await act(async () => { + await nextFrame() + await nextFrame() + }) + + // Trigger suspension mid-animation + await act(async () => { + triggerSuspense!() + }) + + // Resolve suspension to remount + await act(async () => { + resolveSuspense!() + }) + + // After remount, values should be reset to initial (not stuck at + // intermediate animation values) + expect(opacity.get()).toBe(0) + expect(scale.get()).toBe(0) + }) }) diff --git a/packages/framer-motion/src/motion/__tests__/variant.test.tsx b/packages/framer-motion/src/motion/__tests__/variant.test.tsx index 8c454f3848..efe019db9a 100644 --- a/packages/framer-motion/src/motion/__tests__/variant.test.tsx +++ b/packages/framer-motion/src/motion/__tests__/variant.test.tsx @@ -1380,6 +1380,63 @@ describe("animate prop as variant", () => { expect(element).toHaveStyle("transform: translateX(100px)") }) + test("transitionEnd from instant animation does not override subsequent variant", async () => { + /** + * This test targets the race condition from + * https://github.com/motiondivision/motion/issues/1668 + * + * When using type: false (instant transitions), the animation + * completion is deferred to frame.update() but returns no + * animation object. When a new variant switch happens before + * that frame.update fires, the old transitionEnd can override + * the new variant's values because there's no animation object + * to cancel. + */ + const Component = ({ variant }: { variant: string }) => ( + + ) + + const { getByTestId, rerender } = render( + + ) + const element = getByTestId("target") + + await nextFrame() + + // Switch to "on" - with type:false, animation completes instantly + // but onComplete is deferred to frame.update + rerender() + rerender() + + // Switch to "off" BEFORE the frame fires - the "on" variant's + // transitionEnd (display: "flex") should NOT override "off"'s + // display: "none" + rerender() + rerender() + + await nextFrame() + await nextFrame() + + expect(element).toHaveStyle("display: none") + }) + test("staggerChildren is calculated correctly for new children", async () => { const Component = ({ items }: { items: string[] }) => { return ( diff --git a/packages/framer-motion/src/motion/utils/valid-prop.ts b/packages/framer-motion/src/motion/utils/valid-prop.ts index 1c9a8ecf87..515942d845 100644 --- a/packages/framer-motion/src/motion/utils/valid-prop.ts +++ b/packages/framer-motion/src/motion/utils/valid-prop.ts @@ -35,6 +35,7 @@ const validMotionProps = new Set([ "onViewportEnter", "onViewportLeave", "globalTapTarget", + "propagate", "ignoreStrict", "viewport", ]) diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index 429f92a9b2..1cf9ede8ba 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.30.1", + "version": "12.33.0", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion-dom/src/animation/interfaces/visual-element-target.ts b/packages/motion-dom/src/animation/interfaces/visual-element-target.ts index d69a647514..d130f0705f 100644 --- a/packages/motion-dom/src/animation/interfaces/visual-element-target.ts +++ b/packages/motion-dom/src/animation/interfaces/visual-element-target.ts @@ -1,5 +1,6 @@ import { frame } from "../../frameloop" import { getValueTransition } from "../utils/get-value-transition" +import { resolveTransition } from "../utils/resolve-transition" import { positionalKeys } from "../../render/utils/keys-position" import { setTarget } from "../../render/utils/setters" import { addValueToWillChange } from "../../value/will-change/add-will-change" @@ -34,11 +35,16 @@ export function animateTarget( { delay = 0, transitionOverride, type }: VisualElementAnimationOptions = {} ): AnimationPlaybackControlsWithThen[] { let { - transition = visualElement.getDefaultTransition(), + transition, transitionEnd, ...target } = targetAndTransition + const defaultTransition = visualElement.getDefaultTransition() + transition = transition + ? resolveTransition(transition, defaultTransition) + : defaultTransition + const reduceMotion = (transition as { reduceMotion?: boolean })?.reduceMotion if (transitionOverride) transition = transitionOverride @@ -132,11 +138,16 @@ export function animateTarget( } if (transitionEnd) { - Promise.all(animations).then(() => { + const applyTransitionEnd = () => frame.update(() => { transitionEnd && setTarget(visualElement, transitionEnd) }) - }) + + if (animations.length) { + Promise.all(animations).then(applyTransitionEnd) + } else { + applyTransitionEnd() + } } return animations diff --git a/packages/motion-dom/src/animation/types.ts b/packages/motion-dom/src/animation/types.ts index e32b2ad066..23d4040dae 100644 --- a/packages/motion-dom/src/animation/types.ts +++ b/packages/motion-dom/src/animation/types.ts @@ -468,6 +468,14 @@ export interface ValueTransition // @deprecated from?: any + + /** + * If true, this transition will shallow-merge with its parent transition + * instead of replacing it. Inner keys win. + * + * @public + */ + inherit?: boolean } /** diff --git a/packages/motion-dom/src/animation/utils/get-value-transition.ts b/packages/motion-dom/src/animation/utils/get-value-transition.ts index e2381f9e2b..4ea472de54 100644 --- a/packages/motion-dom/src/animation/utils/get-value-transition.ts +++ b/packages/motion-dom/src/animation/utils/get-value-transition.ts @@ -1,7 +1,14 @@ +import { resolveTransition } from "./resolve-transition" + export function getValueTransition(transition: any, key: string) { - return ( + const valueTransition = transition?.[key as keyof typeof transition] ?? transition?.["default"] ?? transition - ) + + if (valueTransition !== transition) { + return resolveTransition(valueTransition, transition) + } + + return valueTransition } diff --git a/packages/motion-dom/src/animation/utils/resolve-transition.ts b/packages/motion-dom/src/animation/utils/resolve-transition.ts new file mode 100644 index 0000000000..d4537f4ffb --- /dev/null +++ b/packages/motion-dom/src/animation/utils/resolve-transition.ts @@ -0,0 +1,16 @@ +/** + * If `transition` has `inherit: true`, shallow-merge it with + * `parentTransition` (child keys win) and strip the `inherit` key. + * Otherwise return `transition` unchanged. + */ +export function resolveTransition( + transition: any, + parentTransition?: any +) { + if (transition?.inherit && parentTransition) { + const { inherit: _, ...rest } = transition + return { ...parentTransition, ...rest } + } + + return transition +} diff --git a/packages/motion-dom/src/gestures/hover.ts b/packages/motion-dom/src/gestures/hover.ts index 32d2240c68..ebaeead7fe 100644 --- a/packages/motion-dom/src/gestures/hover.ts +++ b/packages/motion-dom/src/gestures/hover.ts @@ -44,37 +44,99 @@ export function hover( options ) - const onPointerEnter = (enterEvent: PointerEvent) => { - if (!isValidHover(enterEvent)) return + elements.forEach((element) => { + let isPressed = false + let deferredHoverEnd = false + let hoverEndCallback: OnHoverEndEvent | undefined + + const removePointerLeave = () => { + element.removeEventListener( + "pointerleave", + onPointerLeave as EventListener + ) + } + + const endHover = (event: PointerEvent) => { + if (hoverEndCallback) { + hoverEndCallback(event) + hoverEndCallback = undefined + } + removePointerLeave() + } - const { target } = enterEvent - const onHoverEnd = onHoverStart(target as Element, enterEvent) + const onPointerUp = (event: Event) => { + isPressed = false + window.removeEventListener( + "pointerup", + onPointerUp as EventListener + ) + window.removeEventListener( + "pointercancel", + onPointerUp as EventListener + ) + + if (deferredHoverEnd) { + deferredHoverEnd = false + endHover(event as PointerEvent) + } + } - if (typeof onHoverEnd !== "function" || !target) return + const onPointerDown = () => { + isPressed = true + window.addEventListener( + "pointerup", + onPointerUp as EventListener, + eventOptions + ) + window.addEventListener( + "pointercancel", + onPointerUp as EventListener, + eventOptions + ) + } const onPointerLeave = (leaveEvent: PointerEvent) => { - if (!isValidHover(leaveEvent)) return + if (leaveEvent.pointerType === "touch") return + + if (isPressed) { + deferredHoverEnd = true + return + } - onHoverEnd(leaveEvent) - target.removeEventListener( + endHover(leaveEvent) + } + + const onPointerEnter = (enterEvent: PointerEvent) => { + if (!isValidHover(enterEvent)) return + + deferredHoverEnd = false + + const onHoverEnd = onHoverStart( + element as Element, + enterEvent + ) + + if (typeof onHoverEnd !== "function") return + + hoverEndCallback = onHoverEnd + + element.addEventListener( "pointerleave", - onPointerLeave as EventListener + onPointerLeave as EventListener, + eventOptions ) } - target.addEventListener( - "pointerleave", - onPointerLeave as EventListener, - eventOptions - ) - } - - elements.forEach((element) => { element.addEventListener( "pointerenter", onPointerEnter as EventListener, eventOptions ) + element.addEventListener( + "pointerdown", + onPointerDown as EventListener, + eventOptions + ) }) return cancel diff --git a/packages/motion-dom/src/gestures/press/index.ts b/packages/motion-dom/src/gestures/press/index.ts index ecc6f9805b..d82bc3565d 100644 --- a/packages/motion-dom/src/gestures/press/index.ts +++ b/packages/motion-dom/src/gestures/press/index.ts @@ -18,8 +18,11 @@ function isValidPressEvent(event: PointerEvent) { return isPrimaryPointer(event) && !isDragActive() } +const claimedPointerDownEvents = new WeakSet() + export interface PointerEventOptions extends EventOptions { useGlobalTarget?: boolean + stopPropagation?: boolean } /** @@ -55,9 +58,14 @@ export function press( const target = startEvent.currentTarget as Element if (!isValidPressEvent(startEvent)) return + if (claimedPointerDownEvents.has(startEvent)) return isPressing.add(target) + if (options.stopPropagation) { + claimedPointerDownEvents.add(startEvent) + } + const onPressEnd = onPressStart(target, startEvent) const onPointerEnd = (endEvent: PointerEvent, success: boolean) => { diff --git a/packages/motion-dom/src/index.ts b/packages/motion-dom/src/index.ts index 9ca4a3159c..ef01fca0d8 100644 --- a/packages/motion-dom/src/index.ts +++ b/packages/motion-dom/src/index.ts @@ -12,6 +12,7 @@ export * from "./animation/utils/css-variables-conversion" export { getDefaultTransition } from "./animation/utils/default-transitions" export { getFinalKeyframe } from "./animation/utils/get-final-keyframe" export * from "./animation/utils/get-value-transition" +export * from "./animation/utils/resolve-transition" export * from "./animation/utils/is-css-variable" export { isTransitionDefined } from "./animation/utils/is-transition-defined" export * from "./animation/utils/make-animation-instant" diff --git a/packages/motion-dom/src/node/types.ts b/packages/motion-dom/src/node/types.ts index d5aa01b977..b67557cc74 100644 --- a/packages/motion-dom/src/node/types.ts +++ b/packages/motion-dom/src/node/types.ts @@ -424,7 +424,7 @@ export interface MotionNodeHoverHandlers { * console.log('Hover starts')} /> * ``` */ - onHoverStart?(event: MouseEvent, info: EventInfo): void + onHoverStart?(event: PointerEvent, info: EventInfo): void /** * Callback function that fires when pointer stops hovering over the component. @@ -433,7 +433,7 @@ export interface MotionNodeHoverHandlers { * console.log("Hover ends")} /> * ``` */ - onHoverEnd?(event: MouseEvent, info: EventInfo): void + onHoverEnd?(event: PointerEvent, info: EventInfo): void } /** @@ -538,6 +538,7 @@ export interface MotionNodeTapHandlers { * Note: This is not supported publically. */ globalTapTarget?: boolean + } /** @@ -1043,6 +1044,15 @@ export interface MotionNodeAdvancedOptions { "data-framer-appear-id"?: string } +export interface PropagateOptions { + /** + * If `false`, this element's tap gesture will prevent any parent + * element's tap gesture handlers (`onTap`, `onTapStart`, `whileTap`) + * from firing. Defaults to `true`. + */ + tap?: boolean +} + export interface MotionNodeOptions extends MotionNodeAnimationOptions, MotionNodeEventOptions, @@ -1054,4 +1064,17 @@ export interface MotionNodeOptions MotionNodeDragHandlers, MotionNodeDraggableOptions, MotionNodeLayoutOptions, - MotionNodeAdvancedOptions {} + MotionNodeAdvancedOptions { + /** + * Controls whether gesture events propagate to parent motion components. + * By default all gestures propagate. Set individual gestures to `false` + * to prevent parent handlers from firing. + * + * ```jsx + * + * + * + * ``` + */ + propagate?: PropagateOptions +} diff --git a/packages/motion-dom/src/render/VisualElement.ts b/packages/motion-dom/src/render/VisualElement.ts index deb8877ff8..9d27835627 100644 --- a/packages/motion-dom/src/render/VisualElement.ts +++ b/packages/motion-dom/src/render/VisualElement.ts @@ -355,6 +355,12 @@ export abstract class VisualElement< */ private initialValues: ResolvedValues + /** + * Track whether this element has been mounted before, to detect + * remounts after Suspense unmount/remount cycles. + */ + private hasBeenMounted = false + /** * An object containing a SubscriptionManager for each active event. */ @@ -428,6 +434,18 @@ export abstract class VisualElement< } mount(instance: Instance) { + /** + * If this element has been mounted before (e.g. after a Suspense + * unmount/remount), reset motion values to their initial state + * so animations replay correctly from initial → animate. + */ + if (this.hasBeenMounted) { + for (const key in this.initialValues) { + this.values.get(key)?.jump(this.initialValues[key]) + this.latestValues[key] = this.initialValues[key] + } + } + this.current = instance visualElementStore.set(instance, this) @@ -474,6 +492,8 @@ export abstract class VisualElement< this.parent?.addChild(this) this.update(this.props, this.presenceContext) + + this.hasBeenMounted = true } unmount() { diff --git a/packages/motion/package.json b/packages/motion/package.json index 8ec9a4c206..3db1446f2e 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.31.1", + "version": "12.33.0", "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.31.1", + "framer-motion": "^12.33.0", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/scripts/push-to-site.js b/scripts/push-to-site.js new file mode 100644 index 0000000000..aed1f76cb2 --- /dev/null +++ b/scripts/push-to-site.js @@ -0,0 +1,160 @@ +#!/usr/bin/env node + +// Load environment variables from .env file +require("dotenv").config() + +const fs = require("fs") +const path = require("path") +const Papa = require("papaparse") + +async function pushToSite() { + try { + const projectId = process.env.FRAMER_PROJECT_ID + if (!projectId) { + throw new Error( + "FRAMER_PROJECT_ID environment variable is required" + ) + } + + // Parse changelog.csv + const csvPath = path.join(__dirname, "..", "changelog.csv") + if (!fs.existsSync(csvPath)) { + throw new Error(`changelog.csv not found at ${csvPath}`) + } + + const csvContent = fs.readFileSync(csvPath, "utf8") + const { data: rows } = Papa.parse(csvContent, { + header: true, + skipEmptyLines: true, + transformHeader: (header) => header.trim(), + transform: (value) => value.trim(), + }) + + console.log(`📄 Parsed ${rows.length} entries from changelog.csv`) + + // Dynamic import for ESM-only framer-api + const { connect } = await import("framer-api") + + console.log(`🔗 Connecting to Framer...`) + const framer = await connect(projectId) + + try { + // Find the "Changelog" collection + const collections = await framer.getCollections() + const collection = collections.find( + (c) => c.name === "Changelog" + ) + + if (!collection) { + throw new Error( + 'Collection "Changelog" not found. Please create it in Framer first.' + ) + } + + console.log(`📦 Found "Changelog" collection`) + + // Map field names → field metadata + const fields = await collection.getFields() + const fieldNameToId = new Map( + fields.map((f) => [f.name.toLowerCase(), f.id]) + ) + + // For enum fields, build a case name → case ID map + const enumCaseMaps = new Map() + for (const field of fields) { + if (field.type === "enum") { + const caseMap = new Map( + field.cases.map((c) => [c.name.toLowerCase(), c.id]) + ) + enumCaseMaps.set(field.id, caseMap) + } + } + + // Collect existing slugs + const existingItems = await collection.getItems() + const existingSlugs = new Set( + existingItems.map((item) => item.slug) + ) + + console.log(`📋 ${existingItems.length} existing items in collection`) + + // Filter to only new entries + const newRows = rows.filter((row) => !existingSlugs.has(row.slug)) + + if (newRows.length === 0) { + console.log(`✅ No new entries to add`) + } else { + // Build items + const newItems = newRows.map((row) => { + const fieldData = {} + + const versionFieldId = fieldNameToId.get("version") + if (versionFieldId) { + fieldData[versionFieldId] = { + type: "string", + value: row.version, + } + } + + const dateFieldId = fieldNameToId.get("date") + if (dateFieldId) { + fieldData[dateFieldId] = { + type: "date", + value: row.date, + } + } + + const contentFieldId = fieldNameToId.get("content") + if (contentFieldId) { + fieldData[contentFieldId] = { + type: "formattedText", + value: row.content, + contentType: "markdown", + } + } + + const typeFieldId = fieldNameToId.get("type") + if (typeFieldId) { + const caseMap = enumCaseMaps.get(typeFieldId) + const caseId = caseMap?.get(row.type?.toLowerCase()) + if (caseId) { + fieldData[typeFieldId] = { + type: "enum", + value: caseId, + } + } + } + + return { slug: row.slug, fieldData } + }) + + await collection.addItems(newItems) + console.log(`✅ Added ${newItems.length} new entries`) + } + + // Publish the site + console.log(`🚀 Publishing site...`) + const result = await framer.publish() + console.log(`✅ Site published`) + + if (result?.hostnames?.length > 0) { + const primary = result.hostnames.find((h) => h.isPrimary) + if (primary) { + console.log(` URL: https://${primary.hostname}`) + } + } + } finally { + await framer.disconnect() + } + } catch (error) { + console.error(`❌ Error pushing to site:`, error.message) + process.exit(1) + } +} + +// Run the script +if (require.main === module) { + pushToSite() +} + +module.exports = { pushToSite } diff --git a/tests/gestures/press.spec.ts b/tests/gestures/press.spec.ts index 25a8b2b98d..b54e042eb9 100644 --- a/tests/gestures/press.spec.ts +++ b/tests/gestures/press.spec.ts @@ -245,6 +245,30 @@ test.describe("press events", () => { // await expect(windowOutput).toHaveValue("cancel") }) + test("stopPropagation prevents parent press from firing", async ({ + page, + }) => { + const child = page.locator("#propagate-child") + const output = page.locator("#propagate-output") + + // Press child - only child handlers should fire + await child.dispatchEvent("pointerdown", pointerOptions) + await child.dispatchEvent("pointerup", pointerOptions) + await expect(output).toHaveValue("child-start,child-end,") + }) + + test("parent press fires when clicking outside child", async ({ + page, + }) => { + const parent = page.locator("#propagate-parent") + const output = page.locator("#propagate-output") + + // Press parent directly - parent handlers should fire + await parent.dispatchEvent("pointerdown", pointerOptions) + await parent.dispatchEvent("pointerup", pointerOptions) + await expect(output).toHaveValue("parent-start,parent-end,") + }) + test("nested click handlers", async ({ page }) => { const button = page.locator("#press-click-button") const box = await button.boundingBox() diff --git a/yarn.lock b/yarn.lock index 0c72a6a06f..60d07fcb72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6082,6 +6082,13 @@ __metadata: languageName: node linkType: hard +"devalue@npm:^5.6.2": + version: 5.6.2 + resolution: "devalue@npm:5.6.2" + checksum: 9d031092e3a6ff3a98820261375826ae88846c006857713816b7c1c007224982bdf94abc93b0f33218baff40f4b9d635001d1dfef0860a9e4074d8b7eac4f476 + languageName: node + linkType: hard + "dezalgo@npm:^1.0.0": version: 1.0.4 resolution: "dezalgo@npm:1.0.4" @@ -7420,14 +7427,24 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.31.1, framer-motion@workspace:packages/framer-motion": +"framer-api@npm:^0.1.0": + version: 0.1.0 + resolution: "framer-api@npm:0.1.0" + dependencies: + devalue: ^5.6.2 + std-env: ^3.10.0 + checksum: 8224594208a42d5c52ea164a649b9ad8a8eb6608a4a1840f6a4b44badcda3facbffdb301ee66e2df4ebacd98bbe04c867a0c1b9bf5f6ae30e23ccae127d03003 + languageName: node + linkType: hard + +"framer-motion@^12.33.0, 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.30.1 + motion-dom: ^12.33.0 motion-utils: ^12.29.2 three: 0.137.0 tslib: ^2.4.0 @@ -8192,9 +8209,9 @@ __metadata: version: 0.0.0-use.local resolution: "html-env@workspace:dev/html" dependencies: - framer-motion: ^12.31.1 - motion: ^12.31.1 - motion-dom: ^12.30.1 + framer-motion: ^12.33.0 + motion: ^12.33.0 + motion-dom: ^12.33.0 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 @@ -10936,7 +10953,7 @@ __metadata: languageName: node linkType: hard -"motion-dom@^12.30.1, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.33.0, motion-dom@workspace:packages/motion-dom": version: 0.0.0-use.local resolution: "motion-dom@workspace:packages/motion-dom" dependencies: @@ -10978,6 +10995,7 @@ __metadata: eslint-plugin-react-hooks: ^4.6.0 eslint-plugin-redos-detector: ^2.4.0 eslint-plugin-regexp: ^2.2.0 + framer-api: ^0.1.0 gsap: ^3.12.5 jest: ^29.7.0 jest-environment-jsdom: ^29.7.0 @@ -10985,6 +11003,7 @@ __metadata: jest-watch-typeahead: ^2.2.2 lerna: ^4.0.0 lint-staged: ^8.0.4 + papaparse: ^5.5.3 path-browserify: ^1.0.1 prettier: ^2.5.1 react: ^18.3.1 @@ -11013,11 +11032,11 @@ __metadata: languageName: unknown linkType: soft -"motion@^12.31.1, motion@workspace:packages/motion": +"motion@^12.33.0, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.31.1 + framer-motion: ^12.33.0 tslib: ^2.4.0 peerDependencies: "@emotion/is-prop-valid": "*" @@ -11134,7 +11153,7 @@ __metadata: version: 0.0.0-use.local resolution: "next-env@workspace:dev/next" dependencies: - motion: ^12.31.1 + motion: ^12.33.0 next: 15.4.10 react: 19.0.0 react-dom: 19.0.0 @@ -11987,6 +12006,13 @@ __metadata: languageName: node linkType: hard +"papaparse@npm:^5.5.3": + version: 5.5.3 + resolution: "papaparse@npm:5.5.3" + checksum: 369d68a16340e5fad95d411a0efca34bedbf93550744e6374fa9b60aaf6bc655e29a6d1a39a56afea0cf7dbc4454fd190f50a9ad76db80987b43d6c6c319f018 + languageName: node + linkType: hard + "parent-module@npm:^1.0.0": version: 1.0.1 resolution: "parent-module@npm:1.0.1" @@ -12599,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.31.1 + motion: ^12.33.0 react: ^19.0.0 react-dom: ^19.0.0 vite: ^5.2.0 @@ -12683,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.31.1 + framer-motion: ^12.33.0 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 @@ -14345,6 +14371,13 @@ __metadata: languageName: node linkType: hard +"std-env@npm:^3.10.0": + version: 3.10.0 + resolution: "std-env@npm:3.10.0" + checksum: 51d641b36b0fae494a546fb8446d39a837957fbf902c765c62bd12af8e50682d141c4087ca032f1192fa90330c4f6ff23fd6c9795324efacd1684e814471e0e0 + languageName: node + linkType: hard + "stop-iteration-iterator@npm:^1.0.0, stop-iteration-iterator@npm:^1.1.0": version: 1.1.0 resolution: "stop-iteration-iterator@npm:1.1.0"