diff --git a/.gitignore b/.gitignore index b7a4be1d7f..08d94cd652 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ yarn-error.log !.yarn/versions .turbo .next +*.tsbuildinfo stats.html lerna-debug.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 1448129559..edd552574a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [12.39.0] 2026-05-18 + +### Added + +- Support for `repeatType` and `repeatDelay` in animation sequences. + +### Fixed + +- Variants: Re-run keyframe animations when switching between variant labels even when they share identical keyframe arrays. +- Drag: Preserve in-flight motion value animations across React 19 reorder unmount/remount so `dragSnapToOrigin` no longer leaves the drag transform stranded after a layout swap. +- `LazyMotion`: Share React contexts between the `framer-motion` and `framer-motion/m` (and therefore `motion/react` and `motion/react-m`) CJS bundles so that `` from the `/m` subpath picks up features loaded by `` from the main entry point. +- `useScroll`: Support hydrating `target` and `container` refs from anywhere in the tree. +- Drag: Gesture no longer starts from incorrect start point when rendered inside ``. +- Drag: `dragConstraints`, when set as viewport-relative ref, no longer break on scroll.§ +- Updated `visualElement` hydration order. +- `useAnimate`: Now respects `skipAnimations`. +- `AnimatePresence`: Fix object-form `initial` values not applied on re-entry after exit completes. +- `scroll`: Fixed callback progress when tracking an element. +- `useScroll`: Fix hardware acceleration when tracking an element. + ## [12.38.0] 2026-03-16 ### Added diff --git a/README.md b/README.md index 290b75f225..75a0bc9c4c 100644 --- a/README.md +++ b/README.md @@ -125,12 +125,10 @@ Motion drives the animations on the Cursor homepage, and is working with Cursor ### Platinum -Linear Figma Sanity Sanity Clerk Greptile +Linear Figma Sanity Sanity Clerk ### Gold -Mintlify - ### Silver Liveblocks Frontend.fyi Firecrawl Puzzmo Bolt.new diff --git a/dev/html/package.json b/dev/html/package.json index 7180ee92bf..b7cfad71e4 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.38.0", + "version": "12.40.0", "type": "module", "scripts": { "dev": "vite", @@ -10,13 +10,13 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.38.0", - "motion": "^12.38.0", - "motion-dom": "^12.38.0", + "framer-motion": "^12.40.0", + "motion": "^12.40.0", + "motion-dom": "^12.40.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { - "vite": "^5.2.0" + "vite": "^6.4.2" } } diff --git a/dev/next/app/arcs/page.tsx b/dev/next/app/arcs/page.tsx new file mode 100644 index 0000000000..0841b2f77c --- /dev/null +++ b/dev/next/app/arcs/page.tsx @@ -0,0 +1,46 @@ +"use client" +import { arc, motion, useAnimate } from "motion/react" +import { useEffect } from "react" + +export default function Page() { + return ( +
+ + +
+ ) +} + +function Keyframe() { + return ( + + ) +} + +function UseAnimateExample() { + const [scope, animate] = useAnimate() + + useEffect(() => { + animate( + scope.current, + { x: 200, y: 100 }, + { duration: 1.5, path: arc({ strength: 1 }) } + ) + }, [animate, scope]) + + return ( +
+ ) +} diff --git a/dev/next/package.json b/dev/next/package.json index e327bcc1ca..38c10641e3 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.38.0", + "version": "12.40.0", "type": "module", "scripts": { "dev": "next dev", @@ -10,8 +10,8 @@ "build": "next build" }, "dependencies": { - "motion": "^12.38.0", - "next": "15.5.10", + "motion": "^12.40.0", + "next": "15.5.18", "react": "19.0.0", "react-dom": "19.0.0" } diff --git a/dev/react-19/package.json b/dev/react-19/package.json index 8283d5d753..82a448b5fa 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.38.0", + "version": "12.40.0", "type": "module", "scripts": { "dev": "vite", @@ -12,7 +12,7 @@ }, "dependencies": { "@tanstack/react-virtual": "^3.13.22", - "motion": "^12.38.0", + "motion": "^12.40.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -24,7 +24,7 @@ "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react-swc": "^3.5.0", "eslint-plugin-react-refresh": "^0.4.6", - "vite": "^5.2.0" + "vite": "^6.4.2" }, "resolutions": { "@types/react": "^19.0.0", diff --git a/dev/react/package.json b/dev/react/package.json index 6943c10b20..42e5544a5b 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.38.0", + "version": "12.40.0", "type": "module", "scripts": { "dev": "yarn vite", @@ -13,7 +13,8 @@ "dependencies": { "@base-ui-components/react": "^1.0.0-rc.0", "@tanstack/react-virtual": "^3.13.22", - "framer-motion": "^12.38.0", + "framer-motion": "^12.40.0", + "motion": "^12.40.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -25,6 +26,6 @@ "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react-swc": "^3.5.0", "eslint-plugin-react-refresh": "^0.4.6", - "vite": "^5.2.0" + "vite": "^6.4.2" } } diff --git a/dev/react/src/examples/transition-arc-playground.tsx b/dev/react/src/examples/transition-arc-playground.tsx new file mode 100644 index 0000000000..c2cd3d4673 --- /dev/null +++ b/dev/react/src/examples/transition-arc-playground.tsx @@ -0,0 +1,269 @@ +import { arc, motion } from "framer-motion" +import { useMemo, useState } from "react" + +const DIRECTIONS = ["auto", "cw", "ccw"] as const +type Direction = (typeof DIRECTIONS)[number] + +/** + * Playground for tuning `arc()` options. Sliders + radios rebuild the + * path factory on change; the Toggle button animates between two + * endpoints so you can see the resulting curve and rotation. + * + * URL: `?example=transition-arc-playground` + */ +export const App = () => { + const [strength, setStrength] = useState(1) + const [peak, setPeak] = useState(0.5) + const [rotateScale, setRotateScale] = useState(1) + const [direction, setDirection] = useState("auto") + const [duration, setDuration] = useState(1.5) + const [target, setTarget] = useState<"a" | "b">("a") + + const path = useMemo( + () => + arc({ + strength, + peak, + direction: direction === "auto" ? undefined : direction, + rotate: rotateScale, + }), + [strength, peak, direction, rotateScale] + ) + + const A = { x: 0, y: 0 } + const B = { x: 400, y: 0 } + const pos = target === "a" ? A : B + + const code = `arc({ + strength: ${strength.toFixed(2)}, + peak: ${peak.toFixed(2)}, + direction: ${direction === "auto" ? "undefined" : `"${direction}"`}, + rotate: ${rotateScale === 0 ? "false" : rotateScale.toFixed(2)}, +})` + + return ( +
+
+ + + + +
+ +
+

+ arc() options +

+ + + + + + + + + + + +
{code}
+
+
+ ) +} + +const Marker = ({ label, x, y }: { label: string; x: number; y: number }) => ( +
+ {label} +
+) + +const Slider = ({ + label, + value, + min, + max, + step, + onChange, + help, +}: { + label: string + value: number + min: number + max: number + step: number + onChange: (v: number) => void + help?: string +}) => ( + +) + +const Radio = ({ + label, + value, + options, + onChange, +}: { + label: string + value: T + options: readonly T[] + onChange: (v: T) => void +}) => ( +
+
+ {label} +
+
+ {options.map((opt) => ( + + ))} +
+
+) + +const containerStyle: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "1fr 300px", + height: "100vh", + fontFamily: "ui-sans-serif, system-ui, sans-serif", +} + +const stageStyle: React.CSSProperties = { + position: "relative", + background: "#fafafa", + borderRight: "1px solid #eee", + overflow: "hidden", +} + +const panelStyle: React.CSSProperties = { + padding: 20, + overflowY: "auto", +} + +const toggleBtn: React.CSSProperties = { + position: "absolute", + top: 16, + left: 16, + padding: "8px 14px", + borderRadius: 4, + border: "1px solid #ccc", + background: "#fff", + font: "500 13px ui-sans-serif", + cursor: "pointer", +} + +const codeStyle: React.CSSProperties = { + marginTop: 20, + padding: 12, + background: "#111", + color: "#9ee9b8", + borderRadius: 4, + font: "12px/1.5 ui-monospace, monospace", + whiteSpace: "pre", +} diff --git a/dev/react/src/examples/transition-arc-spring-playground.tsx b/dev/react/src/examples/transition-arc-spring-playground.tsx new file mode 100644 index 0000000000..c499dc30e5 --- /dev/null +++ b/dev/react/src/examples/transition-arc-spring-playground.tsx @@ -0,0 +1,298 @@ +import { arc, motion } from "framer-motion" +import { useMemo, useState } from "react" + +const DIRECTIONS = ["auto", "cw", "ccw"] as const +type Direction = (typeof DIRECTIONS)[number] + +/** + * Spring-driven variant of the arc playground. The path animator hands + * its progress value off to whatever transition the user supplies, so a + * spring with high `bounce` overshoots `t=1` and oscillates back — the + * arc samples past its endpoint during the overshoot, giving the curve + * a bouncy settle. + * + * URL: `?example=transition-arc-spring-playground` + */ +export const App = () => { + const [strength, setStrength] = useState(1) + const [peak, setPeak] = useState(0.5) + const [rotateScale, setRotateScale] = useState(1) + const [direction, setDirection] = useState("auto") + const [bounce, setBounce] = useState(0.6) + const [visualDuration, setVisualDuration] = useState(0.6) + const [target, setTarget] = useState<"a" | "b">("a") + + const path = useMemo( + () => + arc({ + strength, + peak, + direction: direction === "auto" ? undefined : direction, + rotate: rotateScale, + }), + [strength, peak, direction, rotateScale] + ) + + const A = { x: 0, y: 0 } + const B = { x: 400, y: 0 } + const pos = target === "a" ? A : B + + const code = `arc({ + strength: ${strength.toFixed(2)}, + peak: ${peak.toFixed(2)}, + direction: ${direction === "auto" ? "undefined" : `"${direction}"`}, + rotate: ${rotateScale === 0 ? "false" : rotateScale.toFixed(2)}, +}) + +// transition +{ + type: "spring", + bounce: ${bounce.toFixed(2)}, + visualDuration: ${visualDuration.toFixed(2)}, + path, +}` + + return ( +
+
+ + + + +
+ +
+

+ arc() + spring +

+ + + + + +
+ + + + + + + + + +
{code}
+
+
+ ) +} + +const Marker = ({ label, x, y }: { label: string; x: number; y: number }) => ( +
+ {label} +
+) + +const Slider = ({ + label, + value, + min, + max, + step, + onChange, + help, +}: { + label: string + value: number + min: number + max: number + step: number + onChange: (v: number) => void + help?: string +}) => ( + +) + +const Radio = ({ + label, + value, + options, + onChange, +}: { + label: string + value: T + options: readonly T[] + onChange: (v: T) => void +}) => ( +
+
+ {label} +
+
+ {options.map((opt) => ( + + ))} +
+
+) + +const containerStyle: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "1fr 300px", + height: "100vh", + fontFamily: "ui-sans-serif, system-ui, sans-serif", +} + +const stageStyle: React.CSSProperties = { + position: "relative", + background: "#fafafa", + borderRight: "1px solid #eee", + overflow: "hidden", +} + +const panelStyle: React.CSSProperties = { + padding: 20, + overflowY: "auto", +} + +const toggleBtn: React.CSSProperties = { + position: "absolute", + top: 16, + left: 16, + padding: "8px 14px", + borderRadius: 4, + border: "1px solid #ccc", + background: "#fff", + font: "500 13px ui-sans-serif", + cursor: "pointer", +} + +const codeStyle: React.CSSProperties = { + marginTop: 20, + padding: 12, + background: "#111", + color: "#9ee9b8", + borderRadius: 4, + font: "12px/1.5 ui-monospace, monospace", + whiteSpace: "pre", +} diff --git a/dev/react/src/tests/animate-presence-pop-rtl.tsx b/dev/react/src/tests/animate-presence-pop-rtl.tsx new file mode 100644 index 0000000000..9816f9a801 --- /dev/null +++ b/dev/react/src/tests/animate-presence-pop-rtl.tsx @@ -0,0 +1,47 @@ +import { AnimatePresence, motion } from "framer-motion" +import { useState } from "react" + +export const App = () => { + const [state, setState] = useState(true) + + return ( +
+
setState(!state)} + > + + + {state ? ( + + ) : null} + +
+
+ ) +} diff --git a/dev/react/src/tests/animate-virtualized-list-memory.tsx b/dev/react/src/tests/animate-virtualized-list-memory.tsx new file mode 100644 index 0000000000..0593952569 --- /dev/null +++ b/dev/react/src/tests/animate-virtualized-list-memory.tsx @@ -0,0 +1,163 @@ +import { useEffect, useRef, useState } from "react" +import { motion } from "framer-motion" + +/** + * Reproduction harness for issue #3241: alleged memory leak when + * scrolling animated motion.div items in a virtualized list. + * + * Mirrors the original sandbox structurally — each item is a motion.div + * with `initial={{ opacity: 0 }}` / `animate={{ opacity: 1 }}` and 100 + * child divs to amplify any DOM-node leak. + * + * The harness automatically scrolls a sliding window of items every + * 30ms (faster than the 300ms transition, so animations are routinely + * interrupted mid-flight). Every motion.div is registered in a + * FinalizationRegistry; `window.__leakStats` exposes mounted / + * unmounted / still-alive counts. + * + * Open the page in Chrome with `--js-flags=--expose-gc`, let it cycle, + * then run `for (let i=0;i<10;i++){window.gc();await new Promise(r=> + * setTimeout(r,100))}` in DevTools and read `__leakStats`. With no + * leak, `stillAlive` should equal the visible item count + * (typically 3–4) plus a small number of GC stragglers. + */ + +const ITEM_COUNT = 50 +const ITEM_SIZE = 200 +const VIEWPORT_HEIGHT = 400 + +declare global { + interface Window { + gc?: () => void + __leakStats?: { + mounted: number + unmounted: number + stillAlive: number + } + } +} + +let registry: FinalizationRegistry | null = null +const liveIds = new Set() +let totalMounted = 0 +let totalUnmounted = 0 +let idCounter = 0 + +const updateStats = () => { + window.__leakStats = { + mounted: totalMounted, + unmounted: totalUnmounted, + stillAlive: liveIds.size, + } +} + +if (typeof FinalizationRegistry !== "undefined" && !registry) { + registry = new FinalizationRegistry((id) => { + liveIds.delete(id) + updateStats() + }) +} + +const ListItem = ({ + index, + style, +}: { + index: number + style: React.CSSProperties +}) => { + const idRef = useRef(0) + const setRef = (el: HTMLDivElement | null) => { + if (el && !idRef.current) { + idRef.current = ++idCounter + totalMounted++ + liveIds.add(idRef.current) + registry?.register(el, idRef.current) + updateStats() + } else if (!el && idRef.current) { + totalUnmounted++ + updateStats() + } + } + + return ( + +

Item {index}

+ {Array.from({ length: 100 }, (_, i) => ( +
+ ))} + + ) +} + +export const App = () => { + const [scrollTop, setScrollTop] = useState(0) + const cycleRef = useRef(0) + + useEffect(() => { + const id = setInterval(() => { + cycleRef.current += 1 + const totalScroll = ITEM_COUNT * ITEM_SIZE - VIEWPORT_HEIGHT + setScrollTop((s) => (s + ITEM_SIZE) % totalScroll) + }, 30) + return () => clearInterval(id) + }, []) + + const startIndex = Math.floor(scrollTop / ITEM_SIZE) + const endIndex = Math.min( + ITEM_COUNT - 1, + Math.ceil((scrollTop + VIEWPORT_HEIGHT) / ITEM_SIZE) + ) + + const visible: number[] = [] + for (let i = startIndex; i <= endIndex; i++) visible.push(i) + + return ( +
+
+ cycle: {cycleRef.current} / mounted: {totalMounted} / + unmounted: {totalUnmounted} / live: {liveIds.size} +
+
+
+ {visible.map((i) => ( + + ))} +
+
+
+ ) +} diff --git a/dev/react/src/tests/drag-ref-constraints-absolute-scrolled.tsx b/dev/react/src/tests/drag-ref-constraints-absolute-scrolled.tsx new file mode 100644 index 0000000000..1883bc2ae2 --- /dev/null +++ b/dev/react/src/tests/drag-ref-constraints-absolute-scrolled.tsx @@ -0,0 +1,56 @@ +import { motion } from "framer-motion" +import { useRef, useLayoutEffect } from "react" + +/** + * Test page for issue #2829: When dragConstraints is set to a ref pointing + * to a viewport-sized element (`position: absolute; inset: 0`), drag should + * work across the full constraint area regardless of initial scroll position. + * + * The page is tall enough to scroll. We scroll the window in a layout effect + * to simulate a page being refreshed after the user had scrolled, which is + * the exact scenario the bug reporter described. + */ +export const App = () => { + const constraintsRef = useRef(null) + + const params = new URLSearchParams(window.location.search) + const initialScroll = Number(params.get("scroll") || "300") + + useLayoutEffect(() => { + window.scrollTo(0, initialScroll) + }, [initialScroll]) + + return ( +
+
+ +
+
+ ) +} diff --git a/dev/react/src/tests/drag-ref-constraints-resize-handle.tsx b/dev/react/src/tests/drag-ref-constraints-resize-handle.tsx new file mode 100644 index 0000000000..298c8c3430 --- /dev/null +++ b/dev/react/src/tests/drag-ref-constraints-resize-handle.tsx @@ -0,0 +1,63 @@ +import { motion } from "framer-motion" +import { useRef } from "react" + +/** + * Test page for issue #2903: Drag constraints should update when the + * draggable element is resized. Mirrors the CodeSandbox reproduction + * which uses an externally-resizable modal (e.g. CSS `resize: both` + * or imperative DOM resizing) where React state never changes. + * + * Container: 500x500 + * Draggable: starts at 100x100. Clicking the resize button mutates the + * element's inline style directly — bypassing React state — so the only + * signal that the size changed is ResizeObserver (matching the native + * CSS resize-handle behaviour described in the issue). + */ +export const App = () => { + const constraintsRef = useRef(null) + const boxRef = useRef(null) + + const onResize = () => { + if (boxRef.current) { + boxRef.current.style.width = "300px" + boxRef.current.style.height = "300px" + } + } + + return ( +
+ + + + +
+ ) +} diff --git a/dev/react/src/tests/drag-snap-animate-presence-exit.tsx b/dev/react/src/tests/drag-snap-animate-presence-exit.tsx new file mode 100644 index 0000000000..1462cd6303 --- /dev/null +++ b/dev/react/src/tests/drag-snap-animate-presence-exit.tsx @@ -0,0 +1,56 @@ +/** + * Companion to the #3315 fix: exercise drag + dragSnapToOrigin alongside + * AnimatePresence exit. The fix removes a synchronous `value.stop()` from + * VisualElement.unmount and relies on a deferred auto-stop — this page + * lets a Cypress run prove that AnimatePresence exit animations still + * complete cleanly while a drag-driven motion-value animation is mid-flight + * during the unmount. + */ + +import { useState } from "react" +import { AnimatePresence, motion } from "framer-motion" + +const TILE_SIZE = 80 + +export const App = () => { + const [show, setShow] = useState(true) + + return ( +
+ +
+ + {show && ( + + )} + +
+
+ ) +} diff --git a/dev/react/src/tests/drag-snap-layout-id-swap.tsx b/dev/react/src/tests/drag-snap-layout-id-swap.tsx new file mode 100644 index 0000000000..7e492bf333 --- /dev/null +++ b/dev/react/src/tests/drag-snap-layout-id-swap.tsx @@ -0,0 +1,126 @@ +import { useState } from "react" +import { motion } from "framer-motion" + +/** + * Regression test for issue #3315. + * + * Tiles use `drag` + `dragSnapToOrigin` + `layoutId` with absolute + * `top`/`left` positioning. When two same-row tiles swap, React 19's + * reorder reconciliation briefly unmounts and remounts the dragged tile. + * The visual element's unmount used to call `value.stop()` on its owned + * motion values, killing the in-flight dragSnapToOrigin animation and + * leaving the drag transform stranded — so the tile would render at its + * new layout position PLUS the drag offset. + */ + +const TILES_PER_ROW = 3 +const TILE_SIZE = 60 +const GRID_SIZE = TILE_SIZE * TILES_PER_ROW + +export const App = () => { + const [tiles, setTiles] = useState<{ id: number }[][]>(() => { + const t: { id: number }[][] = [] + for (let i = 0; i < TILES_PER_ROW; i++) { + const r: { id: number }[] = [] + for (let j = 0; j < TILES_PER_ROW; j++) { + r.push({ id: i * TILES_PER_ROW + j }) + } + t.push(r) + } + return t + }) + + const handleDragEnd = ( + draggedPos: { x: number; y: number }, + info: { offset: { x: number; y: number } } + ) => { + const dropX = draggedPos.x + Math.round(info.offset.x / TILE_SIZE) + const dropY = draggedPos.y + Math.round(info.offset.y / TILE_SIZE) + if ( + dropX < 0 || + dropX >= TILES_PER_ROW || + dropY < 0 || + dropY >= TILES_PER_ROW || + (draggedPos.x === dropX && draggedPos.y === dropY) + ) { + return + } + const newTiles = tiles.map((row) => [...row]) + newTiles[dropY][dropX] = tiles[draggedPos.y][draggedPos.x] + newTiles[draggedPos.y][draggedPos.x] = tiles[dropY][dropX] + setTiles(newTiles) + } + + return ( +
+
row.map((t) => t.id).join(",")) + .join("|")} + style={{ + border: "solid 1px black", + width: GRID_SIZE, + height: GRID_SIZE, + position: "relative", + }} + > + {tiles.map((r, y) => + r.map((tile, x) => ( + + )) + )} +
+
+ ) +} + +function Tile({ + tile, + position, + id, + onDragEnd, +}: { + tile: { id: number } + position: { x: number; y: number } + id: number + onDragEnd: ( + pos: { x: number; y: number }, + info: { offset: { x: number; y: number } } + ) => void +}) { + const [isDragging, setIsDragging] = useState(false) + return ( + setIsDragging(false)} + onAnimationStart={() => setIsDragging(true)} + drag + dragSnapToOrigin + onDragEnd={(_, info) => onDragEnd(position, info)} + whileDrag={{ zIndex: 1 }} + > + {id} + + ) +} diff --git a/dev/react/src/tests/issue-2833-fixed-position.tsx b/dev/react/src/tests/issue-2833-fixed-position.tsx new file mode 100644 index 0000000000..fdcea5404d --- /dev/null +++ b/dev/react/src/tests/issue-2833-fixed-position.tsx @@ -0,0 +1,67 @@ +import { motion } from "framer-motion" + +/** + * Regression test for #2833. + * + * React-select with menuPosition="fixed" relies on the menu being positioned + * relative to the viewport, not to its ancestors. CSS spec: an ancestor with + * `transform`, `perspective`, `filter`, `backdrop-filter`, or `will-change` + * (containing those properties) establishes a containing block for fixed + * descendants — breaking that assumption. + * + * motion.div should not apply any of those by default, even when transform + * animations are configured via animate/whileHover/whileTap. + */ +export const App = () => { + const variant = + new URLSearchParams(window.location.search).get("variant") ?? "plain" + + const child = ( +
+ ) + + let parent + if (variant === "while-hover") { + parent = ( + + {child} + + ) + } else if (variant === "while-tap") { + parent = ( + + {child} + + ) + } else if (variant === "animate-transform") { + parent = ( + + {child} + + ) + } else if (variant === "initial-transform") { + parent = ( + + {child} + + ) + } else { + parent = {child} + } + + return
{parent}
+} diff --git a/dev/react/src/tests/lazy-motion-react-m-subpath.tsx b/dev/react/src/tests/lazy-motion-react-m-subpath.tsx new file mode 100644 index 0000000000..b0e4891853 --- /dev/null +++ b/dev/react/src/tests/lazy-motion-react-m-subpath.tsx @@ -0,0 +1,46 @@ +import { LazyMotion, domAnimation } from "motion/react" +import * as m from "motion/react-m" +import { useEffect, useRef } from "react" + +/** + * Test for GitHub issue #3091 + * + * LazyMotion (from `motion/react`) wrapping `m.div` from the `motion/react-m` + * subpath. The LazyMotion-supplied renderer and feature definitions must reach + * the m component even though the m components come from a separately-bundled + * subpath, otherwise the m component renders nothing animated. + */ +export const App = () => { + const ref = useRef(null) + + useEffect(() => { + const id = setTimeout(() => { + if (ref.current && !ref.current.dataset.animationComplete) { + ref.current.dataset.animationFailed = "true" + } + }, 1000) + return () => clearTimeout(id) + }, []) + + return ( + + { + if (ref.current) { + ref.current.dataset.animationComplete = "true" + } + }} + style={{ + width: 100, + height: 100, + background: "red", + }} + /> + + ) +} diff --git a/dev/react/src/tests/scroll-view-timeline-transformed-parent.tsx b/dev/react/src/tests/scroll-view-timeline-transformed-parent.tsx new file mode 100644 index 0000000000..9547d3c5b3 --- /dev/null +++ b/dev/react/src/tests/scroll-view-timeline-transformed-parent.tsx @@ -0,0 +1,150 @@ +// Repro for #3658: nested motion components inside a useScroll target bind +// to ScrollTimeline (cover defaults) instead of ViewTimeline. +import { motion, MotionValue, scroll, useScroll, useTransform } from "framer-motion" +import * as React from "react" +import { useEffect, useRef } from "react" + +const heroStyle: React.CSSProperties = { + height: "100vh", + display: "grid", + placeItems: "center", +} + +const FullRangeProbe = ({ progress }: { progress: MotionValue }) => { + const opacity = useTransform(progress, [0, 1], [0, 1]) + return ( + + ) +} + +const TextReveal = ({ text }: { text: string }) => { + const ref = useRef(null) + const jsRef = useRef(null) + const { scrollYProgress } = useScroll({ + target: ref, + offset: ["start start", "end end"], + }) + + useEffect(() => { + if (!ref.current) return + // 2-arg callback forces the JS scrollInfo path (see attach-function.ts). + return scroll( + (_progress, info) => { + if (jsRef.current) + jsRef.current.innerText = info.y.progress.toFixed(4) + }, + { + target: ref.current, + offset: ["start start", "end end"], + } + ) + }, []) + + const words = text.split(" ") + return ( +
+
+
+ js:{" "} + + 0 + +
+ +

