diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a7a1a7056..1bf5e7f851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [12.31.1] 2026-02-04 + +### Added + +- Drag constraints updated even when draggable or constraints resize outside of React renders. + ## [12.31.0] 2026-02-03 ### Added diff --git a/dev/html/package.json b/dev/html/package.json index 86233b9745..80376f9be9 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.31.0", + "version": "12.31.1", "type": "module", "scripts": { "dev": "vite", @@ -10,8 +10,8 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.31.0", - "motion": "^12.31.0", + "framer-motion": "^12.31.1", + "motion": "^12.31.1", "motion-dom": "^12.30.1", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/dev/next/package.json b/dev/next/package.json index c5290d60a2..fe8974c42b 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.31.0", + "version": "12.31.1", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.31.0", + "motion": "^12.31.1", "next": "15.4.10", "react": "19.0.0", "react-dom": "19.0.0" diff --git a/dev/react-19/package.json b/dev/react-19/package.json index b9d6fe6d7b..3848ec2ad5 100644 --- a/dev/react-19/package.json +++ b/dev/react-19/package.json @@ -1,7 +1,7 @@ { "name": "react-19-env", "private": true, - "version": "12.31.0", + "version": "12.31.1", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.31.0", + "motion": "^12.31.1", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index 36ac4e41cb..9689faee8a 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.31.0", + "version": "12.31.1", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.31.0", + "framer-motion": "^12.31.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/react/src/tests/drag-ref-constraints-element-resize.tsx b/dev/react/src/tests/drag-ref-constraints-element-resize.tsx new file mode 100644 index 0000000000..d46b370add --- /dev/null +++ b/dev/react/src/tests/drag-ref-constraints-element-resize.tsx @@ -0,0 +1,61 @@ +import { motion, useMotionValue, useTransform } from "framer-motion" +import { useRef, useCallback } from "react" + +/** + * Test page for issue #2458: Drag constraints should update when + * the draggable element's dimensions change. + * + * Container: 500x500, positioned at top-left + * Draggable: starts at 100x100, can be resized to 300x300 + * + * Before resize: constraints allow 400px of travel (500 - 100) + * After resize: constraints should allow 200px of travel (500 - 300) + */ +export const App = () => { + const constraintsRef = useRef(null) + const widthMV = useMotionValue(100) + const heightMV = useMotionValue(100) + const width = useTransform(widthMV, (v) => `${v}px`) + const height = useTransform(heightMV, (v) => `${v}px`) + + const handleResize = useCallback(() => { + widthMV.set(300) + heightMV.set(300) + }, [widthMV, heightMV]) + + return ( +
+ + + + +
+ ) +} diff --git a/lerna.json b/lerna.json index 01d43bce76..c5e22ac709 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.31.0", + "version": "12.31.1", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/cypress/integration/drag-ref-constraints-element-resize.ts b/packages/framer-motion/cypress/integration/drag-ref-constraints-element-resize.ts new file mode 100644 index 0000000000..432b01a8a8 --- /dev/null +++ b/packages/framer-motion/cypress/integration/drag-ref-constraints-element-resize.ts @@ -0,0 +1,105 @@ +/** + * Tests for issue #2458: Drag constraints should update when the + * draggable element or constraint container resizes. + * + * The test page has: + * - Container (#constraints): 500x500 + * - Draggable (#box): starts 100x100, resizable to 300x300 via button + * + * Before resize: max travel = 400px (500 - 100) + * After resize: max travel = 200px (500 - 300) + */ +describe("Drag Constraints Update on Element Resize", () => { + it("Constrains drag correctly before resize", () => { + cy.visit("?test=drag-ref-constraints-element-resize") + .wait(200) + .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() + // 100x100 box in 500x500 container: max right/bottom = 500 + expect(right).to.be.at.most(502) + expect(bottom).to.be.at.most(502) + }) + }) + + it("Updates drag constraints after draggable element is resized", () => { + cy.visit("?test=drag-ref-constraints-element-resize") + .wait(200) + + // Click resize button to resize draggable from 100x100 to 300x300 + cy.get("#resize-trigger") + .click() + .wait(200) + + // Verify the box is now 300x300 + 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) + }) + + // Now drag to the far bottom-right + 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 in 500x500 container: max right/bottom = 500 + // Without the fix, right/bottom would be ~700 (300 + 400 old constraint) + expect(right).to.be.at.most(502) + expect(bottom).to.be.at.most(502) + }) + }) + + it("Updates drag constraints after draggable element is resized, with existing drag offset", () => { + cy.visit("?test=drag-ref-constraints-element-resize") + .wait(200) + + // First drag to an intermediate position + cy.get("#box") + .trigger("pointerdown", 5, 5) + .trigger("pointermove", 10, 10) + .wait(50) + .trigger("pointermove", 100, 100, { force: true }) + .wait(50) + .trigger("pointerup", { force: true }) + .wait(50) + + // Resize the element from 100x100 to 300x300 + cy.get("#resize-trigger") + .click() + .wait(200) + + // Now drag far to the bottom-right + 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() + // Even after a prior drag + resize, box must stay within container + expect(right).to.be.at.most(502) + expect(bottom).to.be.at.most(502) + }) + }) +}) diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index bfb25eac8e..c4394f1d10 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.31.0", + "version": "12.31.1", "description": "A simple and powerful JavaScript animation library", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", diff --git a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts index 1ac4fb3e4f..53b0284dca 100644 --- a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts +++ b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts @@ -13,6 +13,7 @@ import { PanInfo, percent, ResolvedConstraints, + resize, setDragLock, Transition, type VisualElement, @@ -375,10 +376,13 @@ export class VisualElementDragControls { /** * If we're outputting to external MotionValues, we want to rebase the measured constraints - * from viewport-relative to component-relative. + * from viewport-relative to component-relative. This only applies to relative (non-ref) + * constraints, as ref-based constraints from calcViewportConstraints are already in the + * correct coordinate space for the motion value transform offset. */ if ( prevConstraints !== this.constraints && + !isRefObject(dragConstraints) && layout && this.constraints && !this.hasMutatedConstraints @@ -621,6 +625,12 @@ export class VisualElementDragControls { : "none" projection.root && projection.root.updateScroll() projection.updateLayout() + + /** + * Reset constraints so resolveConstraints() will recalculate them + * with the freshly measured layout rather than returning the cached value. + */ + this.constraints = false this.resolveConstraints() /** @@ -639,6 +649,13 @@ export class VisualElementDragControls { ] as Axis axisValue.set(mixNumber(min, max, boxProgress[axis])) }) + + /** + * Flush the updated transform to the DOM synchronously to prevent + * a visual flash at the element's CSS layout position (0,0) when + * the transform was stripped for measurement. + */ + this.visualElement.render() } addListeners() { @@ -673,10 +690,26 @@ export class VisualElementDragControls { } ) + /** + * If using ref-based constraints, observe both the draggable element + * and the constraint container for size changes via ResizeObserver. + * Setup is deferred because dragConstraints.current is null when + * addListeners first runs (React hasn't committed the ref yet). + */ + let stopResizeObservers: VoidFunction | undefined + const measureDragConstraints = () => { const { dragConstraints } = this.getProps() if (isRefObject(dragConstraints) && dragConstraints.current) { this.constraints = this.resolveRefConstraints() + + if (!stopResizeObservers) { + stopResizeObservers = startResizeObservers( + element, + dragConstraints.current as HTMLElement, + () => this.scalePositionWithinConstraints() + ) + } } } @@ -730,6 +763,7 @@ export class VisualElementDragControls { stopPointerListener() stopMeasureLayoutListener() stopLayoutUpdateListener && stopLayoutUpdateListener() + stopResizeObservers && stopResizeObservers() } } @@ -755,6 +789,30 @@ export class VisualElementDragControls { } } +function skipFirstCall(callback: VoidFunction): VoidFunction { + let isFirst = true + return () => { + if (isFirst) { + isFirst = false + return + } + callback() + } +} + +function startResizeObservers( + element: HTMLElement, + constraintsElement: HTMLElement, + onResize: VoidFunction +): VoidFunction { + const stopElement = resize(element, skipFirstCall(onResize)) + const stopContainer = resize(constraintsElement, skipFirstCall(onResize)) + return () => { + stopElement() + stopContainer() + } +} + function shouldDrag( direction: DragDirection, drag: boolean | DragDirection | undefined, diff --git a/packages/motion/package.json b/packages/motion/package.json index e825a2018a..8ec9a4c206 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.31.0", + "version": "12.31.1", "description": "An animation library for JavaScript and React.", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -76,7 +76,7 @@ "postpublish": "git push --tags" }, "dependencies": { - "framer-motion": "^12.31.0", + "framer-motion": "^12.31.1", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 6069017874..0c72a6a06f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7420,7 +7420,7 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.31.0, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.31.1, framer-motion@workspace:packages/framer-motion": version: 0.0.0-use.local resolution: "framer-motion@workspace:packages/framer-motion" dependencies: @@ -8192,8 +8192,8 @@ __metadata: version: 0.0.0-use.local resolution: "html-env@workspace:dev/html" dependencies: - framer-motion: ^12.31.0 - motion: ^12.31.0 + framer-motion: ^12.31.1 + motion: ^12.31.1 motion-dom: ^12.30.1 react: ^18.3.1 react-dom: ^18.3.1 @@ -11013,11 +11013,11 @@ __metadata: languageName: unknown linkType: soft -"motion@^12.31.0, motion@workspace:packages/motion": +"motion@^12.31.1, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.31.0 + framer-motion: ^12.31.1 tslib: ^2.4.0 peerDependencies: "@emotion/is-prop-valid": "*" @@ -11134,7 +11134,7 @@ __metadata: version: 0.0.0-use.local resolution: "next-env@workspace:dev/next" dependencies: - motion: ^12.31.0 + motion: ^12.31.1 next: 15.4.10 react: 19.0.0 react-dom: 19.0.0 @@ -12599,7 +12599,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - motion: ^12.31.0 + motion: ^12.31.1 react: ^19.0.0 react-dom: ^19.0.0 vite: ^5.2.0 @@ -12683,7 +12683,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - framer-motion: ^12.31.0 + framer-motion: ^12.31.1 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0