+ {words.map((word, i) => { + const start = i / words.length + const end = start + 1 / words.length + const opacity = useTransform( + scrollYProgress, + [start, end], + [0.2, 1] + ) + return ( + + {word} + + ) + })} +

+
+
+ ) +} + +const ClipReveal = ({ children }: { children: React.ReactNode }) => { + const ref = useRef(null) + const { scrollYProgress } = useScroll({ + target: ref, + offset: ["start end", "start 0.3"], + }) + const clipPath = useTransform( + scrollYProgress, + [0, 1], + ["inset(8% 12% round 24px)", "inset(0% 0% round 0px)"] + ) + const scale = useTransform(scrollYProgress, [0, 1], [0.95, 1]) + + return ( +
+ {children} +
+ ) +} + +export const App = () => { + return ( +
+
+

Scroll down ↓

+
+ +
+ +
+
+
+

End

+
+
+ ) +} diff --git a/dev/react/src/tests/svg-style-on-mount.tsx b/dev/react/src/tests/svg-style-on-mount.tsx new file mode 100644 index 0000000000..527545f0e0 --- /dev/null +++ b/dev/react/src/tests/svg-style-on-mount.tsx @@ -0,0 +1,51 @@ +"use client" + +import { motion, useMotionValue, useTransform } from "framer-motion" + +/** + * Test: SVG styles should apply correctly on mount when using useTransform. + * Reproduction for #2949: SVG transform-origin and styles not applying on mount. + * + * The bug: SVG elements with transforms derived from useTransform would have + * incorrect transformOrigin and transformBox on initial mount, causing a visible + * jump when the visual element takes over rendering. + */ +export function App() { + const x = useMotionValue(50) + + // Derived transform values via useTransform + const pathLength = useTransform(x, [0, 100], [0, 1]) + const opacity = useTransform(x, [0, 100], [0, 1]) + const fill = useTransform(x, [0, 100], ["#0000ff", "#ff0000"]) + + return ( + + {/* Path with useTransform-derived pathLength + opacity + CSS transform */} + + {/* Circle with useTransform-derived fill */} + + {/* Rect with static transform to test transformBox/transformOrigin */} + + + ) +} diff --git a/dev/react/src/tests/transition-arc.tsx b/dev/react/src/tests/transition-arc.tsx new file mode 100644 index 0000000000..78c7330ecf --- /dev/null +++ b/dev/react/src/tests/transition-arc.tsx @@ -0,0 +1,301 @@ +import { arc, LayoutGroup, motion } from "framer-motion" +import { useEffect, useMemo, useRef, useState } from "react" + +const ITEM_A = { left: 50, top: 200, width: 100, height: 50 } +const ITEM_B = { left: 450, top: 200, width: 100, height: 50 } +const ITEM_B_NEAR = { left: 60, top: 200, width: 100, height: 50 } + +/** + * URL variants: + * default — auto-toggling layout arc, real ease (visual demo) + * freeze — ease() => 0.5 to pin layout animation at midpoint (Cypress) + * none — like freeze but no `path` (linear baseline) + * small — sub-threshold distance, falls back to linear + * keyframe — keyframe arc with a Toggle button + * oriented — keyframe arc with rotate: true + * freezeAt=N — pin keyframe animation at fraction N (0..1) + * interrupt — auto-toggle layout fast, mid-flight (continuity demo) + * axis-change — interrupt across a dominant-axis change (continuity demo) + * cw — direction locked clockwise + * ccw — direction locked counter-clockwise + * ping-pong — keyframe arc bouncing between three points + */ +export const App = () => { + const params = new URLSearchParams(window.location.search) + const variant = params.get("variant") || "default" + + if (variant === "keyframe" || variant === "oriented") { + return + } + + if (variant === "ping-pong") return + if (variant === "axis-change") return + if (variant === "rotate-compose") return + + return +} + +const LayoutArc = ({ variant }: { variant: string }) => { + const isSmall = variant === "small" + const itemB = isSmall ? ITEM_B_NEAR : ITEM_B + const isFreeze = variant === "freeze" || variant === "none" || isSmall + const direction = variant === "cw" ? "cw" : variant === "ccw" ? "ccw" : undefined + const ease = isFreeze ? () => 0.5 : undefined + const duration = isFreeze ? 4 : 1.2 + + // Memoize the arc factory so its closure (prevBulgeSign) survives renders. + const path = useMemo( + () => (variant === "none" ? undefined : arc({ strength: 1, direction })), + [variant, direction] + ) + + const transition: any = { duration, ...(ease ? { ease } : {}) } + if (path) transition.path = path + + const [active, setActive] = useState<"a" | "b">("a") + + useEffect(() => { + if (isFreeze) return + const interval = variant === "interrupt" ? 600 : 1500 + const id = window.setInterval(() => { + setActive((prev) => (prev === "a" ? "b" : "a")) + }, interval) + return () => window.clearInterval(id) + }, [isFreeze, variant]) + + return ( +
+ + + +
+ {active === "a" && ( + + )} +
+
+ {active === "b" && ( + + )} +
+
+
+ ) +} + +const KeyframeArc = ({ oriented }: { oriented: boolean }) => { + const [target, setTarget] = useState<"a" | "b">("a") + const params = new URLSearchParams(window.location.search) + const freezeAt = params.has("freezeAt") + ? Number(params.get("freezeAt")) + : params.has("freeze") + ? 0.5 + : undefined + const path = useRef(arc({ strength: 1, rotate: oriented })).current + + return ( +
+ + + freezeAt } : {}), + path, + }} + style={{ + position: "absolute", + top: 200, + left: 50, + width: 100, + height: 100, + background: "red", + }} + /> +
+ ) +} + +/** + * Bounces between three corners of a triangle. With a memoized arc(), + * the closure should keep the bulge on a consistent screen side as the + * dominant axis swings between segments. + */ +const PingPong = () => { + const positions = [ + { x: 0, y: 0 }, + { x: 400, y: 0 }, + { x: 200, y: 300 }, + ] + const [i, setI] = useState(0) + const path = useRef(arc({ strength: 0.7, rotate: 0.5 })).current + + useEffect(() => { + const id = window.setInterval(() => { + setI((p) => (p + 1) % positions.length) + }, 1400) + return () => window.clearInterval(id) + }, []) + + return ( +
+ + +
+ ) +} + +/** + * Demonstrates the dominant-axis-change continuity case. Auto-direction + * alone would pick a different screen side when the chord swings from + * mostly-horizontal to mostly-vertical. With a reused arc(), the bulge + * stays consistent. + */ +const AxisChange = () => { + const [phase, setPhase] = useState<0 | 1 | 2 | 3>(0) + const path = useRef(arc({ strength: 0.7 })).current + const points = [ + { x: 0, y: 0 }, + { x: 300, y: 50 }, + { x: 350, y: 350 }, + { x: 0, y: 300 }, + ] + + useEffect(() => { + const id = window.setInterval(() => { + setPhase((p) => ((p + 1) % 4) as 0 | 1 | 2 | 3) + }, 1300) + return () => window.clearInterval(id) + }, []) + + return ( +
+ + +
+ ) +} + +/** + * An oriented arc running *at the same time* as a user `rotate` + * animation. Frozen at t=0.5: pathRotation is ~0 by symmetry there, so + * the only rotation in the matrix should be the user's `rotate` at 50% + * (0 → 90 → 45deg). If the arc clobbered `rotate` (the old behaviour) + * the element would read ~0deg instead. Proves composition + that the + * user's value is never overwritten. + */ +const RotateCompose = () => { + const [target, setTarget] = useState<"a" | "b">("a") + const path = useRef(arc({ strength: 1, rotate: true })).current + + return ( +
+ + + 0.5, path }} + style={{ + position: "absolute", + top: 200, + left: 50, + width: 100, + height: 100, + background: "red", + }} + /> +
+ ) +} + +const Hud = ({ variant }: { variant: string }) => ( +
+ variant={variant} +
+) diff --git a/dev/react/src/tests/use-scroll-target-late-ref.tsx b/dev/react/src/tests/use-scroll-target-late-ref.tsx new file mode 100644 index 0000000000..d038fc7ef7 --- /dev/null +++ b/dev/react/src/tests/use-scroll-target-late-ref.tsx @@ -0,0 +1,90 @@ +import { useMotionValueEvent, useScroll } from "framer-motion" +import * as React from "react" +import { useEffect, useRef, useState } from "react" +import * as ReactDOMClient from "react-dom/client" + +/** + * Reproduction for #2851 — useScroll target ref hydrated after the hook's + * own effects run (e.g. via querySelector in a useEffect declared after + * useScroll). Before the fix, useScroll fell back to the whole-window scroll + * because target.current was still null when its useEffect ran. + * + * The actual reproduction is rendered in a fresh ReactDOM root so it isn't + * wrapped by the dev harness's StrictMode — StrictMode's double-mount in dev + * masks the bug because the second mount sees the hydrated ref. + * + * StrictMode still double-invokes *this* outer effect, so the nested root is + * guarded to a single instance: the deferred unmount is cancelled if a + * remount happens first. Otherwise two trees coexist (duplicate + * #target/#progress IDs, doubled document) and the test reads a stale, + * window-tracking instance — a React 19 flake unrelated to the fix. + */ +export const App = () => { + const containerRef = useRef(null) + const rootRef = useRef(null) + const unmountPending = useRef(false) + + useEffect(() => { + if (!containerRef.current) return + unmountPending.current = false + if (!rootRef.current) { + rootRef.current = ReactDOMClient.createRoot(containerRef.current) + } + rootRef.current.render() + // Defer unmount: React 18 errors when a root is unmounted + // synchronously from another root's effect cleanup. If StrictMode + // remounts before the microtask runs, the remount clears the flag + // and the root is kept. + return () => { + unmountPending.current = true + queueMicrotask(() => { + if (!unmountPending.current) return + rootRef.current?.unmount() + rootRef.current = null + }) + } + }, []) + + return
+} + +const Repro = () => { + const targetRef = useRef(null) + + const { scrollYProgress } = useScroll({ + target: targetRef, + offset: ["start end", "end start"], + }) + + useEffect(() => { + targetRef.current = document.querySelector("#target") + }, []) + + const [progress, setProgress] = useState(0) + useMotionValueEvent(scrollYProgress, "change", setProgress) + + return ( + <> +
+
+
+
+ {progress.toFixed(4)} +
+ + ) +} + +const topSpacer: React.CSSProperties = { height: "200vh" } +const bottomSpacer: React.CSSProperties = { height: "100vh" } +const targetStyle: React.CSSProperties = { + height: "100vh", + background: "red", +} +const progressStyle: React.CSSProperties = { + position: "fixed", + top: 0, + left: 0, + background: "white", + zIndex: 10, +} diff --git a/lerna.json b/lerna.json index 4ac61c1539..69a8b6dba5 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.38.0", + "version": "12.40.0", "packages": [ "packages/*", "dev/*" diff --git a/package.json b/package.json index 3d4152fe5b..47cdde9a80 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "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", @@ -66,7 +65,6 @@ "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/README.md b/packages/framer-motion/README.md index ae2f201593..a997d7bcad 100644 --- a/packages/framer-motion/README.md +++ b/packages/framer-motion/README.md @@ -121,12 +121,10 @@ Motion drives the animations on the Cursor homepage, and is working with Cursor ### Platinum -Linear Figma Sanity Sanity Clerk Greptile +Linear Figma Sanity Sanity Clerk ### Gold -Mintlify - ### Silver Liveblocks Frontend.fyi Firecrawl Puzzmo Bolt.new diff --git a/packages/framer-motion/client/package.json b/packages/framer-motion/client/package.json index ed702d3c3f..c4eeff4b2f 100644 --- a/packages/framer-motion/client/package.json +++ b/packages/framer-motion/client/package.json @@ -1,6 +1,6 @@ { "private": true, - "types": "../dist/types/client.d.ts", + "types": "../dist/client.d.ts", "main": "../dist/cjs/client.js", "module": "../dist/es/client.mjs" } diff --git a/packages/framer-motion/cypress/integration/animate-presence-pop-rtl.ts b/packages/framer-motion/cypress/integration/animate-presence-pop-rtl.ts new file mode 100644 index 0000000000..8a47e02bb9 --- /dev/null +++ b/packages/framer-motion/cypress/integration/animate-presence-pop-rtl.ts @@ -0,0 +1,20 @@ +describe("AnimatePresence popLayout RTL", () => { + it("correctly pops exiting elements in RTL direction without shifting", () => { + let initialLeft: number + + cy.visit("?test=animate-presence-pop-rtl") + .wait(50) + .get("#b") + .then(([$b]: any) => { + initialLeft = $b.getBoundingClientRect().left + }) + .get("#container") + .trigger("click", 60, 60, { force: true }) + .wait(100) + .get("#b") + .should(([$b]: any) => { + const bbox = $b.getBoundingClientRect() + expect(bbox.left).to.equal(initialLeft) + }) + }) +}) diff --git a/packages/framer-motion/cypress/integration/drag-ref-constraints-absolute-scrolled.ts b/packages/framer-motion/cypress/integration/drag-ref-constraints-absolute-scrolled.ts new file mode 100644 index 0000000000..6894ec694c --- /dev/null +++ b/packages/framer-motion/cypress/integration/drag-ref-constraints-absolute-scrolled.ts @@ -0,0 +1,40 @@ +/** + * Tests for issue #2829: Drag with ref-based constraints on a viewport-sized + * element (`position: absolute; inset: 0`) should allow dragging to the bottom + * of the visible viewport, even when the page is loaded with scroll restored. + * + * The test page scrolls the window in a layout effect before drag is wired up, + * mimicking the browser restoring scroll position on refresh. + */ +describe("Drag with ref constraints on absolute element after scroll", () => { + it("Allows dragging to the visible bottom of the viewport after scroll", () => { + cy.viewport(1000, 800) + .visit("?test=drag-ref-constraints-absolute-scrolled&scroll=300") + .wait(300) + .window() + .then((win) => { + expect(win.scrollY).to.be.greaterThan(0) + }) + .get("[data-testid='draggable']") + .trigger("pointerdown", 5, 5, { force: true }) + .trigger("pointermove", 10, 10, { force: true }) + .wait(50) + .trigger("pointermove", 900, 1500, { force: true }) + .wait(50) + .trigger("pointerup", { force: true }) + .wait(100) + .should(($el: any) => { + const el = $el[0] as HTMLDivElement + const rect = el.getBoundingClientRect() + // viewport: 1000x800, scroll: 300, box: 50x50 + // Constraint is `position: absolute; inset: 0` (viewport-sized + // at the document origin). After the page is loaded scrolled, + // the visible portion of the constraint spans viewport y ∈ + // [0, 500]. Before the fix, drag constraints were computed + // with a stale scroll offset, clamping the box's bottom to + // ~200 (= viewportH - 2*scrollY - boxH). With the fix, the + // box should reach the constraint's visible bottom (~500). + expect(rect.bottom).to.be.closeTo(500, 5) + }) + }) +}) diff --git a/packages/framer-motion/cypress/integration/drag-ref-constraints-resize-handle.ts b/packages/framer-motion/cypress/integration/drag-ref-constraints-resize-handle.ts new file mode 100644 index 0000000000..a600cd6a5d --- /dev/null +++ b/packages/framer-motion/cypress/integration/drag-ref-constraints-resize-handle.ts @@ -0,0 +1,46 @@ +/** + * Tests for issue #2903: Drag constraints should update when the + * draggable element is resized via a path that bypasses React state + * (e.g. CSS `resize: both` or imperative DOM resizing — the scenario + * described in the linked CodeSandbox). + * + * - Container (#constraints): 500x500 + * - Draggable (#box): starts 100x100, grows to 300x300 when + * #resize-trigger is clicked. The handler mutates the element's + * inline style directly so the only signal of the size change is + * ResizeObserver — projection's normal lifecycle does not fire. + * + * Before resize: max travel = 400px (500 - 100) + * After resize: max travel = 200px (500 - 300) + */ +describe("Drag Constraints Update on Imperative Resize", () => { + it("Updates drag constraints when element grows via direct DOM mutation", () => { + cy.visit("?test=drag-ref-constraints-resize-handle").wait(200) + + cy.get("#resize-trigger").click().wait(200) + + cy.get("#box").should(($box: any) => { + const box = $box[0] as HTMLDivElement + const { width, height } = box.getBoundingClientRect() + expect(width).to.equal(300) + expect(height).to.equal(300) + }) + + cy.get("#box") + .trigger("pointerdown", 5, 5) + .trigger("pointermove", 10, 10) + .wait(50) + .trigger("pointermove", 600, 600, { force: true }) + .wait(50) + .trigger("pointerup", { force: true }) + .wait(50) + .should(($box: any) => { + const box = $box[0] as HTMLDivElement + const { right, bottom } = box.getBoundingClientRect() + // 300x300 box must stay inside the 500x500 container. + // Without the fix, right/bottom would be ~700. + expect(right).to.be.at.most(502) + expect(bottom).to.be.at.most(502) + }) + }) +}) diff --git a/packages/framer-motion/cypress/integration/drag-snap-animate-presence-exit.ts b/packages/framer-motion/cypress/integration/drag-snap-animate-presence-exit.ts new file mode 100644 index 0000000000..2978a1216f --- /dev/null +++ b/packages/framer-motion/cypress/integration/drag-snap-animate-presence-exit.ts @@ -0,0 +1,52 @@ +/** + * Drag + dragSnapToOrigin + AnimatePresence exit interaction (companion + * to #3315). After the fix removed VE.unmount's synchronous value.stop(), + * we want to confirm that: + * 1. AnimatePresence exit animations still complete and tear the tile down. + * 2. Toggling the tile back on after a drag yields a clean motion element + * (no stranded drag transform from the previous instance). + */ + +describe("drag + dragSnapToOrigin + AnimatePresence exit", () => { + it("exits cleanly after a drag and re-enters without a stranded transform", () => { + const initial: { left?: number; top?: number } = {} + cy.visit("?test=drag-snap-animate-presence-exit") + .wait(200) + // Capture the tile's initial layout box so we can compare the + // re-entered instance to it later. + .get('[data-testid="tile"]') + .then(([$el]: any) => { + const r = $el.getBoundingClientRect() + initial.left = r.left + initial.top = r.top + }) + // Drag the tile and release — dragSnapToOrigin animation kicks in. + .get('[data-testid="tile"]') + .trigger("pointerdown", 10, 10, { force: true }) + .trigger("pointermove", 20, 10, { force: true }) + .wait(50) + .trigger("pointermove", 60, 10, { force: true }) + .wait(50) + .trigger("pointerup", 60, 10, { force: true }) + // Toggle off mid-snap to trigger AnimatePresence exit while + // the drag motion-value animation is still in flight. + .get('[data-testid="toggle"]') + .click() + .wait(800) + // AnimatePresence should have torn the tile down. + .get('[data-testid="tile"]') + .should("not.exist") + // Toggle back on and confirm the new tile renders at the + // same layout position as the first instance — no leftover + // transform from the previous drag. + .get('[data-testid="toggle"]') + .click() + .wait(500) + .get('[data-testid="tile"]') + .should(([$el]: any) => { + const r = $el.getBoundingClientRect() + expect(r.left).to.be.closeTo(initial.left!, 2) + expect(r.top).to.be.closeTo(initial.top!, 2) + }) + }) +}) diff --git a/packages/framer-motion/cypress/integration/drag-snap-layout-id-swap.ts b/packages/framer-motion/cypress/integration/drag-snap-layout-id-swap.ts new file mode 100644 index 0000000000..23d17247f9 --- /dev/null +++ b/packages/framer-motion/cypress/integration/drag-snap-layout-id-swap.ts @@ -0,0 +1,58 @@ +/** + * Regression test for issue #3315. + * + * Tiles use `drag` + `dragSnapToOrigin` + `layoutId` in an absolutely- + * positioned grid. When same-row tiles swap, React 19's reorder + * reconciliation briefly unmounts and remounts the dragged component; + * if the VisualElement's unmount cancels in-flight motion-value + * animations, the dragSnapToOrigin animation dies before remount can + * resubscribe and the tile renders at "new layout + stranded drag offset". + * + * Note: The bug only manifests under React 19's reconciliation. The Cypress + * runner targets the React 19 dev app (`cypress.react-19.json`), so the + * regression gate is on that config; the React 18 run passes either way. + */ +describe("drag + dragSnapToOrigin + layoutId horizontal swap", () => { + it("does not strand the drag transform after a same-row swap", () => { + cy.visit("?test=drag-snap-layout-id-swap") + .wait(200) + .get('[data-testid="tile-0"]') + .should(([$el]: any) => { + const r = $el.getBoundingClientRect() + expect(r.left).to.be.closeTo(50, 2) + expect(r.top).to.be.closeTo(50, 2) + }) + // Drag tile-0 right ~60px (one column) onto tile-1's slot. + // Pointermoves are element-relative; we keep the move small so + // the recomputed-as-element-moves coordinate system still lands + // the pointer on tile-1. + .trigger("pointerdown", 5, 5, { force: true }) + .trigger("pointermove", 10, 5, { force: true }) + .wait(50) + .trigger("pointermove", 35, 5, { force: true }) + .wait(50) + .trigger("pointerup", 35, 5, { force: true }) + .wait(2000) + .get("#grid") + .should(([$grid]: any) => { + // Confirm the state-level swap happened. + expect($grid.getAttribute("data-tile-state")).to.match( + /^1,0,/ + ) + }) + .get('[data-testid="tile-0"]') + .should(([$el]: any) => { + const r = $el.getBoundingClientRect() + // Tile 0 swapped into column 1: 50 (padding) + 60 (column). + // The bug would leave it at ~170 (col 1 + stranded drag). + expect(r.left).to.be.closeTo(110, 2) + expect(r.top).to.be.closeTo(50, 2) + }) + .get('[data-testid="tile-1"]') + .should(([$el]: any) => { + const r = $el.getBoundingClientRect() + expect(r.left).to.be.closeTo(50, 2) + expect(r.top).to.be.closeTo(50, 2) + }) + }) +}) diff --git a/packages/framer-motion/cypress/integration/issue-2833-fixed-position.ts b/packages/framer-motion/cypress/integration/issue-2833-fixed-position.ts new file mode 100644 index 0000000000..0bbdc558e3 --- /dev/null +++ b/packages/framer-motion/cypress/integration/issue-2833-fixed-position.ts @@ -0,0 +1,42 @@ +/** + * Regression test for #2833. + * + * React-select with menuPosition="fixed" was rendering in the wrong position + * because motion.div applied will-change: transform automatically, which + * establishes a CSS containing block for position: fixed descendants. The + * auto will-change behaviour was removed but plain transform/filter/etc. + * should also never be applied by default. + */ +const variants = [ + "plain", + "while-hover", + "while-tap", + "animate-transform", + "initial-transform", +] + +describe("position: fixed children inside motion.div (#2833)", () => { + for (const variant of variants) { + it(`motion.div does not establish a containing block (variant=${variant})`, () => { + cy.visit(`?test=issue-2833-fixed-position&variant=${variant}`) + + cy.get("#parent").then(($el) => { + const cs = getComputedStyle($el[0] as HTMLElement) + expect(cs.transform).to.equal("none") + expect(cs.perspective).to.equal("none") + expect(cs.filter).to.equal("none") + // willChange of "transform"/"perspective"/"filter" would also + // establish a containing block. + expect(cs.willChange).to.equal("auto") + }) + + // The fixed-positioned child should land at viewport (10, 10), + // not offset by the wrapper's 200px padding. + cy.get("#fixed-child").then(($el) => { + const rect = ($el[0] as HTMLElement).getBoundingClientRect() + expect(rect.top).to.equal(10) + expect(rect.left).to.equal(10) + }) + }) + } +}) diff --git a/packages/framer-motion/cypress/integration/lazy-motion-react-m-subpath.ts b/packages/framer-motion/cypress/integration/lazy-motion-react-m-subpath.ts new file mode 100644 index 0000000000..ad6afeb763 --- /dev/null +++ b/packages/framer-motion/cypress/integration/lazy-motion-react-m-subpath.ts @@ -0,0 +1,12 @@ +describe("LazyMotion with m components from /m subpath (issue #3091)", () => { + it("animates m.div from framer-motion/m inside LazyMotion from framer-motion", () => { + cy.visit("?test=lazy-motion-react-m-subpath") + .wait(500) + .get("#box") + .should(([$element]: any) => { + expect($element.dataset.animationFailed).to.not.equal("true") + expect($element.dataset.animationComplete).to.equal("true") + expect(getComputedStyle($element).opacity).to.equal("1") + }) + }) +}) diff --git a/packages/framer-motion/cypress/integration/scroll-view-timeline-transformed-parent.ts b/packages/framer-motion/cypress/integration/scroll-view-timeline-transformed-parent.ts new file mode 100644 index 0000000000..0a2f3f17f0 --- /dev/null +++ b/packages/framer-motion/cypress/integration/scroll-view-timeline-transformed-parent.ts @@ -0,0 +1,53 @@ +// Regression for #3658. The ViewTimeline assertion only runs in browsers +// that support ViewTimeline — CI's Electron does not, so it falls back +// to the JS path and there's nothing to assert against. + +const scrollToPx = (px: number) => + cy.window().then((win) => win.scrollTo(0, px)) + +const readWaapi = () => + cy + .get("#opacity-probe") + .then(([$el]: any) => parseFloat(getComputedStyle($el).opacity)) + +const readJs = () => + cy.get("#js-progress").then(([$el]: any) => parseFloat($el.innerText)) + +describe("useScroll + transformed ancestor (regression for #3658)", () => { + it("attaches ViewTimeline (not ScrollTimeline) to inner motion components", function () { + cy.visit("?test=scroll-view-timeline-transformed-parent").wait(500) + cy.window().then((win) => { + if (!(win as any).ViewTimeline) return this.skip() + cy.get("#opacity-probe").then(([$el]: any) => { + const anims = $el.getAnimations() + expect(anims).to.have.length.greaterThan(0) + const a = anims[0] + expect(a.timeline?.constructor?.name).to.equal("ViewTimeline") + expect(a.rangeStart?.rangeName).to.equal("contain") + expect(a.rangeEnd?.rangeName).to.equal("contain") + }) + }) + }) + + it("WAAPI and JS paths agree on the same target across the scroll range", () => { + cy.visit("?test=scroll-view-timeline-transformed-parent").wait(500) + + cy.window().then((win) => { + const vh = win.innerHeight + const stops = [0.5, 1, 1.25, 1.5, 1.75, 2].map((m) => m * vh) + const drift: number[] = [] + + cy.wrap(stops) + .each((y: number) => { + scrollToPx(y) + cy.wait(200) + readWaapi().then((w) => { + readJs().then((j) => drift.push(Math.abs(w - j))) + }) + }) + .then(() => { + expect(Math.max(...drift)).to.be.lessThan(0.01) + }) + }) + }) +}) diff --git a/packages/framer-motion/cypress/integration/svg-style-on-mount.ts b/packages/framer-motion/cypress/integration/svg-style-on-mount.ts new file mode 100644 index 0000000000..6b4629d302 --- /dev/null +++ b/packages/framer-motion/cypress/integration/svg-style-on-mount.ts @@ -0,0 +1,82 @@ +/** + * Tests for #2949: SVG styles not applying on mount. + * + * The bug: SVG elements using useTransform-derived values would have + * incorrect transformOrigin/transformBox on initial mount (before + * dimensions are measured), causing a visible "jump" when the visual + * element takes over. + * + * The fix: Always set transformBox to "fill-box" and transformOrigin + * to "50% 50%" on SVG elements with transforms, even before dimensions + * are measured. + */ +describe("SVG styles on mount (#2949)", () => { + it("Applies transform, transformBox, and transformOrigin on mount", () => { + cy.visit("?test=svg-style-on-mount") + .get("#path") + .then(([$path]: any) => { + // Transform should be applied immediately on mount + expect($path.style.transform).to.contain("translateX(10px)") + expect($path.style.transform).to.contain("translateY(10px)") + + // transformBox must be "fill-box" on initial mount + // (this was the core of the #2949 bug — it was missing) + expect($path.style.transformBox).to.equal("fill-box") + + // transformOrigin must be set to prevent jumping + expect($path.style.transformOrigin).to.equal("50% 50%") + }) + }) + + it("Applies useTransform-derived pathLength attributes on mount", () => { + cy.visit("?test=svg-style-on-mount") + .get("#path") + .then(([$path]: any) => { + // pathLength should be 0.5 (useTransform(50, [0,100], [0,1])) + // buildSVGPath normalizes the pathLength attribute to 1 + expect($path.getAttribute("pathLength")).to.equal("1") + + // stroke-dasharray should reflect pathLength=0.5 + expect($path.getAttribute("stroke-dasharray")).to.equal( + "0.5 1" + ) + + // stroke-dashoffset should be 0 (pathOffset defaults to 0) + const dashoffset = $path.getAttribute("stroke-dashoffset") + expect(parseFloat(dashoffset)).to.equal(0) + }) + }) + + it("Applies useTransform-derived opacity on SVG path on mount", () => { + cy.visit("?test=svg-style-on-mount") + .get("#path") + .then(([$path]: any) => { + // opacity should be 0.5 (useTransform(50, [0,100], [0,1])) + const opacity = + $path.getAttribute("opacity") ?? + window.getComputedStyle($path).opacity + expect(parseFloat(opacity)).to.equal(0.5) + }) + }) + + it("Applies useTransform-derived fill on SVG circle on mount", () => { + cy.visit("?test=svg-style-on-mount") + .get("#circle") + .then(([$circle]: any) => { + // fill should be interpolated (not null/empty/default) + const fill = $circle.getAttribute("fill") + expect(fill).to.not.be.null + expect(fill).to.not.equal("") + }) + }) + + it("Applies transformBox and transformOrigin on SVG rect with static transform", () => { + cy.visit("?test=svg-style-on-mount") + .get("#rect") + .then(([$rect]: any) => { + expect($rect.style.transform).to.equal("rotate(45deg)") + expect($rect.style.transformBox).to.equal("fill-box") + expect($rect.style.transformOrigin).to.equal("50% 50%") + }) + }) +}) diff --git a/packages/framer-motion/cypress/integration/transition-arc.ts b/packages/framer-motion/cypress/integration/transition-arc.ts new file mode 100644 index 0000000000..29508a2153 --- /dev/null +++ b/packages/framer-motion/cypress/integration/transition-arc.ts @@ -0,0 +1,180 @@ +/** + * Tests for the `path: arc()` transition modifier across both layout + * animations (`transition.layout.path`) and keyframe animations + * (`transition.path`). + * + * Layout-arc tests visit `?variant=freeze` (or `none`/`small`) which use + * `ease: () => 0.5` to pin the layout animation at exactly 50% progress. + * That lets us sample mid-arc position with `getBoundingClientRect()` + * without fighting timing. + * + * The default `?test=transition-arc` URL auto-toggles with a real ease for + * visual inspection — those URLs are not what these tests run against. + */ + +describe("layout arc", () => { + it("deviates from the straight-line path mid-animation", () => { + cy.visit("?test=transition-arc&variant=freeze") + .wait(50) + .get("#indicator") + .should(([$el]: any) => { + expect($el.getBoundingClientRect().top).to.be.closeTo(200, 10) + }) + .get("#toggle") + .click() + .wait(100) + // 400px horizontal, strength=1 → ~200px perpendicular at t=0.5 + .get("#indicator") + .should(([$el]: any) => { + const { top } = $el.getBoundingClientRect() + expect(Math.abs(top - 200)).to.be.greaterThan(80) + }) + }) + + it("stays on the straight-line path without arc config", () => { + cy.visit("?test=transition-arc&variant=none") + .wait(50) + .get("#indicator") + .should(([$el]: any) => { + expect($el.getBoundingClientRect().top).to.be.closeTo(200, 10) + }) + .get("#toggle") + .click() + .wait(100) + .get("#indicator") + .should(([$el]: any) => { + expect($el.getBoundingClientRect().top).to.be.closeTo(200, 20) + }) + }) + + it("does not arc for movements below the 20px minimum distance", () => { + cy.visit("?test=transition-arc&variant=small") + .wait(50) + .get("#indicator") + .should(([$el]: any) => { + expect($el.getBoundingClientRect().top).to.be.closeTo(200, 10) + }) + .get("#toggle") + .click() + .wait(100) + .get("#indicator") + .should(([$el]: any) => { + expect($el.getBoundingClientRect().top).to.be.closeTo(200, 20) + }) + }) +}) + +/** + * Keyframe arc tests use `?freeze` to pin the animation at midpoint + * (same trick as the layout tests). Click Toggle, wait for the layout + * effect + animation start, then sample. + */ +describe("keyframe arc", () => { + it("deflects y perpendicular while x interpolates", () => { + cy.visit("?test=transition-arc&variant=keyframe&freeze") + .wait(50) + .get("#indicator") + .should(([$el]: any) => { + // Starts at top:200 with no x translation. + const r = $el.getBoundingClientRect() + expect(r.top).to.be.closeTo(200, 5) + expect(r.left).to.be.closeTo(50, 5) + }) + .get("#toggle") + .click() + .wait(100) + .get("#indicator") + .should(([$el]: any) => { + // 400px horizontal, strength=1 → ~200px perpendicular at t=0.5. + // Because the chord is horizontal and dx>0, auto-direction + // bulges +y (downward in screen space). + const r = $el.getBoundingClientRect() + expect(r.left, "x is roughly midway").to.be.closeTo(50 + 200, 25) + expect( + Math.abs(r.top - 200), + "y has deflected ~200px from baseline" + ).to.be.greaterThan(150) + }) + }) + + it("rotate option rotates the element along the curve", () => { + // freezeAt=0.25: at t=0.25 the bezier tangent has clearly diverged + // from the baseline tangent so rotation is non-zero. (At t=0.5 it + // crosses zero by symmetry — see KeyframeArc comments.) + cy.visit("?test=transition-arc&variant=oriented&freezeAt=0.25") + .wait(50) + .get("#toggle") + .click() + .wait(100) + .get("#indicator") + .should(([$el]: any) => { + const t = window.getComputedStyle($el).transform + expect(t, "expected a transform matrix").to.match(/matrix/) + const m = t.match(/matrix\(([^)]+)\)/) + if (!m) throw new Error(`unexpected transform: ${t}`) + // matrix(a, b, c, d, tx, ty). For a pure translate, a=1, b=0. + // For rotation, |b| > 0 and a < 1. + const [a, b] = m[1].split(",").map((v) => parseFloat(v)) + expect( + Math.abs(b), + "y-shear of matrix is non-zero (rotated)" + ).to.be.greaterThan(0.05) + expect(a, "x-scale is less than 1 (rotated)").to.be.lessThan(1) + }) + }) + + it("no rotate option leaves rotation untouched", () => { + cy.visit("?test=transition-arc&variant=keyframe&freeze") + .wait(50) + .get("#toggle") + .click() + .wait(100) + .get("#indicator") + .should(([$el]: any) => { + const t = window.getComputedStyle($el).transform + const m = t.match(/matrix\(([^)]+)\)/) + if (!m) return + const [a, b] = m[1].split(",").map((v) => parseFloat(v)) + // Pure translate: a=1, b=0. Allow tiny float fuzz. + expect(a).to.be.closeTo(1, 0.01) + expect(Math.abs(b)).to.be.lessThan(0.01) + }) + }) +}) + +/** + * An oriented arc running concurrently with a user `rotate` animation. + * `pathRotation` is composed onto the user's `rotate` at the build site, + * so the user's value must never be read or clobbered. + * + * Frozen at t=0.5: pathRotation ~0 by symmetry, x ~mid, and `rotate` + * (0 → 90, eased 0.5) ~45deg. The old clobbering behaviour would leave + * rotation at ~0deg instead. + */ +describe("arc composes with concurrent rotate", () => { + it("does not clobber a user rotate animation", () => { + cy.visit("?test=transition-arc&variant=rotate-compose") + .wait(50) + .get("#toggle") + .click() + .wait(100) + .get("#indicator") + .should(([$el]: any) => { + const t = window.getComputedStyle($el).transform + const m = t.match(/matrix\(([^)]+)\)/) + if (!m) throw new Error(`expected a matrix, got: ${t}`) + const [a, b, , , tx] = m[1] + .split(",") + .map((v) => parseFloat(v)) + // x interpolated to ~mid (0 → 400, eased 0.5). + expect(tx, "x is roughly midway").to.be.closeTo(200, 40) + // Decompose rotation from matrix(a,b,...). Should be ~45deg + // from the user's rotate; clobbering would give ~0deg. + const angle = (Math.atan2(b, a) * 180) / Math.PI + expect( + angle, + "user rotate (45deg) survived alongside the arc" + ).to.be.closeTo(45, 12) + }) + }) +}) diff --git a/packages/framer-motion/cypress/integration/use-scroll-target-late-ref.ts b/packages/framer-motion/cypress/integration/use-scroll-target-late-ref.ts new file mode 100644 index 0000000000..15069e5d1d --- /dev/null +++ b/packages/framer-motion/cypress/integration/use-scroll-target-late-ref.ts @@ -0,0 +1,31 @@ +describe("useScroll with target ref hydrated after useScroll's own effects", () => { + it("Tracks the target element, not the whole window", () => { + // Target sits 1000px below the top in a 2000px-tall page (500px + // viewport). At scrollY=400 the target is still off-screen, so + // progress must be ~0. If useScroll falls back to whole-window + // tracking it settles at ~0.27; if the accelerated (ViewTimeline) + // path mistracks a late-hydrated ref it settles at ~0.36. + cy.visit("?test=use-scroll-target-late-ref").viewport(100, 500) + + // Wait for the nested ReactDOM root to mount. Assert exactly one + // instance: if StrictMode's double-invoke leaves two trees, + // fail loudly here rather than silently reading the stale, + // window-tracking one. + cy.get("#target").should("have.length", 1) + cy.get("#progress").should("have.length", 1) + + cy.scrollTo(0, 400) + + // The buggy value does not appear immediately — useScroll starts at + // 0 and only jumps to the wrong value once its (mis)attached scroll + // timeline first reports. A bare `.should` would pass on that + // initial 0 before the bug surfaces (the original false pass that + // masked this on React 19). Let it settle, THEN assert; `.should` + // still retries afterwards, so a correct 0 is caught immediately + // and a regressed ~0.36/~0.27 fails for the full retry window. + cy.wait(1000) + cy.get("#progress").should(([$el]: any) => { + expect(parseFloat($el.innerText)).to.be.lessThan(0.05) + }) + }) +}) diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 95212f706f..c21796bdf0 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,12 +1,12 @@ { "name": "framer-motion", - "version": "12.38.0", + "version": "12.40.0", "description": "A simple and powerful JavaScript animation library", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", "exports": { ".": { - "types": "./dist/types/index.d.ts", + "types": "./dist/index.d.ts", "require": "./dist/cjs/index.js", "import": "./dist/es/index.mjs", "default": "./dist/cjs/index.js" @@ -30,7 +30,7 @@ "default": "./dist/cjs/dom.js" }, "./client": { - "types": "./dist/types/client.d.ts", + "types": "./dist/client.d.ts", "require": "./dist/cjs/client.js", "import": "./dist/es/client.mjs", "default": "./dist/cjs/client.js" @@ -54,7 +54,7 @@ }, "./package.json": "./package.json" }, - "types": "dist/types/index.d.ts", + "types": "dist/index.d.ts", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion/", @@ -88,8 +88,8 @@ "measure": "rollup -c ./rollup.size.config.mjs" }, "dependencies": { - "motion-dom": "^12.38.0", - "motion-utils": "^12.36.0", + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "devDependencies": { diff --git a/packages/framer-motion/rollup.config.mjs b/packages/framer-motion/rollup.config.mjs index 2e4452044f..1b9aa9774b 100644 --- a/packages/framer-motion/rollup.config.mjs +++ b/packages/framer-motion/rollup.config.mjs @@ -113,7 +113,15 @@ const umdDomProd = createUmd("lib/dom.js", `dist/dom.js`) const umdDomMiniProd = createUmd("lib/dom-mini.js", `dist/dom-mini.js`) const cjs = Object.assign({}, config, { - input: ["lib/index.js", "lib/client.js"], + /** + * `lib/m.js` is bundled here alongside `lib/index.js` so that the React + * contexts (LazyContext, MotionContext, etc.) are emitted in a shared + * chunk and not duplicated across the two CJS entry points. Without this, + * `` from the main entry can't communicate with `` + * from the `/m` subpath because each CJS bundle would have its own + * `createContext()` instance. See issue #3091. + */ + input: ["lib/index.js", "lib/client.js", "lib/m.js"], output: { entryFileNames: `[name].js`, dir: "dist/cjs", @@ -139,7 +147,6 @@ const cjsDebug = Object.assign({}, cjs, { input : "lib/debug.js" }) const cjsDom = Object.assign({}, cjs, { input : "lib/dom.js" }) const cjsMini = Object.assign({}, cjs, { input : "lib/mini.js" }) const cjsDomMini = Object.assign({}, cjs, { input : "lib/dom-mini.js" }) -const cjsM = Object.assign({}, cjs, { input : "lib/m.js" }) export const es = Object.assign({}, config, { input: ["lib/index.js", "lib/mini.js", "lib/debug.js", "lib/dom.js", "lib/dom-mini.js", "lib/client.js", "lib/m.js","lib/projection.js"], @@ -163,18 +170,7 @@ export const es = Object.assign({}, config, { const typePlugins = [dts({compilerOptions: {...tsconfig, baseUrl:"types"}})] -const types = { - input: ["types/index.d.ts", "types/client.d.ts"], - output: { - format: "es", - entryFileNames: "[name].d.ts", - dir: "dist", - }, - plugins: typePlugins, -} - - -function createTypes(input, file) { +function createTypes(input, file) { return { input, output: { @@ -185,7 +181,8 @@ function createTypes(input, file) { } } - +const indexTypes = createTypes("types/index.d.ts", "dist/index.d.ts") +const clientTypes = createTypes("types/client.d.ts", "dist/client.d.ts") const miniTypes = createTypes("types/mini.d.ts", "dist/mini.d.ts") const debugTypes = createTypes("types/debug.d.ts", "dist/debug.d.ts") const animateTypes = createTypes("types/dom.d.ts", "dist/dom.d.ts") @@ -205,9 +202,9 @@ export default [ cjsMini, cjsDom, cjsDomMini, - cjsM, es, - types, + indexTypes, + clientTypes, debugTypes, mTypes, miniTypes, diff --git a/packages/framer-motion/scripts/check-bundle.js b/packages/framer-motion/scripts/check-bundle.js index 429a43d6f7..70287309e8 100644 --- a/packages/framer-motion/scripts/check-bundle.js +++ b/packages/framer-motion/scripts/check-bundle.js @@ -1,11 +1,59 @@ const path = require("path") -const { readFileSync } = require("fs") +const { existsSync, readFileSync } = require("fs") +const pkg = require("../package.json") -const file = readFileSync( - path.join(__dirname, "../", "dist", "dom.d.ts"), - "utf8" -) +const dist = path.join(__dirname, "..", "dist") + +const file = readFileSync(path.join(dist, "dom.d.ts"), "utf8") if (file.includes(``)) { throw new Error("DOM bundle includes reference to React") } + +/** + * Verify every "types" entry in package.json exports points to a file that + * exists, that the bundle is self-contained (no relative imports of internal + * chunks — #2900), and that it doesn't inline its own `declare class + * MotionValue` (two inlined declarations have nominally-distinct `private + * current` fields, breaking assignability between entry points like + * `motion/react` and `motion/react-m` — #2887). + */ +for (const [name, entry] of Object.entries(pkg.exports)) { + if (!entry || typeof entry !== "object" || !entry.types) continue + + const typesPath = path.join(__dirname, "..", entry.types) + if (!existsSync(typesPath)) { + throw new Error( + `Types file for "${name}" (${entry.types}) does not exist` + ) + } + + const contents = readFileSync(typesPath, "utf8") + const relativeImport = contents.match(/from ['"](\.[^'"]+)['"]/) + if (relativeImport) { + throw new Error( + `Types file for "${name}" (${entry.types}) contains a relative import (${relativeImport[1]}) — types must be bundled into a single self-contained file` + ) + } + + if (/^declare class MotionValue\b/m.test(contents)) { + throw new Error( + `Types file for "${name}" (${entry.types}) inlines \`declare class MotionValue\` instead of importing it from motion-dom — this breaks MotionValue assignability across entry points (#2887)` + ) + } +} + +/** + * Verify that the CJS m bundle does not declare its own React contexts. If it + * does, `` from the main entry can't communicate with `` + * because each CJS bundle would have a separate `createContext()` instance + * (#3091). The shared CJS chunk emitted from the rollup `cjs` build must + * supply `LazyContext`, `MotionContext` etc. to both `index.js` and `m.js`. + */ +const cjsM = readFileSync(path.join(dist, "cjs", "m.js"), "utf8") +const ownLazyContext = cjsM.match(/createContext\(\{ strict: false \}\)/g) +if (ownLazyContext) { + throw new Error( + `CJS m bundle (dist/cjs/m.js) defines its own LazyContext (${ownLazyContext.length} time(s)) instead of importing it from the shared chunk — this breaks LazyMotion + m component interop across CJS bundles (#3091)` + ) +} diff --git a/packages/framer-motion/src/animation/animate/index.ts b/packages/framer-motion/src/animation/animate/index.ts index 6b23a0f601..5514aac305 100644 --- a/packages/framer-motion/src/animation/animate/index.ts +++ b/packages/framer-motion/src/animation/animate/index.ts @@ -25,6 +25,7 @@ function isSequence(value: unknown): value is AnimationSequence { interface ScopedAnimateOptions { scope?: AnimationScope reduceMotion?: boolean + skipAnimations?: boolean } /** @@ -32,7 +33,7 @@ interface ScopedAnimateOptions { * to a specific element. */ export function createScopedAnimate(options: ScopedAnimateOptions = {}) { - const { scope, reduceMotion } = options + const { scope, reduceMotion, skipAnimations } = options /** * Animate a sequence */ @@ -109,6 +110,12 @@ export function createScopedAnimate(options: ScopedAnimateOptions = {}) { let animations: AnimationPlaybackControlsWithThen[] = [] let animationOnComplete: VoidFunction | undefined + const inherited: { reduceMotion?: boolean; skipAnimations?: boolean } = + {} + if (reduceMotion !== undefined) inherited.reduceMotion = reduceMotion + if (skipAnimations !== undefined) + inherited.skipAnimations = skipAnimations + if (isSequence(subjectOrSequence)) { const { onComplete, ...sequenceOptions } = (optionsOrKeyframes as SequenceOptions) || {} @@ -117,9 +124,7 @@ export function createScopedAnimate(options: ScopedAnimateOptions = {}) { } animations = animateSequence( subjectOrSequence, - reduceMotion !== undefined - ? { reduceMotion, ...sequenceOptions } - : (sequenceOptions as SequenceOptions), + { ...inherited, ...sequenceOptions } as SequenceOptions, scope ) } else { @@ -131,9 +136,7 @@ export function createScopedAnimate(options: ScopedAnimateOptions = {}) { animations = animateSubject( subjectOrSequence as ElementOrSelector, optionsOrKeyframes as DOMKeyframesDefinition, - (reduceMotion !== undefined - ? { reduceMotion, ...rest } - : rest) as DynamicAnimationOptions, + { ...inherited, ...rest } as DynamicAnimationOptions, scope ) } diff --git a/packages/framer-motion/src/animation/hooks/__tests__/use-animate.test.tsx b/packages/framer-motion/src/animation/hooks/__tests__/use-animate.test.tsx index 02d71fd693..66f9dded24 100644 --- a/packages/framer-motion/src/animation/hooks/__tests__/use-animate.test.tsx +++ b/packages/framer-motion/src/animation/hooks/__tests__/use-animate.test.tsx @@ -1,6 +1,7 @@ import "@testing-library/jest-dom" import { render } from "@testing-library/react" import { useEffect } from "react" +import { MotionConfig } from "../../../components/MotionConfig" import { useAnimate } from "../use-animate" describe("useAnimate", () => { @@ -115,4 +116,34 @@ describe("useAnimate", () => { expect(frameCount).toEqual(3) }) + + test("Applies final value instantly when MotionConfig skipAnimations is true", () => { + return new Promise((resolve) => { + const Component = () => { + const [scope, animate] = useAnimate() + + useEffect(() => { + animate( + scope.current, + { opacity: 0.5 }, + { duration: 10 } + ) + + setTimeout(() => { + expect(scope.current).toHaveStyle("opacity: 0.5;") + expect(scope.animations.length).toBe(0) + resolve() + }, 50) + }) + + return
+ } + + render( + + + + ) + }) + }) }) diff --git a/packages/framer-motion/src/animation/hooks/use-animate.ts b/packages/framer-motion/src/animation/hooks/use-animate.ts index e7b76d28dd..b858e9aee1 100644 --- a/packages/framer-motion/src/animation/hooks/use-animate.ts +++ b/packages/framer-motion/src/animation/hooks/use-animate.ts @@ -1,10 +1,11 @@ "use client" -import { useMemo } from "react" +import { useContext, useMemo } from "react" import { AnimationScope } from "motion-dom" import { useConstant } from "../../utils/use-constant" import { useUnmountEffect } from "../../utils/use-unmount-effect" import { useReducedMotionConfig } from "../../utils/reduced-motion/use-reduced-motion-config" +import { MotionConfigContext } from "../../context/MotionConfigContext" import { createScopedAnimate } from "../animate" export function useAnimate() { @@ -14,10 +15,11 @@ export function useAnimate() { })) const reduceMotion = useReducedMotionConfig() ?? undefined + const { skipAnimations } = useContext(MotionConfigContext) const animate = useMemo( - () => createScopedAnimate({ scope, reduceMotion }), - [scope, reduceMotion] + () => createScopedAnimate({ scope, reduceMotion, skipAnimations }), + [scope, reduceMotion, skipAnimations] ) useUnmountEffect(() => { diff --git a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts index c47d885c10..d13fbef711 100644 --- a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts +++ b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts @@ -730,7 +730,44 @@ describe("createAnimationsFromSequence", () => { expect(transition.y.times).toEqual([0, 0.5, 1]) }) - test.skip("It correctly adds repeatDelay between repeated keyframes", () => { + describe("unsupported repeat options on segments (#2915)", () => { + let warn: jest.SpyInstance + beforeEach(() => { + warn = jest + .spyOn(console, "warn") + .mockImplementation(() => {}) + }) + afterEach(() => { + warn.mockRestore() + }) + + test.each([Infinity, 50])( + "ignores repeat=%s on a segment with a warning", + (count) => { + const animations = createAnimationsFromSequence( + [ + [ + a, + { x: [0, 100] }, + { duration: 1, repeat: count, ease: "linear" }, + ], + ], + undefined, + undefined, + { spring } + ) + + // Segment plays once; no repeat is smuggled onto the final transition. + expect(animations.get(a)!.keyframes.x).toEqual([0, 100]) + expect(animations.get(a)!.transition.x.repeat).toBeUndefined() + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("Sequence segments can't repeat") + ) + } + ) + }) + + test("It correctly adds repeatDelay between repeated keyframes", () => { const animations = createAnimationsFromSequence( [ [ @@ -750,7 +787,7 @@ describe("createAnimationsFromSequence", () => { expect(times).toEqual([0, 0.4, 0.6, 0.6, 1]) }) - test.skip("It correctly mirrors repeated keyframes", () => { + test("It correctly mirrors repeated keyframes", () => { const animations = createAnimationsFromSequence( [ [ @@ -772,7 +809,7 @@ describe("createAnimationsFromSequence", () => { expect(times).toEqual([0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1]) }) - test.skip("It correctly reverses repeated keyframes", () => { + test("It correctly reverses repeated keyframes", () => { const animations = createAnimationsFromSequence( [ [ @@ -794,6 +831,53 @@ describe("createAnimationsFromSequence", () => { expect(times).toEqual([0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1]) }) + test("Reverse applies reverseEasing to function easings on flipped iterations", () => { + const forwardEase = (p: number) => p * p + const animations = createAnimationsFromSequence( + [ + [ + a, + { x: [0, 100] }, + { duration: 1, repeat: 1, repeatType: "reverse", ease: forwardEase }, + ], + ], + undefined, + undefined, + { spring } + ) + + // Keyframes [0, 100, 100, 0]. Segment from index 2→3 is the reversed + // iteration's actual movement (100→0). Its easing is at index 2. + const easeArray = animations.get(a)!.transition.x.ease as Easing[] + const reversedSegmentEase = easeArray[2] as (p: number) => number + // reverseEasing(f)(p) = 1 - f(1 - p). With f(p) = p*p, expect 1 - (1-p)^2. + expect(reversedSegmentEase(0.25)).toBeCloseTo(1 - Math.pow(1 - 0.25, 2)) + expect(reversedSegmentEase(0.75)).toBeCloseTo(1 - Math.pow(1 - 0.75, 2)) + }) + + test("Mirror keeps original easings on flipped iterations (matches JSAnimation runtime)", () => { + const forwardEase = (p: number) => p * p + const animations = createAnimationsFromSequence( + [ + [ + a, + { x: [0, 100] }, + { duration: 1, repeat: 1, repeatType: "mirror", ease: forwardEase }, + ], + ], + undefined, + undefined, + { spring } + ) + + // Mirror matches JSAnimation's mirroredGenerator: reversed + // keyframes with the same easing applied as-is. Segment from + // index 2→3 (the flipped iteration's 100→0 movement) should use + // the original forwardEase, NOT a modified curve. + const easeArray = animations.get(a)!.transition.x.ease as Easing[] + expect(easeArray[2]).toBe(forwardEase) + }) + test("Spring defaultTransition does not leak type into multi-element sequence (#3158)", () => { const img = document.createElement("img") const h1 = document.createElement("h1") diff --git a/packages/framer-motion/src/animation/sequence/create.ts b/packages/framer-motion/src/animation/sequence/create.ts index 07ead3c85b..c44acba5c6 100644 --- a/packages/framer-motion/src/animation/sequence/create.ts +++ b/packages/framer-motion/src/animation/sequence/create.ts @@ -16,9 +16,10 @@ import { import { Easing, getEasingForSegment, - invariant, progress, + reverseEasing, secondsToMilliseconds, + warning, } from "motion-utils" import { resolveSubjects } from "../animate/resolve-subjects" import { @@ -195,14 +196,25 @@ export function createAnimationsFromSequence( valueKeyframesAsList.unshift(null) /** - * Handle repeat options + * Segments can't express `repeat: Infinity` or very large + * counts — they'd leave dead time after the segment or + * explode the keyframe array. Ignore with a warning. */ if (repeat) { - invariant( + warning( repeat < MAX_REPEAT, - "Repeat count too high, must be less than 20", - "repeat-count-high" + `Sequence segments can't repeat ${repeat} times — ignoring repeat option. Use a value below ${MAX_REPEAT} or apply repeat at the sequence level instead.` ) + } + + if (repeat && repeat < MAX_REPEAT) { + /** + * Express repeatDelay in units of a single iteration's duration + * so it can be added to the per-iteration time offsets below + * before they're normalized to 0-1. + */ + const repeatDelayUnits = + duration > 0 ? repeatDelay / duration : 0 duration = calculateRepeatDuration( duration, @@ -215,29 +227,82 @@ export function createAnimationsFromSequence( ease = Array.isArray(ease) ? [...ease] : [ease] const originalEase = [...ease] - for (let repeatIndex = 0; repeatIndex < repeat; repeatIndex++) { - valueKeyframesAsList.push(...originalKeyframes) + /** + * For reverse/mirror, alternate iterations play the segment + * backwards. mirror matches JSAnimation's mirroredGenerator: + * reversed keyframes, easings unchanged. reverse matches + * JSAnimation's iterationProgress = 1 - p: reversed + * keyframes, easing array reversed AND each function easing + * mapped through reverseEasing (string easings unchanged — + * they're resolved later by the keyframes engine). + */ + const isFlipping = + repeatType === "reverse" || repeatType === "mirror" + let flippedKeyframes = originalKeyframes + let flippedEases = originalEase + if (isFlipping) { + flippedKeyframes = [...originalKeyframes].reverse() + if (repeatType === "reverse") { + flippedEases = [...originalEase] + .reverse() + .map((e) => + typeof e === "function" + ? reverseEasing(e) + : e + ) + } + } + + for ( + let repeatIndex = 0; + repeatIndex < repeat; + repeatIndex++ + ) { + const isFlipped = isFlipping && repeatIndex % 2 === 0 + const iterKeyframes = isFlipped + ? flippedKeyframes + : originalKeyframes + const iterEase = isFlipped ? flippedEases : originalEase + const iterStartOffset = + (repeatIndex + 1) * (1 + repeatDelayUnits) + + /** + * If repeatDelay is set, hold the previous iteration's + * final value through the delay by inserting a keyframe + * at the moment the next iteration begins. + */ + if (repeatDelayUnits > 0) { + valueKeyframesAsList.push( + valueKeyframesAsList[ + valueKeyframesAsList.length - 1 + ] + ) + times.push(iterStartOffset) + ease.push("linear") + } + + valueKeyframesAsList.push(...iterKeyframes) for ( let keyframeIndex = 0; - keyframeIndex < originalKeyframes.length; + keyframeIndex < iterKeyframes.length; keyframeIndex++ ) { times.push( - originalTimes[keyframeIndex] + (repeatIndex + 1) + originalTimes[keyframeIndex] + iterStartOffset ) ease.push( keyframeIndex === 0 ? "linear" : getEasingForSegment( - originalEase, + iterEase, keyframeIndex - 1 ) ) } } - normalizeTimes(times, repeat) + normalizeTimes(times, repeat, repeatDelayUnits) } const targetTime = startTime + duration diff --git a/packages/framer-motion/src/animation/sequence/types.ts b/packages/framer-motion/src/animation/sequence/types.ts index 226c61b122..ca76397fb5 100644 --- a/packages/framer-motion/src/animation/sequence/types.ts +++ b/packages/framer-motion/src/animation/sequence/types.ts @@ -118,6 +118,7 @@ export interface SequenceOptions extends AnimationPlaybackOptions { duration?: number defaultTransition?: Transition reduceMotion?: boolean + skipAnimations?: boolean onComplete?: () => void } diff --git a/packages/framer-motion/src/animation/sequence/utils/__tests__/calc-repeat-duration.test.ts b/packages/framer-motion/src/animation/sequence/utils/__tests__/calc-repeat-duration.test.ts index a5195c3c0e..2820ed56bd 100644 --- a/packages/framer-motion/src/animation/sequence/utils/__tests__/calc-repeat-duration.test.ts +++ b/packages/framer-motion/src/animation/sequence/utils/__tests__/calc-repeat-duration.test.ts @@ -6,4 +6,10 @@ describe("calculateRepeatDuration", () => { expect(calculateRepeatDuration(1, 1, 0)).toEqual(2) expect(calculateRepeatDuration(1, 2, 0)).toEqual(3) }) + + test("It includes repeatDelay between iterations", () => { + expect(calculateRepeatDuration(1, 1, 0.5)).toEqual(2.5) + expect(calculateRepeatDuration(2, 3, 1)).toEqual(11) + expect(calculateRepeatDuration(1, 0, 0.5)).toEqual(1) + }) }) diff --git a/packages/framer-motion/src/animation/sequence/utils/__tests__/normalize-times.test.ts b/packages/framer-motion/src/animation/sequence/utils/__tests__/normalize-times.test.ts index fb0d130de0..cf712d7dfb 100644 --- a/packages/framer-motion/src/animation/sequence/utils/__tests__/normalize-times.test.ts +++ b/packages/framer-motion/src/animation/sequence/utils/__tests__/normalize-times.test.ts @@ -7,4 +7,10 @@ describe("normalizeTimes", () => { normalizeTimes(times, repeat) expect(times).toEqual([0, 0.25, 0.5, 0.5, 0.75, 1]) }) + + test("It accounts for repeatDelay between iterations", () => { + const times = [0, 1, 1.5, 1.5, 2.5] + normalizeTimes(times, 1, 0.5) + expect(times).toEqual([0, 0.4, 0.6, 0.6, 1]) + }) }) diff --git a/packages/framer-motion/src/animation/sequence/utils/calc-repeat-duration.ts b/packages/framer-motion/src/animation/sequence/utils/calc-repeat-duration.ts index fc29deffa0..396266593b 100644 --- a/packages/framer-motion/src/animation/sequence/utils/calc-repeat-duration.ts +++ b/packages/framer-motion/src/animation/sequence/utils/calc-repeat-duration.ts @@ -1,7 +1,7 @@ export function calculateRepeatDuration( duration: number, repeat: number, - _repeatDelay: number + repeatDelay: number ): number { - return duration * (repeat + 1) + return duration * (repeat + 1) + repeatDelay * repeat } diff --git a/packages/framer-motion/src/animation/sequence/utils/normalize-times.ts b/packages/framer-motion/src/animation/sequence/utils/normalize-times.ts index 58655e4898..bf6b2ac7a6 100644 --- a/packages/framer-motion/src/animation/sequence/utils/normalize-times.ts +++ b/packages/framer-motion/src/animation/sequence/utils/normalize-times.ts @@ -3,9 +3,17 @@ * if we have original times of [0, 0.5, 1] then our repeated times will * be [0, 0.5, 1, 1, 1.5, 2]. Loop over the times and scale them back * down to a 0-1 scale. + * + * `repeatDelayUnits` is the repeatDelay expressed in units of a single + * iteration's duration, so the total span equals `(repeat + 1) + repeat * repeatDelayUnits`. */ -export function normalizeTimes(times: number[], repeat: number): void { +export function normalizeTimes( + times: number[], + repeat: number, + repeatDelayUnits = 0 +): void { + const totalUnits = repeat + 1 + repeat * repeatDelayUnits for (let i = 0; i < times.length; i++) { - times[i] = times[i] / (repeat + 1) + times[i] = times[i] / totalUnits } } diff --git a/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx b/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx index 1eb727a4ff..aaf586874d 100644 --- a/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx @@ -14,6 +14,7 @@ interface Size { left: number right: number bottom: number + direction: string } interface Props { @@ -54,6 +55,7 @@ class PopChildMeasure extends React.Component { size.left = element.offsetLeft size.right = parentWidth - size.width - size.left size.bottom = parentHeight - size.height - size.top + size.direction = computedStyle.direction } return null @@ -79,6 +81,7 @@ export function PopChild({ children, isPresent, anchorX, anchorY, root, pop }: P left: 0, right: 0, bottom: 0, + direction: "ltr", }) const { nonce } = useContext(MotionConfigContext) /** @@ -100,10 +103,13 @@ export function PopChild({ children, isPresent, anchorX, anchorY, root, pop }: P * styles set via the style prop. */ useInsertionEffect(() => { - const { width, height, top, left, right, bottom } = size.current + const { width, height, top, left, right, bottom, direction } = size.current if (isPresent || pop === false || !ref.current || !width || !height) return - const x = anchorX === "left" ? `left: ${left}` : `right: ${right}` + const isRTL = direction === "rtl" + const x = anchorX === "left" + ? (isRTL ? `right: ${right}` : `left: ${left}`) + : (isRTL ? `left: ${left}` : `right: ${right}`) const y = anchorY === "bottom" ? `bottom: ${bottom}` : `top: ${top}` ref.current.dataset.motionPopId = id 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 cfeda6a164..29a357b154 100644 --- a/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx @@ -1502,6 +1502,72 @@ describe("AnimatePresence with custom components", () => { expect(enterCustomValues).toContain(1) }) + test("Re-entering child with object-form initial resets to initial values when exit was complete", async () => { + const opacity = motionValue(1) + const opacityChanges: number[] = [] + + opacity.on("change", (v) => { + opacityChanges.push(v) + }) + + const Component = ({ + showA, + showB, + }: { + showA: boolean + showB: boolean + }) => { + return ( + + {showA && ( + + )} + {showB && ( + + )} + + ) + } + + const { rerender } = render() + await act(async () => { + await nextFrame() + }) + + await act(async () => { + rerender() + }) + await act(async () => { + await nextFrame() + }) + + expect(opacity.get()).toBe(0) + + opacityChanges.length = 0 + await act(async () => { + rerender() + }) + await act(async () => { + await nextFrame() + }) + + expect(opacityChanges.length).toBeGreaterThan(0) + expect(opacityChanges[0]).toBe(0.5) + }) + test("Does not get stuck when state changes cause rapid key alternation in mode='wait'", async () => { /** * Reproduction from #3141: A loading/loaded pattern where diff --git a/packages/framer-motion/src/components/Reorder/Group.tsx b/packages/framer-motion/src/components/Reorder/Group.tsx index 930e07acec..2581d807a3 100644 --- a/packages/framer-motion/src/components/Reorder/Group.tsx +++ b/packages/framer-motion/src/components/Reorder/Group.tsx @@ -175,10 +175,16 @@ export function ReorderGroupComponent< } export const ReorderGroup = /*@__PURE__*/ forwardRef(ReorderGroupComponent) as < - V, + Values extends any[], TagName extends ReorderElementTag = DefaultGroupElement >( - props: ReorderGroupProps & { ref?: React.ForwardedRef } + props: Omit< + ReorderGroupProps, + "values" | "onReorder" + > & { + values: Values + onReorder: (newOrder: Values) => void + } & { ref?: React.ForwardedRef } ) => ReturnType function compareMin(a: ItemData, b: ItemData) { diff --git a/packages/framer-motion/src/components/Reorder/__tests__/index.test.tsx b/packages/framer-motion/src/components/Reorder/__tests__/index.test.tsx index 06e7756bea..93132e4ae9 100644 --- a/packages/framer-motion/src/components/Reorder/__tests__/index.test.tsx +++ b/packages/framer-motion/src/components/Reorder/__tests__/index.test.tsx @@ -1,9 +1,36 @@ -import { useContext, useLayoutEffect, useRef } from "react" +import { useContext, useLayoutEffect, useRef, useState } from "react" import { Reorder } from ".." import { ReorderContext } from "../../../context/ReorderContext" import { render } from "../../../jest.setup" describe("Reorder", () => { + it("Accepts union array types for values prop", () => { + const Component = () => { + const [items, setItems] = useState([ + "a", + "b", + "c", + ]) + + return ( + + {items.map((item) => ( + + {item} + + ))} + + ) + } + + const { container } = render() + expect(container.querySelectorAll("li")).toHaveLength(3) + }) + it("Correctly hydrates ref", () => { let groupRefPass = false let itemRefPass = false diff --git a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts index 1dde45c74d..663104562c 100644 --- a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts +++ b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts @@ -410,6 +410,21 @@ export class VisualElementDragControls { // TODO if (!projection || !projection.layout) return false + /** + * Refresh the root scroll offset so the constraint's viewport box + * translates to correct page coordinates. The scroll captured at + * drag mount can be stale if the document was scrolled afterwards — + * e.g. via the browser restoring scroll on refresh, or an ancestor + * layout effect running after this element's mount (#2829). + * + * Clear the cached scroll first so `updateScroll` bypasses its + * per-animationId cache and re-reads the live value. + */ + if (projection.root) { + projection.root.scroll = undefined + projection.root.updateScroll() + } + const constraintsBox = measurePageBox( constraintsElement, projection.root!, @@ -536,9 +551,7 @@ export class VisualElementDragControls { ? externalMotionValue : this.visualElement.getValue( axis, - (props.initial - ? props.initial[axis as keyof typeof props.initial] - : undefined) || 0 + this.visualElement.latestValues[axis] ?? 0 ) } diff --git a/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx b/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx index 75aea1ea18..d780b3a0d9 100644 --- a/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx +++ b/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx @@ -1,5 +1,6 @@ import { useState } from "react" import { + AnimatePresence, BoundingBox, motion, motionValue, @@ -1216,3 +1217,35 @@ describe("keyboard accessible elements", () => { expect(x.get()).toBeGreaterThanOrEqual(100) }) }) + +describe("drag with AnimatePresence initial={false}", () => { + test("drag starts from animate value, not initial value", async () => { + const Component = () => ( + + + + + + ) + + const { getByTestId, rerender } = render() + rerender() + + const pointer = await drag(getByTestId("draggable")).to(0, 10) + + // After dragging down 10px, transform should reflect y = 10 + // (drag offset from animate.y = 0), not y = 210 (drag offset from + // initial.y = 200). + expect(getByTestId("draggable")).toHaveStyle( + "transform: translateY(10px)" + ) + + pointer.end() + }) +}) diff --git a/packages/framer-motion/src/index.ts b/packages/framer-motion/src/index.ts index 8cba96369d..4ab4be8d46 100644 --- a/packages/framer-motion/src/index.ts +++ b/packages/framer-motion/src/index.ts @@ -142,7 +142,8 @@ export type { MotionTransform, VariantLabels, } from "./motion/types" -export type { IProjectionNode } from "motion-dom" +export { arc } from "motion-dom" +export type { ArcOptions, IProjectionNode, MotionPath } from "motion-dom" export type { DOMMotionComponents } from "./render/dom/types" export type { ForwardRefComponent, HTMLMotionProps } from "./render/html/types" export type { diff --git a/packages/framer-motion/src/motion/__tests__/transition-keyframes.test.tsx b/packages/framer-motion/src/motion/__tests__/transition-keyframes.test.tsx index 83b7e46910..e0239008fb 100644 --- a/packages/framer-motion/src/motion/__tests__/transition-keyframes.test.tsx +++ b/packages/framer-motion/src/motion/__tests__/transition-keyframes.test.tsx @@ -85,7 +85,7 @@ describe("keyframes transition", () => { expect(xResult).toBe(100) }) - test("keyframes animation doesn't rerun when variants change and keyframes are the same", async () => { + test("keyframes animation reruns when variants change and keyframes are the same", async () => { const xResult = await new Promise((resolve) => { const x = motionValue(0) const Component = ({ animate }: any) => { @@ -109,7 +109,45 @@ describe("keyframes transition", () => { setTimeout(() => resolve(x.get()), 50) }) - expect(xResult).toBe(100) + expect(xResult).toBe(50) + }) + + test("issue #2855: keyframes with shared values across variants rerun on each change", async () => { + const z = motionValue(0) + const updateCounts: number[] = [] + + const Component = ({ animate }: any) => ( + + ) + + const recordAndReset = async () => { + let count = 0 + const unsubscribe = z.on("change", () => count++) + await new Promise((r) => setTimeout(r, 100)) + unsubscribe() + updateCounts.push(count) + } + + const { rerender } = render() + await recordAndReset() + + rerender() + await recordAndReset() + + rerender() + await recordAndReset() + + expect(updateCounts[0]).toBeGreaterThan(0) + expect(updateCounts[1]).toBeGreaterThan(0) + expect(updateCounts[2]).toBeGreaterThan(0) }) test("times works as expected", async () => { diff --git a/packages/framer-motion/src/motion/__tests__/unmount-motion-value.test.tsx b/packages/framer-motion/src/motion/__tests__/unmount-motion-value.test.tsx new file mode 100644 index 0000000000..10e9714b5b --- /dev/null +++ b/packages/framer-motion/src/motion/__tests__/unmount-motion-value.test.tsx @@ -0,0 +1,131 @@ +/** + * Regression coverage for issue #3315. + * + * `VisualElement.unmount()` used to synchronously stop animations on any + * motion value it owned. That broke React 19's reorder reconciliation — the + * dragged tile briefly unmounts and remounts, and the synchronous stop + * killed the in-flight `dragSnapToOrigin` animation before remount could + * resubscribe, leaving the drag transform stranded. + * + * These tests pin the new behaviour: the synchronous stop is gone, and the + * deferred auto-stop in `MotionValue.on("change")` cleanup still runs. + */ + +import { createRef } from "react" +import { frame, visualElementStore } from "motion-dom" +import { motion } from "../../render/components/motion" +import { render } from "../../jest.setup" +import { nextFrame } from "../../gestures/__tests__/utils" + +describe("motion value lifecycle on unmount (#3315)", () => { + test("does not synchronously stop animations on VE-owned motion values", async () => { + const ref = createRef() + + const Component = () => ( + + ) + + const { unmount, rerender } = render() + rerender() + await nextFrame() + + const ve = visualElementStore.get(ref.current!)! + const xValue = ve.getValue("x")! + + // Confirm the test setup is exercising a VE-owned motion value + // (this is the only path that hit the old synchronous stop). + expect(xValue.owner).toBe(ve) + expect(xValue.isAnimating()).toBe(true) + + // Subscribe so the deferred auto-stop won't run when the VE + // unmounts — this simulates the React 19 remount case where a new + // VisualElement re-binds to the motion value before the next frame. + const externalSubscriber = jest.fn() + const stopListening = xValue.on("change", externalSubscriber) + + unmount() + + // Pre-#3315 this would be false: VE.unmount called value.stop() + // synchronously and killed the animation. We need it true so a + // remount that re-subscribes can pick the animation up. + expect(xValue.isAnimating()).toBe(true) + + stopListening() + }) + + test("deferred auto-stop fires on next frame when nothing resubscribes", async () => { + const ref = createRef() + + const Component = () => ( + + ) + + const { unmount, rerender } = render() + rerender() + await nextFrame() + + const ve = visualElementStore.get(ref.current!)! + const xValue = ve.getValue("x")! + expect(xValue.isAnimating()).toBe(true) + + unmount() + + // Synchronously, animation is still running... + expect(xValue.isAnimating()).toBe(true) + + // ...but with no listener attached, the deferred auto-stop runs on + // the next frame and the animation is cleaned up. This is the path + // that prevents leaks for genuinely permanent unmounts. + await nextFrame() + expect(xValue.isAnimating()).toBe(false) + }) + + test("animation can survive an unmount-then-resubscribe within a single frame", async () => { + const ref = createRef() + + const Component = () => ( + + ) + + const { unmount, rerender } = render() + rerender() + await nextFrame() + + const ve = visualElementStore.get(ref.current!)! + const xValue = ve.getValue("x")! + expect(xValue.isAnimating()).toBe(true) + + unmount() + + // Simulate a remount re-establishing a change listener before the + // deferred auto-stop callback runs. + const resubscribe = jest.fn() + const stopListening = xValue.on("change", resubscribe) + + // Wait long enough for the deferred frame.read callback to fire. + await nextFrame() + + // Because a listener is now present, the auto-stop must skip and + // the animation should keep ticking. + expect(xValue.isAnimating()).toBe(true) + // The motion value should still be reporting changes after the + // simulated remount. + const valueAfterRemount = xValue.get() + await new Promise((resolve) => frame.postRender(() => resolve())) + expect(xValue.get()).not.toBe(valueAfterRemount) + + stopListening() + }) +}) diff --git a/packages/framer-motion/src/motion/features/animation/exit.ts b/packages/framer-motion/src/motion/features/animation/exit.ts index 5872216ced..a5d3c8b058 100644 --- a/packages/framer-motion/src/motion/features/animation/exit.ts +++ b/packages/framer-motion/src/motion/features/animation/exit.ts @@ -25,7 +25,12 @@ export class ExitAnimationFeature extends Feature { if (this.isExitComplete) { const { initial, custom } = this.node.getProps() - if (typeof initial === "string") { + if ( + typeof initial === "string" || + (typeof initial === "object" && + initial !== null && + !Array.isArray(initial)) + ) { const resolved = resolveVariant( this.node, initial, diff --git a/packages/framer-motion/src/motion/utils/__tests__/use-motion-ref.test.tsx b/packages/framer-motion/src/motion/utils/__tests__/use-motion-ref.test.tsx index c8d932c63f..ba68e1a59a 100644 --- a/packages/framer-motion/src/motion/utils/__tests__/use-motion-ref.test.tsx +++ b/packages/framer-motion/src/motion/utils/__tests__/use-motion-ref.test.tsx @@ -1,6 +1,6 @@ import * as React from "react" import { useRef } from "react" -import { motion } from "../../.." +import { motion, visualElementStore } from "../../.." import { render } from "../../../jest.setup" describe("useMotionRef", () => { @@ -85,6 +85,23 @@ describe("useMotionRef", () => { expect(() => rerender()).not.toThrow() }) + it("should register VisualElement in visualElementStore before external ref callback runs", () => { + let lookedUpVisualElement: unknown = "not-set" + + const refCallback = (instance: HTMLDivElement | null) => { + if (instance) { + lookedUpVisualElement = visualElementStore.get(instance) + } + } + + const Component = () => + + render() + + expect(lookedUpVisualElement).toBeDefined() + expect(lookedUpVisualElement).not.toBeNull() + }) + it("should work with forwardRef components", () => { const cleanup = jest.fn() const refCallback = jest.fn(() => cleanup) diff --git a/packages/framer-motion/src/motion/utils/use-motion-ref.ts b/packages/framer-motion/src/motion/utils/use-motion-ref.ts index 1bbb9054da..5606047551 100644 --- a/packages/framer-motion/src/motion/utils/use-motion-ref.ts +++ b/packages/framer-motion/src/motion/utils/use-motion-ref.ts @@ -35,6 +35,10 @@ export function useMotionRef( visualState.onMount?.(instance) } + if (visualElement) { + instance ? visualElement.mount(instance) : visualElement.unmount() + } + const ref = externalRefContainer.current if (typeof ref === "function") { if (instance) { @@ -51,10 +55,6 @@ export function useMotionRef( } else if (ref) { ;(ref as React.MutableRefObject).current = instance } - - if (visualElement) { - instance ? visualElement.mount(instance) : visualElement.unmount() - } }, [visualElement] ) diff --git a/packages/framer-motion/src/render/dom/scroll/__tests__/index.test.ts b/packages/framer-motion/src/render/dom/scroll/__tests__/index.test.ts index 88da756bcf..151b0f3006 100644 --- a/packages/framer-motion/src/render/dom/scroll/__tests__/index.test.ts +++ b/packages/framer-motion/src/render/dom/scroll/__tests__/index.test.ts @@ -1,4 +1,4 @@ -import { frame } from "motion-dom" +import { frame, supportsFlags } from "motion-dom" import { scroll } from "../" import { ScrollOffset } from "../offsets/presets" import { scrollInfo } from "../track" @@ -834,4 +834,49 @@ describe("scroll", () => { resolve() }) }) + + test("Respects offset when callback has no info parameter (#3668).", async () => { + // JSDOM lacks window.ScrollTimeline; fake it so the native code path + // is taken — that's the path that previously dropped offsets. + class FakeScrollTimeline { + currentTime: { value: number } | null = { value: 30 } + } + const originalScrollTimeline = (window as any).ScrollTimeline + ;(window as any).ScrollTimeline = FakeScrollTimeline + supportsFlags.scrollTimeline = true + + try { + await fireScroll(0) + setWindowHeight(1000) + setDocumentHeight(3000) + + let receivedProgress: number | undefined + + const stopScroll = scroll( + (progress: number) => { + receivedProgress = progress + }, + { offset: [0.5, 1] } + ) + + // scrollLength = 2000. raw progress 600/2000 = 0.3, before offset + // start of 0.5 → clamped to 0. Without the fix, callback would + // receive FakeScrollTimeline.currentTime.value / 100 = 0.3. + await fireScroll(600) + expect(receivedProgress).toBeCloseTo(0) + + // raw 1500/2000 = 0.75 → (0.75 - 0.5) / (1 - 0.5) = 0.5 + await fireScroll(1500) + expect(receivedProgress).toBeCloseTo(0.5) + + stopScroll() + } finally { + supportsFlags.scrollTimeline = undefined + if (originalScrollTimeline === undefined) { + delete (window as any).ScrollTimeline + } else { + ;(window as any).ScrollTimeline = originalScrollTimeline + } + } + }) }) diff --git a/packages/framer-motion/src/render/dom/scroll/__tests__/on-scroll-handler.test.ts b/packages/framer-motion/src/render/dom/scroll/__tests__/on-scroll-handler.test.ts new file mode 100644 index 0000000000..09322fa5e4 --- /dev/null +++ b/packages/framer-motion/src/render/dom/scroll/__tests__/on-scroll-handler.test.ts @@ -0,0 +1,62 @@ +import { createScrollInfo } from "../info" +import { createOnScrollHandler } from "../on-scroll-handler" + +describe("on-scroll-handler static-position warning", () => { + let consoleWarnSpy: jest.SpyInstance + let getComputedStyleSpy: jest.SpyInstance + + beforeEach(() => { + consoleWarnSpy = jest + .spyOn(console, "warn") + .mockImplementation(() => {}) + getComputedStyleSpy = jest + .spyOn(window, "getComputedStyle") + .mockReturnValue({ + position: "static", + } as CSSStyleDeclaration) + }) + + afterEach(() => { + consoleWarnSpy.mockRestore() + getComputedStyleSpy.mockRestore() + }) + + const didWarn = () => + consoleWarnSpy.mock.calls.some((args) => + args.some( + (arg: unknown) => + typeof arg === "string" && + arg.includes("non-static position") + ) + ) + + const measureWith = (container: Element, target: Element) => { + const handler = createOnScrollHandler( + container, + () => {}, + createScrollInfo(), + { target } + ) + handler.measure(0) + } + + test("does not warn when container is document.documentElement", () => { + const target = document.createElement("div") + document.body.appendChild(target) + + measureWith(document.documentElement, target) + + expect(didWarn()).toBe(false) + }) + + test("warns when a custom container has static position", () => { + const container = document.createElement("div") + const target = document.createElement("div") + container.appendChild(target) + document.body.appendChild(container) + + measureWith(container, target) + + expect(didWarn()).toBe(true) + }) +}) diff --git a/packages/framer-motion/src/render/dom/scroll/attach-function.ts b/packages/framer-motion/src/render/dom/scroll/attach-function.ts index 53e0825d81..fa2a4b1c49 100644 --- a/packages/framer-motion/src/render/dom/scroll/attach-function.ts +++ b/packages/framer-motion/src/render/dom/scroll/attach-function.ts @@ -2,6 +2,7 @@ import { observeTimeline } from "motion-dom" import { scrollInfo } from "./track" import { OnScroll, OnScrollWithInfo, ScrollOptionsWithDefaults } from "./types" import { getTimeline } from "./utils/get-timeline" +import { isElementTracking } from "./utils/is-element-tracking" /** * If the onScroll function has two arguments, it's expecting @@ -15,7 +16,7 @@ export function attachToFunction( onScroll: OnScroll, options: ScrollOptionsWithDefaults ) { - if (isOnScrollWithInfo(onScroll)) { + if (isOnScrollWithInfo(onScroll) || isElementTracking(options)) { return scrollInfo((info) => { onScroll(info[options.axis!].progress, info) }, options) diff --git a/packages/framer-motion/src/render/dom/scroll/on-scroll-handler.ts b/packages/framer-motion/src/render/dom/scroll/on-scroll-handler.ts index 4ecf39785c..4909ae1156 100644 --- a/packages/framer-motion/src/render/dom/scroll/on-scroll-handler.ts +++ b/packages/framer-motion/src/render/dom/scroll/on-scroll-handler.ts @@ -36,10 +36,18 @@ function measure( /** * In development mode ensure scroll containers aren't position: static as this makes - * it difficult to measure their relative positions. + * it difficult to measure their relative positions. The document scrolling element + * is exempt: offsetParent measurements naturally resolve relative to the document. */ if (process.env.NODE_ENV !== "production") { - if (container && target && target !== container) { + if ( + container && + target && + target !== container && + container !== document.documentElement && + container !== document.scrollingElement && + container !== document.body + ) { warnOnce( getComputedStyle(container).position !== "static", "Please ensure that the container has a non-static position, like 'relative', 'fixed', or 'absolute' to ensure scroll offset is calculated correctly." diff --git a/packages/framer-motion/src/value/use-scroll.ts b/packages/framer-motion/src/value/use-scroll.ts index caaa62fe35..cde6fc5257 100644 --- a/packages/framer-motion/src/value/use-scroll.ts +++ b/packages/framer-motion/src/value/use-scroll.ts @@ -2,6 +2,8 @@ import { AnimationPlaybackControls, + cancelMicrotask, + microtask, motionValue, supportsScrollTimeline, supportsViewTimeline, @@ -39,13 +41,32 @@ function makeAccelerateConfig( target?: RefObject ) { return { - factory: (animation: AnimationPlaybackControls) => - scroll(animation, { - ...options, - axis, - container: container?.current || undefined, - target: target?.current || undefined, - }), + // Refs attach child-first; defer so target.current is populated + // before scroll() reads it. + factory: (animation: AnimationPlaybackControls) => { + let cleanup: VoidFunction | undefined + const start = () => { + // A provided ref may be hydrated by an effect declared after + // useScroll (or in a parent). Don't attach to the window + // scroll in the meantime — that result gets cached and would + // permanently mistrack. Wait until the ref resolves. + if (isRefPending(container) || isRefPending(target)) { + microtask.read(start) + return + } + cleanup = scroll(animation, { + ...options, + axis, + container: container?.current || undefined, + target: target?.current || undefined, + }) + } + microtask.read(start) + return () => { + cancelMicrotask(start) + cleanup?.() + } + }, times: [0, 1], keyframes: [0, 1], ease: (v: number) => v, @@ -129,20 +150,31 @@ export function useScroll({ }, [start]) useEffect(() => { - if (needsStart.current) { + if (!needsStart.current) return + + // Defer to a microtask so any sibling/parent effect that hydrates the + // ref has a chance to run first. + let cleanup: VoidFunction | undefined + const tryStart = () => { + const containerPending = isRefPending(container) + const targetPending = isRefPending(target) invariant( - !isRefPending(container), + !containerPending, "Container ref is defined but not hydrated", "use-scroll-ref" ) invariant( - !isRefPending(target), + !targetPending, "Target ref is defined but not hydrated", "use-scroll-ref" ) - return start() - } else { - return + if (!containerPending && !targetPending) cleanup = start() + } + microtask.read(tryStart) + + return () => { + cancelMicrotask(tryStart) + cleanup?.() } }, [start]) diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index fb35ffcf32..65bcb1d6ef 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.38.0", + "version": "12.40.0", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", @@ -17,7 +17,7 @@ } }, "dependencies": { - "motion-utils": "^12.36.0" + "motion-utils": "^12.39.0" }, "scripts": { "clean": "rm -rf types dist lib", diff --git a/packages/motion-dom/src/animation/interfaces/motion-value.ts b/packages/motion-dom/src/animation/interfaces/motion-value.ts index f8fc404b07..6112ccba0e 100644 --- a/packages/motion-dom/src/animation/interfaces/motion-value.ts +++ b/packages/motion-dom/src/animation/interfaces/motion-value.ts @@ -100,7 +100,8 @@ export const animateMotionValue = if ( MotionGlobalConfig.instantAnimations || MotionGlobalConfig.skipAnimations || - element?.shouldSkipAnimations + element?.shouldSkipAnimations || + valueTransition.skipAnimations ) { shouldSkip = true makeAnimationInstant(options) 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 8fe4a46ee8..82eb229c27 100644 --- a/packages/motion-dom/src/animation/interfaces/visual-element-target.ts +++ b/packages/motion-dom/src/animation/interfaces/visual-element-target.ts @@ -6,6 +6,7 @@ import { setTarget } from "../../render/utils/setters" import { addValueToWillChange } from "../../value/will-change/add-will-change" import { getOptimisedAppearId } from "../optimized-appear/get-appear-id" import { animateMotionValue } from "./motion-value" +import type { MotionPath } from "../types" import type { VisualElementAnimationOptions } from "./types" import type { AnimationPlaybackControlsWithThen } from "../types" import type { TargetAndTransition } from "../../node/types" @@ -46,6 +47,7 @@ export function animateTarget( : defaultTransition const reduceMotion = (transition as { reduceMotion?: boolean })?.reduceMotion + const skipAnimations = transition?.skipAnimations if (transitionOverride) transition = transitionOverride @@ -56,6 +58,18 @@ export function animateTarget( visualElement.animationState && visualElement.animationState.getState()[type] + const path = (transition as { path?: MotionPath } | undefined)?.path + if (path) { + // path mutates `target` to claim x/y; loop below skips them. + path.animateVisualElement( + visualElement, + target, + transition, + delay, + animations + ) + } + for (const key in target) { const value = visualElement.getValue( key, @@ -76,6 +90,8 @@ export function animateTarget( ...getValueTransition(transition || {}, key), } + if (skipAnimations) valueTransition.skipAnimations = true + /** * If the value is already at the defined target, skip the animation. * We still re-assert the value via frame.update to take precedence diff --git a/packages/motion-dom/src/animation/types.ts b/packages/motion-dom/src/animation/types.ts index 801584dade..e5b70c9ecd 100644 --- a/packages/motion-dom/src/animation/types.ts +++ b/packages/motion-dom/src/animation/types.ts @@ -1,5 +1,8 @@ import { Easing } from "motion-utils" +import type { Delta } from "motion-utils" +import type { TargetAndTransition } from "../node/types" import { SVGAttributes } from "../render/svg/types" +import type { VisualElement } from "../render/VisualElement" import { MotionValue } from "../value" import { Driver } from "./drivers/types" import { KeyframeResolver } from "./keyframes/KeyframesResolver" @@ -277,6 +280,8 @@ export interface SpringOptions extends DurationSpringOptions, VelocityOptions { * Stiffness of the spring. Higher values will create more sudden movement. * Set to `100` by default. * + * @default 100 + * * @public */ stiffness?: number @@ -285,6 +290,8 @@ export interface SpringOptions extends DurationSpringOptions, VelocityOptions { * Strength of opposing force. If set to 0, spring will oscillate * indefinitely. Set to `10` by default. * + * @default 10 + * * @public */ damping?: number @@ -293,6 +300,8 @@ export interface SpringOptions extends DurationSpringOptions, VelocityOptions { * Mass of the moving object. Higher values will result in more lethargic * movement. Set to `1` by default. * + * @default 1 + * * @public */ mass?: number @@ -479,6 +488,27 @@ export interface ValueTransition * @public */ inherit?: boolean + + /** + * If true, the animation skips straight to its final value instead of + * tweening. Used by `MotionConfig`'s `skipAnimations` to opt entire + * subtrees out of animation (e.g. for E2E screenshot stability). + * + * @public + */ + skipAnimations?: boolean + + /** + * The path the element travels between its old and new x/y positions. + * Slot in a path factory (e.g. `arc()`) to swap the default + * straight-line interpolation for something curved. + * + * Can be used in keyframe animations (`transition.path`) and layout + * animations (`transition.layout.path`), including with `useAnimate`. + * + * @public + */ + path?: MotionPath } /** @@ -595,6 +625,45 @@ export type Transition = | ValueAnimationTransition | TransitionWithValueOverrides +export interface Point2D { + x: number + y: number +} + +export interface PathState { + x: number + y: number + /** + * Optional rotation in degrees. If returned, the engine will apply it + * to the element's `rotate` value for the duration of the animation. + */ + rotate?: number +} + +/** + * Sampling function — returns position (and optionally rotation) at + * progress `t` (0–1). + */ +export interface PathInterpolator { + (t: number): PathState +} + +/** + * Returned by a path factory such as `arc()` and passed to `transition.path`. + * Implements both the keyframe-animation hook (`animateVisualElement`) and + * the layout-projection hook (`interpolateProjection`). + */ +export interface MotionPath { + animateVisualElement( + visualElement: VisualElement, + target: TargetAndTransition, + transition: Transition | undefined, + delay: number, + animations: AnimationPlaybackControlsWithThen[] + ): void + interpolateProjection(delta: Delta): PathInterpolator | undefined +} + export type DynamicOption = (i: number, total: number) => T export type ValueAnimationWithDynamicDelay = Omit< diff --git a/packages/motion-dom/src/animation/utils/__tests__/arc.test.ts b/packages/motion-dom/src/animation/utils/__tests__/arc.test.ts new file mode 100644 index 0000000000..a7b8720af2 --- /dev/null +++ b/packages/motion-dom/src/animation/utils/__tests__/arc.test.ts @@ -0,0 +1,184 @@ +import { createArcPath } from "../arc" + +describe("createArcPath()", () => { + test("returns the from point at t=0 and to point at t=1", () => { + const interp = createArcPath({ strength: 1 })({ x: 0, y: 0 }, { x: 200, y: 0 }) + const start = interp(0) + const end = interp(1) + expect(start.x).toBeCloseTo(0) + expect(start.y).toBeCloseTo(0) + expect(end.x).toBeCloseTo(200) + expect(end.y).toBeCloseTo(0) + }) + + test("strength=1 horizontal: bulges perpendicular by ~half travel at t=0.5", () => { + // bezier midpoint perpendicular component = control.y / 2 = 200/2 = 100 + const interp = createArcPath({ strength: 1 })({ x: 0, y: 0 }, { x: 200, y: 0 }) + const mid = interp(0.5) + expect(mid.x).toBeCloseTo(100) + expect(Math.abs(mid.y)).toBeCloseTo(100) + }) + + test("strength=0 produces straight line at midpoint", () => { + const interp = createArcPath({ strength: 0 })({ x: 0, y: 0 }, { x: 200, y: 100 }) + const mid = interp(0.5) + expect(mid.x).toBeCloseTo(100) + expect(mid.y).toBeCloseTo(50) + }) + + test("default strength (no options) produces a curve", () => { + const interp = createArcPath()({ x: 0, y: 0 }, { x: 200, y: 0 }) + expect(Math.abs(interp(0.5).y)).toBeGreaterThan(0) + }) + + test("direction='cw' bulges opposite side from 'ccw'", () => { + const cw = createArcPath({ strength: 1, direction: "cw" })( + { x: 0, y: 0 }, + { x: 200, y: 0 } + )(0.5) + const ccw = createArcPath({ strength: 1, direction: "ccw" })( + { x: 0, y: 0 }, + { x: 200, y: 0 } + )(0.5) + expect(Math.sign(cw.y)).toBe(-Math.sign(ccw.y)) + }) + + test("explicit direction is rotationally consistent across horizontal reversals", () => { + // Reused factory — explicit direction must skip the auto continuity flip. + const cw = createArcPath({ strength: 1, direction: "cw" }) + const lr = cw({ x: 0, y: 0 }, { x: 200, y: 0 })(0.5) + const rl = cw({ x: 200, y: 0 }, { x: 0, y: 0 })(0.5) + expect(Math.sign(lr.y)).toBe(-1) + expect(Math.sign(rl.y)).toBe(+1) + + const ccw = createArcPath({ strength: 1, direction: "ccw" }) + const lrCcw = ccw({ x: 0, y: 0 }, { x: 200, y: 0 })(0.5) + const rlCcw = ccw({ x: 200, y: 0 }, { x: 0, y: 0 })(0.5) + expect(Math.sign(lrCcw.y)).toBe(+1) + expect(Math.sign(rlCcw.y)).toBe(-1) + }) + + test("explicit direction is rotationally consistent across vertical reversals", () => { + // Think clock face: traveling DOWN (12→6) curling cw curves toward + // 3 (RIGHT). Traveling UP (6→12) curling cw curves toward 9 (LEFT). + // ccw is the mirror. + const cw = createArcPath({ strength: 1, direction: "cw" }) + const down = cw({ x: 0, y: 0 }, { x: 0, y: 200 })(0.5) + const up = cw({ x: 0, y: 200 }, { x: 0, y: 0 })(0.5) + expect(Math.sign(down.x)).toBe(+1) + expect(Math.sign(up.x)).toBe(-1) + + const ccw = createArcPath({ strength: 1, direction: "ccw" }) + const downCcw = ccw({ x: 0, y: 0 }, { x: 0, y: 200 })(0.5) + const upCcw = ccw({ x: 0, y: 200 }, { x: 0, y: 0 })(0.5) + expect(Math.sign(downCcw.x)).toBe(-1) + expect(Math.sign(upCcw.x)).toBe(+1) + }) + + test("auto direction: same screen side regardless of travel direction", () => { + // Moving right then left should both bulge to the same screen-y side. + const right = createArcPath({ strength: 1 })( + { x: 0, y: 0 }, + { x: 200, y: 0 } + )(0.5) + const left = createArcPath({ strength: 1 })( + { x: 200, y: 0 }, + { x: 0, y: 0 } + )(0.5) + expect(Math.sign(right.y)).toBe(Math.sign(left.y)) + }) + + test("peak shifts the control point along the chord", () => { + // For a horizontal chord, peak shifts where x hits its midpoint — + // earlier peak pulls x ahead at t=0.5, later peak holds it back. + const early = createArcPath({ strength: 1, peak: 0.2 })( + { x: 0, y: 0 }, + { x: 200, y: 0 } + )(0.5) + const late = createArcPath({ strength: 1, peak: 0.8 })( + { x: 0, y: 0 }, + { x: 200, y: 0 } + )(0.5) + expect(early.x).toBeLessThan(late.x) + }) + + test("rotate false omits rotate", () => { + const interp = createArcPath({ strength: 1 })( + { x: 0, y: 0 }, + { x: 200, y: 0 } + ) + expect(interp(0.5).rotate).toBeUndefined() + }) + + test("rotate true returns rotate values along the curve", () => { + const interp = createArcPath({ strength: 1, rotate: true })( + { x: 0, y: 0 }, + { x: 200, y: 0 } + ) + expect(interp(0.5).rotate).toBeDefined() + // Rotation is normalized to 0 at endpoints + expect(interp(0).rotate).toBeCloseTo(0) + expect(interp(1).rotate).toBeCloseTo(0) + }) + + test("rotate number scales rotation intensity", () => { + const full = createArcPath({ strength: 1, rotate: 1 })( + { x: 0, y: 0 }, + { x: 200, y: 0 } + )(0.25) + const half = createArcPath({ strength: 1, rotate: 0.5 })( + { x: 0, y: 0 }, + { x: 200, y: 0 } + )(0.25) + expect(Math.abs(half.rotate!)).toBeCloseTo(Math.abs(full.rotate!) * 0.5) + }) + + test("clean reversal naturally bulges the same screen side (auto-direction)", () => { + // No factory state needed — auto-direction's flip cancels the + // perpendicular flip when the chord reverses cleanly. + const a1 = createArcPath({ strength: 1 }) + const a2 = createArcPath({ strength: 1 }) + const first = a1({ x: 0, y: 0 }, { x: 200, y: 0 })(0.5) + const second = a2({ x: 200, y: 0 }, { x: 0, y: 0 })(0.5) + expect(Math.sign(first.y)).toBe(Math.sign(second.y)) + }) + + test("dominant-axis change keeps bulging same side when factory is reused", () => { + // Arc 1: mostly horizontal — auto-direction picks +y bulge (down). + // Arc 2 from arc 1's apex toward a mostly-vertical chord — + // auto-direction alone would pick a different screen side. + // Reusing the factory closes over prevBulgeSign and forces same side. + const a = createArcPath({ strength: 1 }) + const apex = a({ x: 0, y: 0 }, { x: 300, y: 50 })(0.5) + // Apex.y > 25 (chord midpoint y) means arc 1 bulged down. + expect(apex.y).toBeGreaterThan(25) + + const second = a({ x: apex.x, y: apex.y }, { x: 50, y: 300 })(0.5) + // For arc 2's mostly-vertical chord, "same screen side as down" means + // the bulge sign in x is on the side that keeps screen-y monotone-ish. + // Concretely: arc 2 should not invert direction relative to apex's y. + expect(second.y).toBeGreaterThan(apex.y) + }) + + test("fresh factory per call has no memory (documented limitation)", () => { + // Two unrelated factories — the dominant-axis-change continuity + // feature only fires when the same factory is reused. + const apex = createArcPath({ strength: 1 })({ x: 0, y: 0 }, { x: 300, y: 50 })(0.5) + const second = createArcPath({ strength: 1 })( + { x: apex.x, y: apex.y }, + { x: 50, y: 300 } + )(0.5) + // Without shared state, behavior is purely auto-direction. + // We don't assert a specific direction here — just that this + // codepath runs without throwing. + expect(typeof second.x).toBe("number") + expect(typeof second.y).toBe("number") + }) + + test("zero distance yields a no-op interpolator", () => { + const interp = createArcPath({ strength: 1 })({ x: 5, y: 10 }, { x: 5, y: 10 }) + const mid = interp(0.5) + expect(mid.x).toBeCloseTo(5) + expect(mid.y).toBeCloseTo(10) + }) +}) diff --git a/packages/motion-dom/src/animation/utils/arc.ts b/packages/motion-dom/src/animation/utils/arc.ts new file mode 100644 index 0000000000..8445d5c0a6 --- /dev/null +++ b/packages/motion-dom/src/animation/utils/arc.ts @@ -0,0 +1,325 @@ +import { wrap } from "motion-utils" +import { motionValue } from "../../value" +import { animateMotionValue } from "../interfaces/motion-value" +import type { MotionPath, PathInterpolator, Point2D } from "../types" +import { getValueTransition } from "./get-value-transition" + +export interface ArcOptions { + /** + * How far the arc bulges perpendicular to the straight-line path, + * as a fraction of the total distance. A value of `1` means the arc + * peaks at a height equal to the full travel distance. Default `0.5`. + */ + strength?: number + /** + * Where along the path (0–1) the arc reaches its maximum height. + * Default `0.5` (symmetric). + */ + peak?: number + /** + * Which side the arc bulges toward. + * - `"cw"` / `"ccw"` — locked relative to direction of travel + * - unset — auto-pick a stable screen-space side + */ + direction?: "cw" | "ccw" + /** + * Rotates the element to follow the tangent of the arc path. + * - `true` — full tangent following (1.0) + * - number 0–1 — scale factor + */ + rotate?: boolean | number +} + +const MIN_LAYOUT_DISTANCE = 20 + +function bezierPoint( + t: number, + origin: number, + control: number, + target: number +): number { + const inv = 1 - t + return inv * inv * origin + 2 * inv * t * control + t * t * target +} + +function bezierTangentAngle( + t: number, + originX: number, + controlX: number, + targetX: number, + originY: number, + controlY: number, + targetY: number +): number { + const dx = + 2 * (1 - t) * (controlX - originX) + 2 * t * (targetX - controlX) + const dy = + 2 * (1 - t) * (controlY - originY) + 2 * t * (targetY - controlY) + return Math.atan2(dy, dx) * (180 / Math.PI) +} + +function computeArcControlPoint( + fromX: number, + fromY: number, + toX: number, + toY: number, + strength: number, + peak: number +): { x: number; y: number } { + const deltaX = toX - fromX + const deltaY = toY - fromY + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) + + if (distance > 0) { + const normalPerpX = -deltaY / distance + const normalPerpY = deltaX / distance + const desiredHeight = strength * distance + + return { + x: fromX + deltaX * peak + normalPerpX * desiredHeight, + y: fromY + deltaY * peak + normalPerpY * desiredHeight, + } + } + + return { x: fromX, y: fromY } +} + +/** + * The pure sampling factory: `(from, to) => (t) => point`. Internal — + * used by {@link arc} and the unit tests. Not part of the public surface. + */ +export function createArcPath({ + strength = 0.5, + peak = 0.5, + direction, + rotate = false, +}: ArcOptions = {}): (from: Point2D, to: Point2D) => PathInterpolator { + const rotationScale = + rotate === true ? 1 : typeof rotate === "number" ? rotate : 0 + + // Auto-direction only: persists across calls to flip the bulge back + // onto the same screen side when the dominant axis changes between + // calls. Reuse the factory (module scope / useMemo) to keep this alive. + let prevBulgeSign: number | undefined + + const createInterpolator = ( + from: Point2D, + to: Point2D + ): PathInterpolator => { + const dx = to.x - from.x + const dy = to.y - from.y + + let signed: number + if (direction === "cw") { + signed = -strength + } else if (direction === "ccw") { + signed = strength + } else { + const dom = Math.abs(dx) >= Math.abs(dy) ? dx : dy + signed = dom < 0 ? -strength : strength + } + + let control = computeArcControlPoint( + from.x, + from.y, + to.x, + to.y, + signed, + peak + ) + + if (direction === undefined) { + const isVertical = Math.abs(dx) < Math.abs(dy) + const midX = from.x + dx * peak + const midY = from.y + dy * peak + const bulgeSign = isVertical + ? Math.sign(control.x - midX) + : Math.sign(control.y - midY) + + if ( + prevBulgeSign !== undefined && + bulgeSign !== 0 && + bulgeSign !== prevBulgeSign + ) { + signed = -signed + control = computeArcControlPoint( + from.x, + from.y, + to.x, + to.y, + signed, + peak + ) + } else if (bulgeSign !== 0) { + prevBulgeSign = bulgeSign + } + } + + const tangent0 = rotationScale + ? bezierTangentAngle( + 0, + from.x, + control.x, + to.x, + from.y, + control.y, + to.y + ) + : 0 + const tangent1 = rotationScale + ? bezierTangentAngle( + 1, + from.x, + control.x, + to.x, + from.y, + control.y, + to.y + ) + : 0 + const tangentDelta = rotationScale + ? wrap(-180, 180, tangent1 - tangent0) + : 0 + + return (t: number) => { + const out: { x: number; y: number; rotate?: number } = { + x: bezierPoint(t, from.x, control.x, to.x), + y: bezierPoint(t, from.y, control.y, to.y), + } + if (rotationScale) { + const raw = bezierTangentAngle( + t, + from.x, + control.x, + to.x, + from.y, + control.y, + to.y + ) + const baseline = tangent0 + tangentDelta * t + out.rotate = wrap(-180, 180, raw - baseline) * rotationScale + } + return out + } + } + + return createInterpolator +} + +/** + * Creates a curved path for `transition.path`: + * + * ```ts + * + * ``` + * + * Reuse the returned value (module scope / useMemo / useRef) so its + * continuity closure survives re-renders — a fresh `arc()` has no memory. + */ +export function arc(options: ArcOptions = {}): MotionPath { + const sample = createArcPath(options) + + const path: MotionPath = { + interpolateProjection(delta) { + // `from` is the current translate offset (carries any in-flight + // displacement when interrupted); `to` is the new layout origin. + // The distance floor avoids visible wobble on tiny shifts. + const tx = delta.x.translate + const ty = delta.y.translate + if (Math.sqrt(tx * tx + ty * ty) < MIN_LAYOUT_DISTANCE) { + return undefined + } + return sample({ x: tx, y: ty }, { x: 0, y: 0 }) + }, + + animateVisualElement( + visualElement, + target, + transition, + delay, + animations + ) { + if (!("x" in target || "y" in target)) return + + const xValue = visualElement.getValue( + "x", + visualElement.latestValues["x"] ?? 0 + ) + const yValue = visualElement.getValue( + "y", + visualElement.latestValues["y"] ?? 0 + ) + + const xRaw = target.x as number | number[] | undefined + const yRaw = target.y as number | number[] | undefined + + const xFrom = ((Array.isArray(xRaw) && xRaw[0] != null + ? xRaw[0] + : xValue?.get()) as number) ?? 0 + const yFrom = ((Array.isArray(yRaw) && yRaw[0] != null + ? yRaw[0] + : yValue?.get()) as number) ?? 0 + const xTo = (Array.isArray(xRaw) + ? xRaw[xRaw.length - 1] + : xRaw ?? xFrom) as number + const yTo = (Array.isArray(yRaw) + ? yRaw[yRaw.length - 1] + : yRaw ?? yFrom) as number + + // Interruption needs no flag: x/y already hold the displaced + // mid-arc position, so xFrom/yFrom carry the continuity geometry. + const interpolate = sample( + { x: xFrom, y: yFrom }, + { x: xTo, y: yTo } + ) + + // Drive a dedicated `pathRotation` value (composed onto `rotate` + // at the build sites) rather than `rotate` itself, so a + // concurrent rotate animation composes and nothing accumulates + // on interrupt. + const pathRotationValue = + interpolate(0).rotate !== undefined + ? visualElement.getValue("pathRotation", 0) + : undefined + + const pathTransition = { + delay, + ...getValueTransition(transition || {}, "x"), + } + delete (pathTransition as { path?: unknown }).path + + const progress = motionValue(0) + progress.start( + animateMotionValue("", progress, [0, 1000] as any, { + ...pathTransition, + isSync: true, + velocity: 0, + onUpdate: (latest: number) => { + const point = interpolate(latest / 1000) + xValue?.set(point.x) + yValue?.set(point.y) + if (pathRotationValue && point.rotate !== undefined) { + pathRotationValue.set(point.rotate) + } + }, + onComplete: () => { + xValue?.set(xTo) + yValue?.set(yTo) + pathRotationValue?.set(0) + }, + // Interrupt/cancel must clear our additive contribution + // so it can't linger on top of the user's `rotate`. + onStop: () => pathRotationValue?.set(0), + onCancel: () => pathRotationValue?.set(0), + }) + ) + + if (progress.animation) animations.push(progress.animation) + + delete (target as { x?: unknown }).x + delete (target as { y?: unknown }).y + }, + } + + return path +} diff --git a/packages/motion-dom/src/effects/style/transform.ts b/packages/motion-dom/src/effects/style/transform.ts index 9be4d04acd..4586e32407 100644 --- a/packages/motion-dom/src/effects/style/transform.ts +++ b/packages/motion-dom/src/effects/style/transform.ts @@ -37,5 +37,17 @@ export function buildTransform(state: MotionValueState) { } } + // See build-transform.ts: additive `rotate()` so user `rotate` isn't + // clobbered. Not a `transformPropOrder` slot. + const pathRotation = state.latest.pathRotation + if (pathRotation) { + transformIsDefault = false + transform += `rotate(${ + typeof pathRotation === "number" + ? `${pathRotation}deg` + : pathRotation + }) ` + } + return transformIsDefault ? "none" : transform.trim() } diff --git a/packages/motion-dom/src/index.ts b/packages/motion-dom/src/index.ts index d1d0e44f3c..945bb7ad52 100644 --- a/packages/motion-dom/src/index.ts +++ b/packages/motion-dom/src/index.ts @@ -8,6 +8,8 @@ export * from "./animation/NativeAnimationWrapper" export * from "./animation/types" export * from "./animation/utils/active-animations" export { calcChildStagger } from "./animation/utils/calc-child-stagger" +export { arc } from "./animation/utils/arc" +export type { ArcOptions } from "./animation/utils/arc" export * from "./animation/utils/css-variables-conversion" export { getDefaultTransition } from "./animation/utils/default-transitions" export { getFinalKeyframe } from "./animation/keyframes/get-final" diff --git a/packages/motion-dom/src/projection/node/create-projection-node.ts b/packages/motion-dom/src/projection/node/create-projection-node.ts index 0a7dd5bf8b..0c47c3bb5d 100644 --- a/packages/motion-dom/src/projection/node/create-projection-node.ts +++ b/packages/motion-dom/src/projection/node/create-projection-node.ts @@ -11,7 +11,12 @@ import { import { animateSingleValue } from "../../animation/animate/single-value" import { JSAnimation } from "../../animation/JSAnimation" import { getOptimisedAppearId } from "../../animation/optimized-appear/get-appear-id" -import { Transition, ValueAnimationOptions } from "../../animation/types" +import { + MotionPath, + PathInterpolator, + Transition, + ValueAnimationOptions, +} from "../../animation/types" import { getValueTransition } from "../../animation/utils/get-value-transition" import { cancelFrame, frame, frameData, frameSteps } from "../../frameloop" import { microtask } from "../../frameloop/microtask" @@ -574,7 +579,9 @@ export function createProjectionNode({ */ this.setAnimationOrigin( delta, - hasOnlyRelativeTargetChanged + hasOnlyRelativeTargetChanged, + (animationOptions as { path?: MotionPath }) + .path ) } else { /** @@ -1582,7 +1589,8 @@ export function createProjectionNode({ setAnimationOrigin( delta: Delta, - hasOnlyRelativeTargetChanged: boolean = false + hasOnlyRelativeTargetChanged: boolean = false, + pathFn?: MotionPath ) { const snapshot = this.snapshot const snapshotLatestValues = snapshot ? snapshot.latestValues : {} @@ -1615,11 +1623,29 @@ export function createProjectionNode({ let prevRelativeTarget: Box + // The path decides whether the layout shift is worth curving + // (distance floor) and resolves the interpolator from the delta. + const interpolate: PathInterpolator | undefined = + pathFn?.interpolateProjection(delta) + this.mixTargetDelta = (latest: number) => { const progress = latest / 1000 + const point = interpolate?.(progress) + + if (point) { + targetDelta.x.translate = point.x + targetDelta.x.scale = mixNumber(delta.x.scale, 1, progress) + targetDelta.x.origin = delta.x.origin + targetDelta.x.originPoint = delta.x.originPoint + targetDelta.y.translate = point.y + targetDelta.y.scale = mixNumber(delta.y.scale, 1, progress) + targetDelta.y.origin = delta.y.origin + targetDelta.y.originPoint = delta.y.originPoint + } else { + mixAxisDeltaLinear(targetDelta.x, delta.x, progress) + mixAxisDeltaLinear(targetDelta.y, delta.y, progress) + } - mixAxisDelta(targetDelta.x, delta.x, progress) - mixAxisDelta(targetDelta.y, delta.y, progress) this.setTargetDelta(targetDelta) if ( @@ -1670,6 +1696,14 @@ export function createProjectionNode({ ) } + if (point && point.rotate !== undefined) { + // Dedicated `pathRotation` channel, not `rotate`, so an + // animating `rotate` is composed with, never clobbered. + if (!this.animationValues) + this.animationValues = mixedValues + this.animationValues.pathRotation = point.rotate + } + this.root.scheduleUpdateProjection() this.scheduleRender() @@ -2360,7 +2394,7 @@ function removeLeadSnapshots(stack: NodeStack) { stack.removeLeadSnapshot() } -export function mixAxisDelta(output: AxisDelta, delta: AxisDelta, p: number) { +function mixAxisDeltaLinear(output: AxisDelta, delta: AxisDelta, p: number) { output.translate = mixNumber(delta.translate, 0, p) output.scale = mixNumber(delta.scale, 1, p) output.origin = delta.origin diff --git a/packages/motion-dom/src/projection/node/types.ts b/packages/motion-dom/src/projection/node/types.ts index a90cb9dde1..6b2bab5121 100644 --- a/packages/motion-dom/src/projection/node/types.ts +++ b/packages/motion-dom/src/projection/node/types.ts @@ -1,5 +1,9 @@ import type { JSAnimation } from "../../animation/JSAnimation" -import type { Transition, ValueTransition } from "../../animation/types" +import type { + MotionPath, + Transition, + ValueTransition, +} from "../../animation/types" import type { ResolvedValues } from "../../render/types" import type { VisualElement, MotionStyle } from "../../render/VisualElement" import { Box, Delta, Point } from "motion-utils" @@ -117,7 +121,11 @@ export interface IProjectionNode { isTreeAnimating?: boolean isAnimationBlocked?: boolean isTreeAnimationBlocked: () => boolean - setAnimationOrigin(delta: Delta): void + setAnimationOrigin( + delta: Delta, + hasOnlyRelativeTargetChanged?: boolean, + pathFn?: MotionPath + ): void startAnimation(transition: ValueTransition): void finishAnimation(): void hasCheckedOptimisedAppear: boolean diff --git a/packages/motion-dom/src/projection/styles/transform.ts b/packages/motion-dom/src/projection/styles/transform.ts index dde9c04d3e..c6d76bb857 100644 --- a/packages/motion-dom/src/projection/styles/transform.ts +++ b/packages/motion-dom/src/projection/styles/transform.ts @@ -30,11 +30,20 @@ export function buildProjectionTransform( } if (latestTransform) { - const { transformPerspective, rotate, rotateX, rotateY, skewX, skewY } = - latestTransform + const { + transformPerspective, + rotate, + pathRotation, + rotateX, + rotateY, + skewX, + skewY, + } = latestTransform if (transformPerspective) transform = `perspective(${transformPerspective}px) ${transform}` if (rotate) transform += `rotate(${rotate}deg) ` + // Additive `rotate()` so user `rotate` isn't clobbered. + if (pathRotation) transform += `rotate(${pathRotation}deg) ` if (rotateX) transform += `rotateX(${rotateX}deg) ` if (rotateY) transform += `rotateY(${rotateY}deg) ` if (skewX) transform += `skewX(${skewX}deg) ` diff --git a/packages/motion-dom/src/render/VisualElement.ts b/packages/motion-dom/src/render/VisualElement.ts index 8a8075891e..64e9705f1c 100644 --- a/packages/motion-dom/src/render/VisualElement.ts +++ b/packages/motion-dom/src/render/VisualElement.ts @@ -602,7 +602,8 @@ export abstract class VisualElement< this.valueSubscriptions.set(key, () => { removeOnChange() if (removeSyncCheck) removeSyncCheck() - if (value.owner) value.stop() + // Defer to MotionValue.on("change") auto-stop so React 19 remounts + // can resubscribe before the animation is cancelled (#3315). }) } diff --git a/packages/motion-dom/src/render/html/utils/build-transform.ts b/packages/motion-dom/src/render/html/utils/build-transform.ts index caceb10c31..51e367f0e7 100644 --- a/packages/motion-dom/src/render/html/utils/build-transform.ts +++ b/packages/motion-dom/src/render/html/utils/build-transform.ts @@ -62,6 +62,18 @@ export function buildTransform( } } + // `pathRotation` composes onto `rotate` as a separate additive term so + // the user's `rotate` is never clobbered. Deliberately not a slot in + // `transformPropOrder`. + const pathRotation = latestValues.pathRotation + if (pathRotation) { + transformIsDefault = false + transformString += `rotate(${getValueAsType( + pathRotation, + numberValueTypes.pathRotation + )}) ` + } + transformString = transformString.trim() // If we have a custom `transform` template, pass our transform values and diff --git a/packages/motion-dom/src/render/utils/animation-state.ts b/packages/motion-dom/src/render/utils/animation-state.ts index 5772fb197c..9798dab554 100644 --- a/packages/motion-dom/src/render/utils/animation-state.ts +++ b/packages/motion-dom/src/render/utils/animation-state.ts @@ -300,7 +300,8 @@ export function createAnimationState(visualElement: any): AnimationState { */ let valueHasChanged = false if (isKeyframesTarget(next) && isKeyframesTarget(prev)) { - valueHasChanged = !shallowCompare(next, prev) + valueHasChanged = + !shallowCompare(next, prev) || variantDidChange } else { valueHasChanged = next !== prev } diff --git a/packages/motion-dom/src/render/utils/keys-transform.ts b/packages/motion-dom/src/render/utils/keys-transform.ts index aeb273fa2a..a358ff71d7 100644 --- a/packages/motion-dom/src/render/utils/keys-transform.ts +++ b/packages/motion-dom/src/render/utils/keys-transform.ts @@ -23,6 +23,13 @@ export const transformPropOrder = [ /** * A quick lookup for transform props. + * + * `pathRotation` is a transform for routing purposes (skipped from raw + * style application, wired to the transform composite, flags transform + * dirty) but is intentionally NOT in `transformPropOrder` — it is + * composed onto `rotate` at the build sites, not serialized in its own + * slot, and must stay out of the order-array consumers (parse-transform, + * unit-conversion, keys-position). */ export const transformProps = /*@__PURE__*/ (() => - new Set(transformPropOrder))() + new Set([...transformPropOrder, "pathRotation"]))() diff --git a/packages/motion-dom/src/value/__tests__/follow-value-framerate.test.ts b/packages/motion-dom/src/value/__tests__/follow-value-framerate.test.ts index 280e578fa5..1abad59d4c 100644 --- a/packages/motion-dom/src/value/__tests__/follow-value-framerate.test.ts +++ b/packages/motion-dom/src/value/__tests__/follow-value-framerate.test.ts @@ -24,7 +24,7 @@ function processFrame(timestamp: number) { frameData.isProcessing = false } -describe("Spring follow at different frame rates (issue #3265)", () => { +describe("Spring follow at different frame rates (issues #3265, #3407)", () => { beforeEach(() => { MotionGlobalConfig.useManualTiming = true frameData.timestamp = 0 diff --git a/packages/motion-dom/src/value/types/maps/transform.ts b/packages/motion-dom/src/value/types/maps/transform.ts index 079070d955..21603333b1 100644 --- a/packages/motion-dom/src/value/types/maps/transform.ts +++ b/packages/motion-dom/src/value/types/maps/transform.ts @@ -4,6 +4,12 @@ import { ValueTypeMap } from "./types" export const transformValueTypes: ValueTypeMap = { rotate: degrees, + /** + * Internal channel for `transition.path` orientToPath. Composed onto + * `rotate` at the transform-build sites so the user's `rotate` is + * never read or overwritten. Not part of `transformPropOrder`. + */ + pathRotation: degrees, rotateX: degrees, rotateY: degrees, rotateZ: degrees, diff --git a/packages/motion-utils/package.json b/packages/motion-utils/package.json index ccaa13d1da..2397c49646 100644 --- a/packages/motion-utils/package.json +++ b/packages/motion-utils/package.json @@ -1,6 +1,6 @@ { "name": "motion-utils", - "version": "12.36.0", + "version": "12.39.0", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion-utils/src/easing/cubic-bezier.ts b/packages/motion-utils/src/easing/cubic-bezier.ts index 87cf26d55b..d08ebbca65 100644 --- a/packages/motion-utils/src/easing/cubic-bezier.ts +++ b/packages/motion-utils/src/easing/cubic-bezier.ts @@ -53,6 +53,7 @@ function binarySubdivide( return currentT } +/*#__NO_SIDE_EFFECTS__*/ export function cubicBezier( mX1: number, mY1: number, diff --git a/packages/motion-utils/src/easing/modifiers/mirror.ts b/packages/motion-utils/src/easing/modifiers/mirror.ts index 63dd2190df..bcdf2cd043 100644 --- a/packages/motion-utils/src/easing/modifiers/mirror.ts +++ b/packages/motion-utils/src/easing/modifiers/mirror.ts @@ -3,5 +3,6 @@ import { EasingModifier } from "../types" // the second half of the animation. Turns easeIn into easeInOut. +/*#__NO_SIDE_EFFECTS__*/ export const mirrorEasing: EasingModifier = (easing) => (p) => p <= 0.5 ? easing(2 * p) / 2 : (2 - easing(2 * (1 - p))) / 2 diff --git a/packages/motion-utils/src/easing/modifiers/reverse.ts b/packages/motion-utils/src/easing/modifiers/reverse.ts index 879e1e40ad..50479bc333 100644 --- a/packages/motion-utils/src/easing/modifiers/reverse.ts +++ b/packages/motion-utils/src/easing/modifiers/reverse.ts @@ -3,5 +3,6 @@ import { EasingModifier } from "../types" // Turns easeIn into easeOut. +/*#__NO_SIDE_EFFECTS__*/ export const reverseEasing: EasingModifier = (easing) => (p) => 1 - easing(1 - p) diff --git a/packages/motion-utils/src/easing/steps.ts b/packages/motion-utils/src/easing/steps.ts index ade33fd42e..14df5c7907 100644 --- a/packages/motion-utils/src/easing/steps.ts +++ b/packages/motion-utils/src/easing/steps.ts @@ -10,6 +10,7 @@ import type { EasingFunction } from "./types" */ export type Direction = "start" | "end" +/*#__NO_SIDE_EFFECTS__*/ export function steps( numSteps: number, direction: Direction = "end" diff --git a/packages/motion-utils/src/easing/utils/get-easing-for-segment.ts b/packages/motion-utils/src/easing/utils/get-easing-for-segment.ts index 9059dd1968..6d340fc732 100644 --- a/packages/motion-utils/src/easing/utils/get-easing-for-segment.ts +++ b/packages/motion-utils/src/easing/utils/get-easing-for-segment.ts @@ -2,6 +2,7 @@ import { wrap } from "../../wrap" import { Easing } from "../types" import { isEasingArray } from "./is-easing-array" +/*#__NO_SIDE_EFFECTS__*/ export function getEasingForSegment( easing: Easing | Easing[], i: number diff --git a/packages/motion-utils/src/easing/utils/is-bezier-definition.ts b/packages/motion-utils/src/easing/utils/is-bezier-definition.ts index baec956edb..8aea4b6cb9 100644 --- a/packages/motion-utils/src/easing/utils/is-bezier-definition.ts +++ b/packages/motion-utils/src/easing/utils/is-bezier-definition.ts @@ -1,5 +1,6 @@ import { BezierDefinition, Easing } from "../types" +/*#__NO_SIDE_EFFECTS__*/ export const isBezierDefinition = ( easing: Easing | Easing[] ): easing is BezierDefinition => diff --git a/packages/motion-utils/src/easing/utils/is-easing-array.ts b/packages/motion-utils/src/easing/utils/is-easing-array.ts index 44b8d9b97f..926e2cc872 100644 --- a/packages/motion-utils/src/easing/utils/is-easing-array.ts +++ b/packages/motion-utils/src/easing/utils/is-easing-array.ts @@ -1,5 +1,6 @@ import { Easing } from "../types" +/*#__NO_SIDE_EFFECTS__*/ export const isEasingArray = (ease: any): ease is Easing[] => { return Array.isArray(ease) && typeof ease[0] !== "number" } diff --git a/packages/motion-utils/src/is-object.ts b/packages/motion-utils/src/is-object.ts index 547490269f..85f0243b75 100644 --- a/packages/motion-utils/src/is-object.ts +++ b/packages/motion-utils/src/is-object.ts @@ -1,3 +1,2 @@ -export function isObject(value: unknown): value is object { - return typeof value === "object" && value !== null -} +export const isObject = (value: unknown): value is object => + typeof value === "object" && value !== null diff --git a/packages/motion-utils/src/pipe.ts b/packages/motion-utils/src/pipe.ts index 527b003bf6..e7204cf54d 100644 --- a/packages/motion-utils/src/pipe.ts +++ b/packages/motion-utils/src/pipe.ts @@ -5,6 +5,5 @@ * @param {...functions} transformers * @return {function} */ -const combineFunctions = (a: Function, b: Function) => (v: any) => b(a(v)) export const pipe = (...transformers: Function[]) => - transformers.reduce(combineFunctions) + transformers.reduce((a, b) => (v: any) => b(a(v))) diff --git a/packages/motion-utils/src/progress.ts b/packages/motion-utils/src/progress.ts index 7d902b3463..5348daa6c5 100644 --- a/packages/motion-utils/src/progress.ts +++ b/packages/motion-utils/src/progress.ts @@ -4,15 +4,9 @@ Given a lower limit and an upper limit, we return the progress (expressed as a number 0-1) represented by the given value, and limit that progress to within 0-1. - - @param [number]: Lower limit - @param [number]: Upper limit - @param [number]: Value to find progress within given range - @return [number]: Progress of value within range as expressed 0-1 */ /*#__NO_SIDE_EFFECTS__*/ export const progress = (from: number, to: number, value: number) => { - const toFromDifference = to - from - - return toFromDifference === 0 ? 1 : (value - from) / toFromDifference + const range = to - from + return range ? (value - from) / range : 1 } diff --git a/packages/motion-utils/src/velocity-per-second.ts b/packages/motion-utils/src/velocity-per-second.ts index 33d22c7708..7c83f0ade3 100644 --- a/packages/motion-utils/src/velocity-per-second.ts +++ b/packages/motion-utils/src/velocity-per-second.ts @@ -1,9 +1,6 @@ /* Convert velocity into velocity per second - - @param [number]: Unit per frame - @param [number]: Frame duration in ms */ -export function velocityPerSecond(velocity: number, frameDuration: number) { - return frameDuration ? velocity * (1000 / frameDuration) : 0 -} +/*#__NO_SIDE_EFFECTS__*/ +export const velocityPerSecond = (velocity: number, frameDuration: number) => + frameDuration ? velocity * (1000 / frameDuration) : 0 diff --git a/packages/motion/README.md b/packages/motion/README.md index 2f71f5cbe0..c4ec0234fd 100644 --- a/packages/motion/README.md +++ b/packages/motion/README.md @@ -125,12 +125,10 @@ Motion drives the animations on the Cursor homepage, and is working with Cursor ### Platinum -Linear Figma Sanity Sanity Clerk Greptile +Linear Figma Sanity Sanity Clerk ### Gold -Mintlify - ### Silver Liveblocks Frontend.fyi Firecrawl Puzzmo Bolt.new diff --git a/packages/motion/package.json b/packages/motion/package.json index 3974443863..e1ba188172 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.38.0", + "version": "12.40.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.38.0", + "framer-motion": "^12.40.0", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/scripts/push-to-site.js b/scripts/push-to-site.js index aed1f76cb2..33daaed704 100644 --- a/scripts/push-to-site.js +++ b/scripts/push-to-site.js @@ -1,158 +1,54 @@ #!/usr/bin/env node -// Load environment variables from .env file -require("dotenv").config() +/** + * Mirrors changelog.csv into the motion.dev site source so the public + * changelog at motion.dev/changelog stays in sync with this repo. + * + * Set MOTION_API_PATH to override the destination repo location. + */ const fs = require("fs") const path = require("path") -const Papa = require("papaparse") +const os = require("os") -async function pushToSite() { - try { - const projectId = process.env.FRAMER_PROJECT_ID - if (!projectId) { - throw new Error( - "FRAMER_PROJECT_ID environment variable is required" - ) - } +const LIBRARY_FILENAME = "motion.csv" - // 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`) +function pushToSite() { + const csvPath = path.join(__dirname, "..", "changelog.csv") + if (!fs.existsSync(csvPath)) { + console.error(`changelog.csv not found at ${csvPath}`) + process.exit(1) + } - 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) + const motionApiPath = + process.env.MOTION_API_PATH || path.join(os.homedir(), "Sites", "motion-api") + const destDir = path.join( + motionApiPath, + "packages", + "site", + "app", + "content", + "changelog" + ) + + if (!fs.existsSync(destDir)) { + console.error(`Destination not found: ${destDir}`) + console.error( + `Set MOTION_API_PATH if motion-api is checked out elsewhere.` + ) process.exit(1) } + + const destPath = path.join(destDir, LIBRARY_FILENAME) + fs.copyFileSync(csvPath, destPath) + + const bytes = fs.statSync(destPath).size + console.log(`Mirrored changelog.csv → ${destPath} (${bytes} bytes)`) + console.log( + `Commit and push motion-api to deploy the updated changelog at motion.dev/changelog.` + ) } -// Run the script if (require.main === module) { pushToSite() } diff --git a/yarn.lock b/yarn.lock index ee1732cc67..5adb768432 100644 --- a/yarn.lock +++ b/yarn.lock @@ -600,163 +600,184 @@ __metadata: languageName: node linkType: hard -"@esbuild/aix-ppc64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/aix-ppc64@npm:0.21.5" +"@esbuild/aix-ppc64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/aix-ppc64@npm:0.25.12" conditions: os=aix & cpu=ppc64 languageName: node linkType: hard -"@esbuild/android-arm64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/android-arm64@npm:0.21.5" +"@esbuild/android-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/android-arm64@npm:0.25.12" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@esbuild/android-arm@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/android-arm@npm:0.21.5" +"@esbuild/android-arm@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/android-arm@npm:0.25.12" conditions: os=android & cpu=arm languageName: node linkType: hard -"@esbuild/android-x64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/android-x64@npm:0.21.5" +"@esbuild/android-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/android-x64@npm:0.25.12" conditions: os=android & cpu=x64 languageName: node linkType: hard -"@esbuild/darwin-arm64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/darwin-arm64@npm:0.21.5" +"@esbuild/darwin-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/darwin-arm64@npm:0.25.12" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@esbuild/darwin-x64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/darwin-x64@npm:0.21.5" +"@esbuild/darwin-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/darwin-x64@npm:0.25.12" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@esbuild/freebsd-arm64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/freebsd-arm64@npm:0.21.5" +"@esbuild/freebsd-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/freebsd-arm64@npm:0.25.12" conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard -"@esbuild/freebsd-x64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/freebsd-x64@npm:0.21.5" +"@esbuild/freebsd-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/freebsd-x64@npm:0.25.12" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@esbuild/linux-arm64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/linux-arm64@npm:0.21.5" +"@esbuild/linux-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-arm64@npm:0.25.12" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"@esbuild/linux-arm@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/linux-arm@npm:0.21.5" +"@esbuild/linux-arm@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-arm@npm:0.25.12" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@esbuild/linux-ia32@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/linux-ia32@npm:0.21.5" +"@esbuild/linux-ia32@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-ia32@npm:0.25.12" conditions: os=linux & cpu=ia32 languageName: node linkType: hard -"@esbuild/linux-loong64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/linux-loong64@npm:0.21.5" +"@esbuild/linux-loong64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-loong64@npm:0.25.12" conditions: os=linux & cpu=loong64 languageName: node linkType: hard -"@esbuild/linux-mips64el@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/linux-mips64el@npm:0.21.5" +"@esbuild/linux-mips64el@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-mips64el@npm:0.25.12" conditions: os=linux & cpu=mips64el languageName: node linkType: hard -"@esbuild/linux-ppc64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/linux-ppc64@npm:0.21.5" +"@esbuild/linux-ppc64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-ppc64@npm:0.25.12" conditions: os=linux & cpu=ppc64 languageName: node linkType: hard -"@esbuild/linux-riscv64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/linux-riscv64@npm:0.21.5" +"@esbuild/linux-riscv64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-riscv64@npm:0.25.12" conditions: os=linux & cpu=riscv64 languageName: node linkType: hard -"@esbuild/linux-s390x@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/linux-s390x@npm:0.21.5" +"@esbuild/linux-s390x@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-s390x@npm:0.25.12" conditions: os=linux & cpu=s390x languageName: node linkType: hard -"@esbuild/linux-x64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/linux-x64@npm:0.21.5" +"@esbuild/linux-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-x64@npm:0.25.12" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"@esbuild/netbsd-x64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/netbsd-x64@npm:0.21.5" +"@esbuild/netbsd-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/netbsd-arm64@npm:0.25.12" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/netbsd-x64@npm:0.25.12" conditions: os=netbsd & cpu=x64 languageName: node linkType: hard -"@esbuild/openbsd-x64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/openbsd-x64@npm:0.21.5" +"@esbuild/openbsd-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/openbsd-arm64@npm:0.25.12" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/openbsd-x64@npm:0.25.12" conditions: os=openbsd & cpu=x64 languageName: node linkType: hard -"@esbuild/sunos-x64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/sunos-x64@npm:0.21.5" +"@esbuild/openharmony-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/openharmony-arm64@npm:0.25.12" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/sunos-x64@npm:0.25.12" conditions: os=sunos & cpu=x64 languageName: node linkType: hard -"@esbuild/win32-arm64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/win32-arm64@npm:0.21.5" +"@esbuild/win32-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/win32-arm64@npm:0.25.12" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@esbuild/win32-ia32@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/win32-ia32@npm:0.21.5" +"@esbuild/win32-ia32@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/win32-ia32@npm:0.25.12" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@esbuild/win32-x64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/win32-x64@npm:0.21.5" +"@esbuild/win32-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/win32-x64@npm:0.25.12" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -2221,65 +2242,65 @@ __metadata: languageName: node linkType: hard -"@next/env@npm:15.5.10": - version: 15.5.10 - resolution: "@next/env@npm:15.5.10" - checksum: cc481d2bd5a7cf97a0d3c093bb9a9b5359ad324c9eb26c89621c90f8d8d11a284fd05cdcc5358db973074fb65c0d9aa01ecbc464b181c9fe3b2bca0776a0fc54 +"@next/env@npm:15.5.18": + version: 15.5.18 + resolution: "@next/env@npm:15.5.18" + checksum: de4165dc1caaf9da8bf014618e5a096d5c06e074509c1950c5a074a64110a497ed44d61866fbd1def3041e1e67b7f88ed515953eea4d5ab62f6ece01be79d7f4 languageName: node linkType: hard -"@next/swc-darwin-arm64@npm:15.5.7": - version: 15.5.7 - resolution: "@next/swc-darwin-arm64@npm:15.5.7" +"@next/swc-darwin-arm64@npm:15.5.18": + version: 15.5.18 + resolution: "@next/swc-darwin-arm64@npm:15.5.18" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@next/swc-darwin-x64@npm:15.5.7": - version: 15.5.7 - resolution: "@next/swc-darwin-x64@npm:15.5.7" +"@next/swc-darwin-x64@npm:15.5.18": + version: 15.5.18 + resolution: "@next/swc-darwin-x64@npm:15.5.18" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@next/swc-linux-arm64-gnu@npm:15.5.7": - version: 15.5.7 - resolution: "@next/swc-linux-arm64-gnu@npm:15.5.7" +"@next/swc-linux-arm64-gnu@npm:15.5.18": + version: 15.5.18 + resolution: "@next/swc-linux-arm64-gnu@npm:15.5.18" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-arm64-musl@npm:15.5.7": - version: 15.5.7 - resolution: "@next/swc-linux-arm64-musl@npm:15.5.7" +"@next/swc-linux-arm64-musl@npm:15.5.18": + version: 15.5.18 + resolution: "@next/swc-linux-arm64-musl@npm:15.5.18" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@next/swc-linux-x64-gnu@npm:15.5.7": - version: 15.5.7 - resolution: "@next/swc-linux-x64-gnu@npm:15.5.7" +"@next/swc-linux-x64-gnu@npm:15.5.18": + version: 15.5.18 + resolution: "@next/swc-linux-x64-gnu@npm:15.5.18" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-x64-musl@npm:15.5.7": - version: 15.5.7 - resolution: "@next/swc-linux-x64-musl@npm:15.5.7" +"@next/swc-linux-x64-musl@npm:15.5.18": + version: 15.5.18 + resolution: "@next/swc-linux-x64-musl@npm:15.5.18" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@next/swc-win32-arm64-msvc@npm:15.5.7": - version: 15.5.7 - resolution: "@next/swc-win32-arm64-msvc@npm:15.5.7" +"@next/swc-win32-arm64-msvc@npm:15.5.18": + version: 15.5.18 + resolution: "@next/swc-win32-arm64-msvc@npm:15.5.18" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@next/swc-win32-x64-msvc@npm:15.5.7": - version: 15.5.7 - resolution: "@next/swc-win32-x64-msvc@npm:15.5.7" +"@next/swc-win32-x64-msvc@npm:15.5.18": + version: 15.5.18 + resolution: "@next/swc-win32-x64-msvc@npm:15.5.18" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -2952,13 +2973,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm-eabi@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-android-arm-eabi@npm:4.44.0" - conditions: os=android & cpu=arm - languageName: node - linkType: hard - "@rollup/rollup-android-arm-eabi@npm:4.59.0": version: 4.59.0 resolution: "@rollup/rollup-android-arm-eabi@npm:4.59.0" @@ -2966,10 +2980,10 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm64@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-android-arm64@npm:4.44.0" - conditions: os=android & cpu=arm64 +"@rollup/rollup-android-arm-eabi@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.60.1" + conditions: os=android & cpu=arm languageName: node linkType: hard @@ -2980,10 +2994,10 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-darwin-arm64@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-darwin-arm64@npm:4.44.0" - conditions: os=darwin & cpu=arm64 +"@rollup/rollup-android-arm64@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-android-arm64@npm:4.60.1" + conditions: os=android & cpu=arm64 languageName: node linkType: hard @@ -2994,10 +3008,10 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-darwin-x64@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-darwin-x64@npm:4.44.0" - conditions: os=darwin & cpu=x64 +"@rollup/rollup-darwin-arm64@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-darwin-arm64@npm:4.60.1" + conditions: os=darwin & cpu=arm64 languageName: node linkType: hard @@ -3008,10 +3022,10 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-freebsd-arm64@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-freebsd-arm64@npm:4.44.0" - conditions: os=freebsd & cpu=arm64 +"@rollup/rollup-darwin-x64@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-darwin-x64@npm:4.60.1" + conditions: os=darwin & cpu=x64 languageName: node linkType: hard @@ -3022,10 +3036,10 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-freebsd-x64@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-freebsd-x64@npm:4.44.0" - conditions: os=freebsd & cpu=x64 +"@rollup/rollup-freebsd-arm64@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.60.1" + conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard @@ -3036,10 +3050,10 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm-gnueabihf@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.44.0" - conditions: os=linux & cpu=arm & libc=glibc +"@rollup/rollup-freebsd-x64@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-freebsd-x64@npm:4.60.1" + conditions: os=freebsd & cpu=x64 languageName: node linkType: hard @@ -3050,10 +3064,10 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm-musleabihf@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.44.0" - conditions: os=linux & cpu=arm & libc=musl +"@rollup/rollup-linux-arm-gnueabihf@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.60.1" + conditions: os=linux & cpu=arm & libc=glibc languageName: node linkType: hard @@ -3064,10 +3078,10 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm64-gnu@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.44.0" - conditions: os=linux & cpu=arm64 & libc=glibc +"@rollup/rollup-linux-arm-musleabihf@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.60.1" + conditions: os=linux & cpu=arm & libc=musl languageName: node linkType: hard @@ -3078,10 +3092,10 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm64-musl@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-linux-arm64-musl@npm:4.44.0" - conditions: os=linux & cpu=arm64 & libc=musl +"@rollup/rollup-linux-arm64-gnu@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.60.1" + conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard @@ -3092,6 +3106,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm64-musl@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.60.1" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-loong64-gnu@npm:4.59.0": version: 4.59.0 resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.59.0" @@ -3099,6 +3120,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-loong64-gnu@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.60.1" + conditions: os=linux & cpu=loong64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-loong64-musl@npm:4.59.0": version: 4.59.0 resolution: "@rollup/rollup-linux-loong64-musl@npm:4.59.0" @@ -3106,23 +3134,23 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-loongarch64-gnu@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.44.0" - conditions: os=linux & cpu=loong64 & libc=glibc +"@rollup/rollup-linux-loong64-musl@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-loong64-musl@npm:4.60.1" + conditions: os=linux & cpu=loong64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-powerpc64le-gnu@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.44.0" +"@rollup/rollup-linux-ppc64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.59.0" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-ppc64-gnu@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.59.0" +"@rollup/rollup-linux-ppc64-gnu@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.60.1" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard @@ -3134,10 +3162,10 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-gnu@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.44.0" - conditions: os=linux & cpu=riscv64 & libc=glibc +"@rollup/rollup-linux-ppc64-musl@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.60.1" + conditions: os=linux & cpu=ppc64 & libc=musl languageName: node linkType: hard @@ -3148,10 +3176,10 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-musl@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.44.0" - conditions: os=linux & cpu=riscv64 & libc=musl +"@rollup/rollup-linux-riscv64-gnu@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.60.1" + conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard @@ -3162,10 +3190,10 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-s390x-gnu@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.44.0" - conditions: os=linux & cpu=s390x & libc=glibc +"@rollup/rollup-linux-riscv64-musl@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.60.1" + conditions: os=linux & cpu=riscv64 & libc=musl languageName: node linkType: hard @@ -3176,10 +3204,10 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-x64-gnu@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-linux-x64-gnu@npm:4.44.0" - conditions: os=linux & cpu=x64 & libc=glibc +"@rollup/rollup-linux-s390x-gnu@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.60.1" + conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard @@ -3190,10 +3218,10 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-x64-musl@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-linux-x64-musl@npm:4.44.0" - conditions: os=linux & cpu=x64 & libc=musl +"@rollup/rollup-linux-x64-gnu@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.60.1" + conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard @@ -3204,6 +3232,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-musl@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.60.1" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-openbsd-x64@npm:4.59.0": version: 4.59.0 resolution: "@rollup/rollup-openbsd-x64@npm:4.59.0" @@ -3211,6 +3246,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-openbsd-x64@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-openbsd-x64@npm:4.60.1" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-openharmony-arm64@npm:4.59.0": version: 4.59.0 resolution: "@rollup/rollup-openharmony-arm64@npm:4.59.0" @@ -3218,10 +3260,10 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-arm64-msvc@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.44.0" - conditions: os=win32 & cpu=arm64 +"@rollup/rollup-openharmony-arm64@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-openharmony-arm64@npm:4.60.1" + conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard @@ -3232,10 +3274,10 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-ia32-msvc@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.44.0" - conditions: os=win32 & cpu=ia32 +"@rollup/rollup-win32-arm64-msvc@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.60.1" + conditions: os=win32 & cpu=arm64 languageName: node linkType: hard @@ -3246,6 +3288,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-ia32-msvc@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.60.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@rollup/rollup-win32-x64-gnu@npm:4.59.0": version: 4.59.0 resolution: "@rollup/rollup-win32-x64-gnu@npm:4.59.0" @@ -3253,9 +3302,9 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-x64-msvc@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-win32-x64-msvc@npm:4.44.0" +"@rollup/rollup-win32-x64-gnu@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-win32-x64-gnu@npm:4.60.1" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -3267,6 +3316,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-x64-msvc@npm:4.60.1": + version: 4.60.1 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.60.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rtsao/scc@npm:^1.1.0": version: 1.1.0 resolution: "@rtsao/scc@npm:1.1.0" @@ -6243,13 +6299,6 @@ __metadata: languageName: node linkType: hard -"devalue@npm:^5.6.2": - version: 5.6.4 - resolution: "devalue@npm:5.6.4" - checksum: 6c90626bf96864e1b20b29bf2a06b72d3779b75b562e84207295702874e31b3741836531a278d5915999a3aa0a3658b80ce0ee1967e373f5325b1ce2b66f3e46 - languageName: node - linkType: hard - "dezalgo@npm:^1.0.0": version: 1.0.4 resolution: "dezalgo@npm:1.0.4" @@ -6655,33 +6704,36 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.21.3": - version: 0.21.5 - resolution: "esbuild@npm:0.21.5" - dependencies: - "@esbuild/aix-ppc64": 0.21.5 - "@esbuild/android-arm": 0.21.5 - "@esbuild/android-arm64": 0.21.5 - "@esbuild/android-x64": 0.21.5 - "@esbuild/darwin-arm64": 0.21.5 - "@esbuild/darwin-x64": 0.21.5 - "@esbuild/freebsd-arm64": 0.21.5 - "@esbuild/freebsd-x64": 0.21.5 - "@esbuild/linux-arm": 0.21.5 - "@esbuild/linux-arm64": 0.21.5 - "@esbuild/linux-ia32": 0.21.5 - "@esbuild/linux-loong64": 0.21.5 - "@esbuild/linux-mips64el": 0.21.5 - "@esbuild/linux-ppc64": 0.21.5 - "@esbuild/linux-riscv64": 0.21.5 - "@esbuild/linux-s390x": 0.21.5 - "@esbuild/linux-x64": 0.21.5 - "@esbuild/netbsd-x64": 0.21.5 - "@esbuild/openbsd-x64": 0.21.5 - "@esbuild/sunos-x64": 0.21.5 - "@esbuild/win32-arm64": 0.21.5 - "@esbuild/win32-ia32": 0.21.5 - "@esbuild/win32-x64": 0.21.5 +"esbuild@npm:^0.25.0": + version: 0.25.12 + resolution: "esbuild@npm:0.25.12" + dependencies: + "@esbuild/aix-ppc64": 0.25.12 + "@esbuild/android-arm": 0.25.12 + "@esbuild/android-arm64": 0.25.12 + "@esbuild/android-x64": 0.25.12 + "@esbuild/darwin-arm64": 0.25.12 + "@esbuild/darwin-x64": 0.25.12 + "@esbuild/freebsd-arm64": 0.25.12 + "@esbuild/freebsd-x64": 0.25.12 + "@esbuild/linux-arm": 0.25.12 + "@esbuild/linux-arm64": 0.25.12 + "@esbuild/linux-ia32": 0.25.12 + "@esbuild/linux-loong64": 0.25.12 + "@esbuild/linux-mips64el": 0.25.12 + "@esbuild/linux-ppc64": 0.25.12 + "@esbuild/linux-riscv64": 0.25.12 + "@esbuild/linux-s390x": 0.25.12 + "@esbuild/linux-x64": 0.25.12 + "@esbuild/netbsd-arm64": 0.25.12 + "@esbuild/netbsd-x64": 0.25.12 + "@esbuild/openbsd-arm64": 0.25.12 + "@esbuild/openbsd-x64": 0.25.12 + "@esbuild/openharmony-arm64": 0.25.12 + "@esbuild/sunos-x64": 0.25.12 + "@esbuild/win32-arm64": 0.25.12 + "@esbuild/win32-ia32": 0.25.12 + "@esbuild/win32-x64": 0.25.12 dependenciesMeta: "@esbuild/aix-ppc64": optional: true @@ -6717,10 +6769,16 @@ __metadata: optional: true "@esbuild/linux-x64": optional: true + "@esbuild/netbsd-arm64": + optional: true "@esbuild/netbsd-x64": optional: true + "@esbuild/openbsd-arm64": + optional: true "@esbuild/openbsd-x64": optional: true + "@esbuild/openharmony-arm64": + optional: true "@esbuild/sunos-x64": optional: true "@esbuild/win32-arm64": @@ -6731,7 +6789,7 @@ __metadata: optional: true bin: esbuild: bin/esbuild - checksum: 2911c7b50b23a9df59a7d6d4cdd3a4f85855787f374dce751148dbb13305e0ce7e880dde1608c2ab7a927fc6cec3587b80995f7fc87a64b455f8b70b55fd8ec1 + checksum: 3d1dc181338e2c44f4374508e9d0da3e7ae90f65d7f3f5d8076ff401a1726c5c9ecc86cfc825249349f1652e12d5ae13f02bcaa4d9487c88c7a11167f52ba353 languageName: node linkType: hard @@ -7337,6 +7395,18 @@ __metadata: languageName: node linkType: hard +"fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: bd537daa9d3cd53887eed35efa0eab2dbb1ca408790e10e024120e7a36c6e9ae2b33710cb8381e35def01bc9c1d7eaba746f886338413e68ff6ebaee07b9a6e8 + languageName: node + linkType: hard + "figures@npm:^1.7.0": version: 1.7.0 resolution: "figures@npm:1.7.0" @@ -7468,9 +7538,9 @@ __metadata: linkType: hard "flatted@npm:^3.2.9": - version: 3.3.3 - resolution: "flatted@npm:3.3.3" - checksum: 8c96c02fbeadcf4e8ffd0fa24983241e27698b0781295622591fc13585e2f226609d95e422bcf2ef044146ffacb6b68b1f20871454eddf75ab3caa6ee5f4a1fe + version: 3.4.2 + resolution: "flatted@npm:3.4.2" + checksum: 1b2536fccbbf75d67a823dea67819f764c19266ad5e4aca6b47f6bf84d3b5e1c15eb5862f7dec1fb87129b60741524933192051286de52baddbc97129896380d languageName: node linkType: hard @@ -7482,12 +7552,12 @@ __metadata: linkType: hard "follow-redirects@npm:^1.14.9": - version: 1.15.9 - resolution: "follow-redirects@npm:1.15.9" + version: 1.16.0 + resolution: "follow-redirects@npm:1.16.0" peerDependenciesMeta: debug: optional: true - checksum: 859e2bacc7a54506f2bf9aacb10d165df78c8c1b0ceb8023f966621b233717dab56e8d08baadc3ad3b9db58af290413d585c999694b7c146aaf2616340c3d2a6 + checksum: e90dce4607b1f6b8b9883287f912585573c19088209ad82341d550a795b4ba514522b73b1b340cf618279df27975cd46504d09149be60291ba6767384c1fd8f8 languageName: node linkType: hard @@ -7588,25 +7658,15 @@ __metadata: languageName: node linkType: hard -"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.38.0, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.40.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.38.0 - motion-utils: ^12.36.0 + motion-dom: ^12.40.0 + motion-utils: ^12.39.0 three: 0.137.0 tslib: ^2.4.0 peerDependencies: @@ -8156,8 +8216,8 @@ __metadata: linkType: hard "handlebars@npm:^4.7.7": - version: 4.7.8 - resolution: "handlebars@npm:4.7.8" + version: 4.7.9 + resolution: "handlebars@npm:4.7.9" dependencies: minimist: ^1.2.5 neo-async: ^2.6.2 @@ -8169,7 +8229,7 @@ __metadata: optional: true bin: handlebars: bin/handlebars - checksum: 00e68bb5c183fd7b8b63322e6234b5ac8fbb960d712cb3f25587d559c2951d9642df83c04a1172c918c41bcfc81bfbd7a7718bbce93b893e0135fc99edea93ff + checksum: ac39070fc1c3c76a654e4b526383eaf1601976eaa474547b263915b4806977f083600e586ca923709baeed7c82a42640bcc9cc04c37a7efd3fb444f49b8347d6 languageName: node linkType: hard @@ -8370,12 +8430,12 @@ __metadata: version: 0.0.0-use.local resolution: "html-env@workspace:dev/html" dependencies: - framer-motion: ^12.38.0 - motion: ^12.38.0 - motion-dom: ^12.38.0 + framer-motion: ^12.40.0 + motion: ^12.40.0 + motion-dom: ^12.40.0 react: ^18.3.1 react-dom: ^18.3.1 - vite: ^5.2.0 + vite: ^6.4.2 languageName: unknown linkType: soft @@ -10469,12 +10529,12 @@ __metadata: linkType: hard "lodash.template@npm:^4.5.0": - version: 4.5.0 - resolution: "lodash.template@npm:4.5.0" + version: 4.18.1 + resolution: "lodash.template@npm:4.18.1" dependencies: lodash._reinterpolate: ^3.0.0 lodash.templatesettings: ^4.0.0 - checksum: ca64e5f07b6646c9d3dbc0fe3aaa995cb227c4918abd1cef7a9024cd9c924f2fa389a0ec4296aa6634667e029bc81d4bbdb8efbfde11df76d66085e6c529b450 + checksum: 9c1f2bd844344d1cc9658d3d31451f00b5adcded6d745319194df97b9d6e469333ea280b3e01a60d3a5254c4db6937e53902759456ea45ae086ed063d3e8e848 languageName: node linkType: hard @@ -10488,9 +10548,9 @@ __metadata: linkType: hard "lodash@npm:^4.17.11, lodash@npm:^4.17.15, lodash@npm:^4.17.19, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.7.0": - version: 4.17.23 - resolution: "lodash@npm:4.17.23" - checksum: 7daad39758a72872e94651630fbb54ba76868f904211089721a64516ce865506a759d9ad3d8ff22a2a49a50a09db5d27c36f22762d21766e47e3ba918d6d7bab + version: 4.18.1 + resolution: "lodash@npm:4.18.1" + checksum: bb5f5b49aad29614e709af02b64c56b0f8b78c6a81434a3c1ae527d2f0f78ca08f9d9fb22aa825a053876c9d2166e9c01f31c356014b5e2bdc0556c057433102 languageName: node linkType: hard @@ -11114,11 +11174,11 @@ __metadata: languageName: node linkType: hard -"motion-dom@^12.38.0, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.40.0, motion-dom@workspace:packages/motion-dom": version: 0.0.0-use.local resolution: "motion-dom@workspace:packages/motion-dom" dependencies: - motion-utils: ^12.36.0 + motion-utils: ^12.39.0 languageName: unknown linkType: soft @@ -11156,7 +11216,6 @@ __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 @@ -11164,7 +11223,6 @@ __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 @@ -11187,17 +11245,17 @@ __metadata: languageName: unknown linkType: soft -"motion-utils@^12.36.0, motion-utils@workspace:packages/motion-utils": +"motion-utils@^12.39.0, motion-utils@workspace:packages/motion-utils": version: 0.0.0-use.local resolution: "motion-utils@workspace:packages/motion-utils" languageName: unknown linkType: soft -"motion@^12.38.0, motion@workspace:packages/motion": +"motion@^12.40.0, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.38.0 + framer-motion: ^12.40.0 tslib: ^2.4.0 peerDependencies: "@emotion/is-prop-valid": "*" @@ -11314,26 +11372,26 @@ __metadata: version: 0.0.0-use.local resolution: "next-env@workspace:dev/next" dependencies: - motion: ^12.38.0 - next: 15.5.10 + motion: ^12.40.0 + next: 15.5.18 react: 19.0.0 react-dom: 19.0.0 languageName: unknown linkType: soft -"next@npm:15.5.10": - version: 15.5.10 - resolution: "next@npm:15.5.10" - dependencies: - "@next/env": 15.5.10 - "@next/swc-darwin-arm64": 15.5.7 - "@next/swc-darwin-x64": 15.5.7 - "@next/swc-linux-arm64-gnu": 15.5.7 - "@next/swc-linux-arm64-musl": 15.5.7 - "@next/swc-linux-x64-gnu": 15.5.7 - "@next/swc-linux-x64-musl": 15.5.7 - "@next/swc-win32-arm64-msvc": 15.5.7 - "@next/swc-win32-x64-msvc": 15.5.7 +"next@npm:15.5.18": + version: 15.5.18 + resolution: "next@npm:15.5.18" + dependencies: + "@next/env": 15.5.18 + "@next/swc-darwin-arm64": 15.5.18 + "@next/swc-darwin-x64": 15.5.18 + "@next/swc-linux-arm64-gnu": 15.5.18 + "@next/swc-linux-arm64-musl": 15.5.18 + "@next/swc-linux-x64-gnu": 15.5.18 + "@next/swc-linux-x64-musl": 15.5.18 + "@next/swc-win32-arm64-msvc": 15.5.18 + "@next/swc-win32-x64-msvc": 15.5.18 "@swc/helpers": 0.5.15 caniuse-lite: ^1.0.30001579 postcss: 8.4.31 @@ -11376,7 +11434,7 @@ __metadata: optional: true bin: next: dist/bin/next - checksum: c60fc5eb6d2fc7868a110a713fc772e69dfb0b13a64dc6ddb3b79a37bfe88c870433959337693b33bc88c1086a0c42e25b6eafbb6e198c935e7e7bff213c40e9 + checksum: 97989a5a3766e859c239921d293111d29f5f3eaec5d4f2c06044d0e1952527cb1ecf09a42656538cb75b140b2bee34979156a3003e4af639d2fd74402f9acad1 languageName: node linkType: hard @@ -12167,13 +12225,6 @@ __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" @@ -12358,9 +12409,9 @@ __metadata: linkType: hard "picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.2, picomatch@npm:^2.2.3, picomatch@npm:^2.3.1": - version: 2.3.1 - resolution: "picomatch@npm:2.3.1" - checksum: 050c865ce81119c4822c45d3c84f1ced46f93a0126febae20737bd05ca20589c564d6e9226977df859ed5e03dc73f02584a2b0faad36e896936238238b0446cf + version: 2.3.2 + resolution: "picomatch@npm:2.3.2" + checksum: 0a3f5b9ff28faf022e1429b66e47c122e19e7b31cbd098095d29e949684e7ff1d9b83a2133d931326a53ec6ec11c7c59b1850c27fde2f26ca1d5f35861e9701a languageName: node linkType: hard @@ -12371,6 +12422,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.4": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 76b387b5157951422fa6049a96bdd1695e39dd126cd99df34d343638dc5cdb8bcdc83fff288c23eddcf7c26657c35e3173d4d5f488c4f28b889b314472e0a662 + languageName: node + linkType: hard + "pify@npm:^2.0.0, pify@npm:^2.2.0, pify@npm:^2.3.0": version: 2.3.0 resolution: "pify@npm:2.3.0" @@ -12498,14 +12556,14 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.4.43": - version: 8.5.6 - resolution: "postcss@npm:8.5.6" +"postcss@npm:^8.5.3": + version: 8.5.9 + resolution: "postcss@npm:8.5.9" dependencies: nanoid: ^3.3.11 picocolors: ^1.1.1 source-map-js: ^1.2.1 - checksum: 20f3b5d673ffeec2b28d65436756d31ee33f65b0a8bedb3d32f556fbd5973be38c3a7fb5b959a5236c60a5db7b91b0a6b14ffaac0d717dce1b903b964ee1c1bb + checksum: 0dcaa32d934fe97ad7b7da0113901f619258485c1906325ad7b873010ef09b83a6c2d52fe9e818c1c90eadcc9dc05870a9e34f34341696c7a07cfbb78b80946b languageName: node linkType: hard @@ -12787,10 +12845,10 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - motion: ^12.38.0 + motion: ^12.40.0 react: ^19.0.0 react-dom: ^19.0.0 - vite: ^5.2.0 + vite: ^6.4.2 languageName: unknown linkType: soft @@ -12873,10 +12931,11 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - framer-motion: ^12.38.0 + framer-motion: ^12.40.0 + motion: ^12.40.0 react: ^18.3.1 react-dom: ^18.3.1 - vite: ^5.2.0 + vite: ^6.4.2 languageName: unknown linkType: soft @@ -13628,30 +13687,35 @@ __metadata: languageName: node linkType: hard -"rollup@npm:^4.20.0": - version: 4.44.0 - resolution: "rollup@npm:4.44.0" - dependencies: - "@rollup/rollup-android-arm-eabi": 4.44.0 - "@rollup/rollup-android-arm64": 4.44.0 - "@rollup/rollup-darwin-arm64": 4.44.0 - "@rollup/rollup-darwin-x64": 4.44.0 - "@rollup/rollup-freebsd-arm64": 4.44.0 - "@rollup/rollup-freebsd-x64": 4.44.0 - "@rollup/rollup-linux-arm-gnueabihf": 4.44.0 - "@rollup/rollup-linux-arm-musleabihf": 4.44.0 - "@rollup/rollup-linux-arm64-gnu": 4.44.0 - "@rollup/rollup-linux-arm64-musl": 4.44.0 - "@rollup/rollup-linux-loongarch64-gnu": 4.44.0 - "@rollup/rollup-linux-powerpc64le-gnu": 4.44.0 - "@rollup/rollup-linux-riscv64-gnu": 4.44.0 - "@rollup/rollup-linux-riscv64-musl": 4.44.0 - "@rollup/rollup-linux-s390x-gnu": 4.44.0 - "@rollup/rollup-linux-x64-gnu": 4.44.0 - "@rollup/rollup-linux-x64-musl": 4.44.0 - "@rollup/rollup-win32-arm64-msvc": 4.44.0 - "@rollup/rollup-win32-ia32-msvc": 4.44.0 - "@rollup/rollup-win32-x64-msvc": 4.44.0 +"rollup@npm:^4.34.9": + version: 4.60.1 + resolution: "rollup@npm:4.60.1" + dependencies: + "@rollup/rollup-android-arm-eabi": 4.60.1 + "@rollup/rollup-android-arm64": 4.60.1 + "@rollup/rollup-darwin-arm64": 4.60.1 + "@rollup/rollup-darwin-x64": 4.60.1 + "@rollup/rollup-freebsd-arm64": 4.60.1 + "@rollup/rollup-freebsd-x64": 4.60.1 + "@rollup/rollup-linux-arm-gnueabihf": 4.60.1 + "@rollup/rollup-linux-arm-musleabihf": 4.60.1 + "@rollup/rollup-linux-arm64-gnu": 4.60.1 + "@rollup/rollup-linux-arm64-musl": 4.60.1 + "@rollup/rollup-linux-loong64-gnu": 4.60.1 + "@rollup/rollup-linux-loong64-musl": 4.60.1 + "@rollup/rollup-linux-ppc64-gnu": 4.60.1 + "@rollup/rollup-linux-ppc64-musl": 4.60.1 + "@rollup/rollup-linux-riscv64-gnu": 4.60.1 + "@rollup/rollup-linux-riscv64-musl": 4.60.1 + "@rollup/rollup-linux-s390x-gnu": 4.60.1 + "@rollup/rollup-linux-x64-gnu": 4.60.1 + "@rollup/rollup-linux-x64-musl": 4.60.1 + "@rollup/rollup-openbsd-x64": 4.60.1 + "@rollup/rollup-openharmony-arm64": 4.60.1 + "@rollup/rollup-win32-arm64-msvc": 4.60.1 + "@rollup/rollup-win32-ia32-msvc": 4.60.1 + "@rollup/rollup-win32-x64-gnu": 4.60.1 + "@rollup/rollup-win32-x64-msvc": 4.60.1 "@types/estree": 1.0.8 fsevents: ~2.3.2 dependenciesMeta: @@ -13675,9 +13739,13 @@ __metadata: optional: true "@rollup/rollup-linux-arm64-musl": optional: true - "@rollup/rollup-linux-loongarch64-gnu": + "@rollup/rollup-linux-loong64-gnu": + optional: true + "@rollup/rollup-linux-loong64-musl": + optional: true + "@rollup/rollup-linux-ppc64-gnu": optional: true - "@rollup/rollup-linux-powerpc64le-gnu": + "@rollup/rollup-linux-ppc64-musl": optional: true "@rollup/rollup-linux-riscv64-gnu": optional: true @@ -13689,17 +13757,23 @@ __metadata: optional: true "@rollup/rollup-linux-x64-musl": optional: true + "@rollup/rollup-openbsd-x64": + optional: true + "@rollup/rollup-openharmony-arm64": + optional: true "@rollup/rollup-win32-arm64-msvc": optional: true "@rollup/rollup-win32-ia32-msvc": optional: true + "@rollup/rollup-win32-x64-gnu": + optional: true "@rollup/rollup-win32-x64-msvc": optional: true fsevents: optional: true bin: rollup: dist/bin/rollup - checksum: 5df779de232030da147f721aab0efc58461e21d2df3e9c3860164cfbafa3272f2bab147d2a90417976708d44767f30043968e231673a39edd4a392d96e03203a + checksum: ae449b5d410523152ba32e2fb18ebe2cd7a8879cea11cbb859442cf429dbb46d85b08b44639df10691a5263058bef1337cb19977d44f11b3a1031de77ed88166 languageName: node linkType: hard @@ -14569,13 +14643,6 @@ __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" @@ -15114,6 +15181,16 @@ __metadata: languageName: node linkType: hard +"tinyglobby@npm:^0.2.13": + version: 0.2.16 + resolution: "tinyglobby@npm:0.2.16" + dependencies: + fdir: ^6.5.0 + picomatch: ^4.0.4 + checksum: db9d22ce1deb1095720a683c492cd5e80da0f71fed21ed697e2752f6f298edd8a1249dab197c86a26f001c180594a81bf532400fe519791ed2a2cb57b03bc337 + languageName: node + linkType: hard + "tmp@npm:^0.0.33": version: 0.0.33 resolution: "tmp@npm:0.0.33" @@ -15909,29 +15986,37 @@ __metadata: languageName: node linkType: hard -"vite@npm:^5.2.0": - version: 5.4.21 - resolution: "vite@npm:5.4.21" +"vite@npm:^6.4.2": + version: 6.4.2 + resolution: "vite@npm:6.4.2" dependencies: - esbuild: ^0.21.3 + esbuild: ^0.25.0 + fdir: ^6.4.4 fsevents: ~2.3.3 - postcss: ^8.4.43 - rollup: ^4.20.0 + picomatch: ^4.0.2 + postcss: ^8.5.3 + rollup: ^4.34.9 + tinyglobby: ^0.2.13 peerDependencies: - "@types/node": ^18.0.0 || >=20.0.0 + "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: ">=1.21.0" less: "*" lightningcss: ^1.21.0 sass: "*" sass-embedded: "*" stylus: "*" sugarss: "*" - terser: ^5.4.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 dependenciesMeta: fsevents: optional: true peerDependenciesMeta: "@types/node": optional: true + jiti: + optional: true less: optional: true lightningcss: @@ -15946,9 +16031,13 @@ __metadata: optional: true terser: optional: true + tsx: + optional: true + yaml: + optional: true bin: vite: bin/vite.js - checksum: 7177fa03cff6a382f225290c9889a0d0e944d17eab705bcba89b58558a6f7adfa1f47e469b88f42a044a0eb40c12a1bf68b3cb42abb5295d04f9d7d4dd320837 + checksum: f0b57b675aaa8a61b03f5e53c0f58ec4c7e77705adae5a8086e3ab8664bf763fb77332a8309a9dad092267bdedc5e583b28c72fc950a2ddce119ac1574181bf5 languageName: node linkType: hard