From ec7fe3f8808517876de4b53d260a045d84000dbb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 06:20:05 +0000 Subject: [PATCH 1/9] docs: Add investigation findings for issue #2458 drag constraints bug Root cause: Drag constraints are cached and only recalculated on drag start, projection "measure" events, or window resize. When the draggable element resizes via CSS, none of these triggers fire, leaving stale constraints that cause overflow. Fix: Attach ResizeObserver to the draggable element (and optionally the constraint container) to trigger constraint recalculation on size changes. https://claude.ai/code/session_013GYT7zpJ72FuvCdmttfsvU --- ISSUE_2458_INVESTIGATION.md | 141 ++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 ISSUE_2458_INVESTIGATION.md diff --git a/ISSUE_2458_INVESTIGATION.md b/ISSUE_2458_INVESTIGATION.md new file mode 100644 index 0000000000..9e154e1f0c --- /dev/null +++ b/ISSUE_2458_INVESTIGATION.md @@ -0,0 +1,141 @@ +# Investigation: Drag Constraints Not Updating on Element Resize (Issue #2458) + +## Summary + +When a draggable element's dimensions change (e.g., through CSS resize), the drag constraints do not update, causing the resized element to overflow/underflow its constraint boundaries. + +**Issue:** https://github.com/motiondivision/motion/issues/2458 + +## Root Cause Analysis + +### How Drag Constraints Work + +When using ref-based constraints (`dragConstraints={containerRef}`), constraints are calculated by comparing the draggable element's layout box to the container's layout box: + +```typescript +// packages/framer-motion/src/gestures/drag/utils/constraints.ts:93-110 +function calcViewportAxisConstraints(layoutAxis: Axis, constraintsAxis: Axis) { + let min = constraintsAxis.min - layoutAxis.min + let max = constraintsAxis.max - layoutAxis.max + // If constraints axis is smaller than layout axis, flip constraints + if (constraintsAxis.max - constraintsAxis.min < layoutAxis.max - layoutAxis.min) { + [min, max] = [max, min] + } + return { min, max } +} +``` + +For example: +- Container: 500px wide +- Draggable element: 100px wide +- Constraints: `{ min: 0, max: 400 }` (can move 400px right before hitting edge) + +### The Bug + +**Constraints are only recalculated in three scenarios:** + +1. **Drag start** (`VisualElementDragControls.ts:144`) - Fresh constraints on each new drag +2. **Projection "measure" event** (`VisualElementDragControls.ts:681-684`) - Only fires when the draggable element's projection updates +3. **Window resize** (`VisualElementDragControls.ts:697-699`) - Calls `scalePositionWithinConstraints()` + +**The problem:** When the draggable element resizes via CSS (e.g., `resize: both`), none of these triggers fire: + +- The user is not starting a new drag +- The projection system doesn't automatically detect CSS-driven size changes +- The window hasn't resized + +The constraints remain cached at their original values (`VisualElementDragControls.ts:76`): +```typescript +private constraints: ResolvedConstraints | false = false +``` + +### Example Scenario + +1. Initial state: + - Container: 500px x 500px + - Draggable: 100px x 100px + - Cached constraints: `{ x: { min: 0, max: 400 }, y: { min: 0, max: 400 } }` + +2. User resizes draggable to 200px x 200px using CSS resize handle + +3. User starts dragging: + - **Expected constraints:** `{ x: { min: 0, max: 300 }, y: { min: 0, max: 300 } }` + - **Actual constraints:** Still `{ x: { min: 0, max: 400 }, y: { min: 0, max: 400 } }` (stale!) + +4. Result: Element can overflow container by 100px in each direction + +## Key Code Locations + +| File | Lines | Description | +|------|-------|-------------| +| `packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts` | 76 | Cached constraints instance variable | +| `packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts` | 346-370 | `resolveConstraints()` - calculates constraints | +| `packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts` | 398-444 | `resolveRefConstraints()` - measures container and calculates viewport constraints | +| `packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts` | 672-677 | `measureDragConstraints()` - only listener for constraint updates | +| `packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts` | 681-684 | Projection "measure" event listener | +| `packages/framer-motion/src/gestures/drag/utils/constraints.ts` | 93-110 | `calcViewportAxisConstraints()` - actual constraint math | + +## Potential Fix + +The drag controls should observe size changes on **both** the draggable element and the constraint container using `ResizeObserver`. The codebase already has a `resizeElement()` utility that wraps `ResizeObserver`: + +```typescript +// packages/motion-dom/src/resize/handle-element.ts +export function resizeElement( + target: ElementOrSelector, + handler: ResizeHandler +): VoidFunction +``` + +### Proposed Solution + +In `VisualElementDragControls.addListeners()`, add a `ResizeObserver` on the draggable element to trigger constraint recalculation: + +```typescript +// In addListeners() method +const { dragConstraints } = this.getProps() + +// Observe the draggable element for size changes +const stopElementResizeListener = resizeElement(element, () => { + // Recalculate constraints when the draggable element resizes + if (isRefObject(dragConstraints) && dragConstraints.current) { + // Need to update layout first + const { projection } = this.visualElement + if (projection) { + projection.updateLayout() + } + this.constraints = this.resolveRefConstraints() + } +}) + +// Optionally, also observe the constraint container +if (isRefObject(dragConstraints) && dragConstraints.current) { + const stopContainerResizeListener = resizeElement( + dragConstraints.current, + () => { + this.constraints = this.resolveRefConstraints() + } + ) +} +``` + +### Implementation Considerations + +1. **Performance:** Only attach observers when using ref-based constraints +2. **Cleanup:** Return cleanup functions from `addListeners()` +3. **Layout sync:** May need to call `projection.updateLayout()` before recalculating constraints +4. **During drag:** Consider whether to update constraints mid-drag or only between drags + +## Related Issues + +- Issue #2903 (cross-referenced) + +## Reproduction + +CodeSandbox: https://codesandbox.io/p/sandbox/framer-motion-drag-constraints-issue-r233cy + +Steps: +1. Drag the modal to verify constraints work initially +2. Resize the modal using the bottom-right resize handle +3. Attempt to drag the resized modal toward screen edges +4. Observe: Element overflows constraint boundaries From 801a699a5398d0adebe2c87078731e066990a1cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 06:59:22 +0000 Subject: [PATCH 2/9] fix(drag): Update constraints when draggable element or container resizes This fixes an issue where drag constraints would not update when the draggable element's dimensions changed (e.g., via CSS resize), causing the element to overflow its constraint boundaries. The fix adds ResizeObserver to both the draggable element and the constraint container (when using ref-based constraints). When either element resizes, the projection layout is updated and constraints are recalculated. Fixes #2458 https://claude.ai/code/session_013GYT7zpJ72FuvCdmttfsvU --- .../drag/VisualElementDragControls.ts | 32 ++++++++ .../gestures/drag/__tests__/index.test.tsx | 81 ++++++++++++++++++- 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts index 5a2001996b..46cfe87201 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, @@ -698,6 +699,35 @@ export class VisualElementDragControls { this.scalePositionWithinConstraints() ) + const { dragConstraints } = this.getProps() + + /** + * If using ref-based constraints, observe both the draggable element and + * the constraint container for size changes. This ensures constraints + * are recalculated when either element resizes (e.g., via CSS resize). + */ + let stopElementResizeObserver: VoidFunction | undefined + let stopContainerResizeObserver: VoidFunction | undefined + + if (isRefObject(dragConstraints) && dragConstraints.current) { + const onResize = () => { + // Update the layout before recalculating constraints + if (projection) { + projection.updateLayout() + } + measureDragConstraints() + } + + // Observe the draggable element for size changes + stopElementResizeObserver = resize(element, onResize) + + // Observe the constraint container for size changes + stopContainerResizeObserver = resize( + dragConstraints.current, + onResize + ) + } + /** * If the element's layout changes, calculate the delta and apply that to * the drag gesture's origin point. @@ -726,6 +756,8 @@ export class VisualElementDragControls { stopPointerListener() stopMeasureLayoutListener() stopLayoutUpdateListener && stopLayoutUpdateListener() + stopElementResizeObserver && stopElementResizeObserver() + stopContainerResizeObserver && stopContainerResizeObserver() } } 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 f2607d493c..2b027aa435 100644 --- a/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx +++ b/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx @@ -1,4 +1,4 @@ -import { useState } from "react" +import { useRef, useState } from "react" import { BoundingBox, motion, @@ -1119,3 +1119,82 @@ describe("keyboard accessible elements", () => { expect(x.get()).toBe(0) }) }) + +describe("ref-based drag constraints", () => { + test("works with ref-based drag constraints", async () => { + const x = motionValue(0) + const y = motionValue(0) + + const Component = () => { + const constraintsRef = useRef(null) + return ( + + + + + + ) + } + + const { getByTestId, rerender } = render() + rerender() + + const pointer = await drag(getByTestId("draggable")).to(1, 1) + await pointer.to(50, 50) + pointer.end() + + await nextFrame() + + // Verify drag works with ref-based constraints + expect(x.get()).toBeGreaterThan(0) + expect(y.get()).toBeGreaterThan(0) + }) + + /** + * Note: Full testing of constraint updates on element resize requires + * E2E tests (Playwright) since the Jest ResizeObserver stub doesn't + * trigger callbacks. See issue #2458 for the bug this addresses. + */ + test("cleans up resize observers on unmount", async () => { + const x = motionValue(0) + const y = motionValue(0) + + const Component = () => { + const constraintsRef = useRef(null) + return ( + + + + + + ) + } + + const { unmount, rerender } = render() + rerender() + + await nextFrame() + + // Should not throw when unmounting (cleanup of resize observers) + expect(() => unmount()).not.toThrow() + }) +}) From 6998023a1ce7f874241aee559e7d7af93d58c231 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 06:59:52 +0000 Subject: [PATCH 3/9] chore: Remove investigation doc (superseded by fix) https://claude.ai/code/session_013GYT7zpJ72FuvCdmttfsvU --- ISSUE_2458_INVESTIGATION.md | 141 ------------------------------------ 1 file changed, 141 deletions(-) delete mode 100644 ISSUE_2458_INVESTIGATION.md diff --git a/ISSUE_2458_INVESTIGATION.md b/ISSUE_2458_INVESTIGATION.md deleted file mode 100644 index 9e154e1f0c..0000000000 --- a/ISSUE_2458_INVESTIGATION.md +++ /dev/null @@ -1,141 +0,0 @@ -# Investigation: Drag Constraints Not Updating on Element Resize (Issue #2458) - -## Summary - -When a draggable element's dimensions change (e.g., through CSS resize), the drag constraints do not update, causing the resized element to overflow/underflow its constraint boundaries. - -**Issue:** https://github.com/motiondivision/motion/issues/2458 - -## Root Cause Analysis - -### How Drag Constraints Work - -When using ref-based constraints (`dragConstraints={containerRef}`), constraints are calculated by comparing the draggable element's layout box to the container's layout box: - -```typescript -// packages/framer-motion/src/gestures/drag/utils/constraints.ts:93-110 -function calcViewportAxisConstraints(layoutAxis: Axis, constraintsAxis: Axis) { - let min = constraintsAxis.min - layoutAxis.min - let max = constraintsAxis.max - layoutAxis.max - // If constraints axis is smaller than layout axis, flip constraints - if (constraintsAxis.max - constraintsAxis.min < layoutAxis.max - layoutAxis.min) { - [min, max] = [max, min] - } - return { min, max } -} -``` - -For example: -- Container: 500px wide -- Draggable element: 100px wide -- Constraints: `{ min: 0, max: 400 }` (can move 400px right before hitting edge) - -### The Bug - -**Constraints are only recalculated in three scenarios:** - -1. **Drag start** (`VisualElementDragControls.ts:144`) - Fresh constraints on each new drag -2. **Projection "measure" event** (`VisualElementDragControls.ts:681-684`) - Only fires when the draggable element's projection updates -3. **Window resize** (`VisualElementDragControls.ts:697-699`) - Calls `scalePositionWithinConstraints()` - -**The problem:** When the draggable element resizes via CSS (e.g., `resize: both`), none of these triggers fire: - -- The user is not starting a new drag -- The projection system doesn't automatically detect CSS-driven size changes -- The window hasn't resized - -The constraints remain cached at their original values (`VisualElementDragControls.ts:76`): -```typescript -private constraints: ResolvedConstraints | false = false -``` - -### Example Scenario - -1. Initial state: - - Container: 500px x 500px - - Draggable: 100px x 100px - - Cached constraints: `{ x: { min: 0, max: 400 }, y: { min: 0, max: 400 } }` - -2. User resizes draggable to 200px x 200px using CSS resize handle - -3. User starts dragging: - - **Expected constraints:** `{ x: { min: 0, max: 300 }, y: { min: 0, max: 300 } }` - - **Actual constraints:** Still `{ x: { min: 0, max: 400 }, y: { min: 0, max: 400 } }` (stale!) - -4. Result: Element can overflow container by 100px in each direction - -## Key Code Locations - -| File | Lines | Description | -|------|-------|-------------| -| `packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts` | 76 | Cached constraints instance variable | -| `packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts` | 346-370 | `resolveConstraints()` - calculates constraints | -| `packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts` | 398-444 | `resolveRefConstraints()` - measures container and calculates viewport constraints | -| `packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts` | 672-677 | `measureDragConstraints()` - only listener for constraint updates | -| `packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts` | 681-684 | Projection "measure" event listener | -| `packages/framer-motion/src/gestures/drag/utils/constraints.ts` | 93-110 | `calcViewportAxisConstraints()` - actual constraint math | - -## Potential Fix - -The drag controls should observe size changes on **both** the draggable element and the constraint container using `ResizeObserver`. The codebase already has a `resizeElement()` utility that wraps `ResizeObserver`: - -```typescript -// packages/motion-dom/src/resize/handle-element.ts -export function resizeElement( - target: ElementOrSelector, - handler: ResizeHandler -): VoidFunction -``` - -### Proposed Solution - -In `VisualElementDragControls.addListeners()`, add a `ResizeObserver` on the draggable element to trigger constraint recalculation: - -```typescript -// In addListeners() method -const { dragConstraints } = this.getProps() - -// Observe the draggable element for size changes -const stopElementResizeListener = resizeElement(element, () => { - // Recalculate constraints when the draggable element resizes - if (isRefObject(dragConstraints) && dragConstraints.current) { - // Need to update layout first - const { projection } = this.visualElement - if (projection) { - projection.updateLayout() - } - this.constraints = this.resolveRefConstraints() - } -}) - -// Optionally, also observe the constraint container -if (isRefObject(dragConstraints) && dragConstraints.current) { - const stopContainerResizeListener = resizeElement( - dragConstraints.current, - () => { - this.constraints = this.resolveRefConstraints() - } - ) -} -``` - -### Implementation Considerations - -1. **Performance:** Only attach observers when using ref-based constraints -2. **Cleanup:** Return cleanup functions from `addListeners()` -3. **Layout sync:** May need to call `projection.updateLayout()` before recalculating constraints -4. **During drag:** Consider whether to update constraints mid-drag or only between drags - -## Related Issues - -- Issue #2903 (cross-referenced) - -## Reproduction - -CodeSandbox: https://codesandbox.io/p/sandbox/framer-motion-drag-constraints-issue-r233cy - -Steps: -1. Drag the modal to verify constraints work initially -2. Resize the modal using the bottom-right resize handle -3. Attempt to drag the resized modal toward screen edges -4. Observe: Element overflows constraint boundaries From 1644b92740cbc1a2717813b869356e382da67ef4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 10:13:43 +0000 Subject: [PATCH 4/9] test(drag): Add Cypress E2E test for drag constraints updating on element resize Adds a test page and Cypress tests that reproduce the exact behavior from issue #2458: a draggable element with ref-based constraints that gets resized should have its constraints recalculated so it cannot overflow the container. Tests cover: - Constraints work correctly before resize - Constraints update after the draggable element is resized - Constraints update after resize even with a prior drag offset https://claude.ai/code/session_013GYT7zpJ72FuvCdmttfsvU --- .../drag-ref-constraints-element-resize.tsx | 61 ++++++++++ .../drag-ref-constraints-element-resize.ts | 105 ++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 dev/react/src/tests/drag-ref-constraints-element-resize.tsx create mode 100644 packages/framer-motion/cypress/integration/drag-ref-constraints-element-resize.ts 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/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) + }) + }) +}) From 037f30a1c808e5bc8545f7fc107c4f18c3194ff6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 10:19:00 +0000 Subject: [PATCH 5/9] test(drag): Remove ref-based constraint unit tests that fail in JSDOM Ref-based drag constraints require a real layout engine to calculate bounding boxes, which JSDOM doesn't provide. The Cypress E2E test covers this behavior properly in a real browser. https://claude.ai/code/session_013GYT7zpJ72FuvCdmttfsvU --- .../gestures/drag/__tests__/index.test.tsx | 81 +------------------ 1 file changed, 1 insertion(+), 80 deletions(-) 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 2b027aa435..f2607d493c 100644 --- a/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx +++ b/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react" +import { useState } from "react" import { BoundingBox, motion, @@ -1119,82 +1119,3 @@ describe("keyboard accessible elements", () => { expect(x.get()).toBe(0) }) }) - -describe("ref-based drag constraints", () => { - test("works with ref-based drag constraints", async () => { - const x = motionValue(0) - const y = motionValue(0) - - const Component = () => { - const constraintsRef = useRef(null) - return ( - - - - - - ) - } - - const { getByTestId, rerender } = render() - rerender() - - const pointer = await drag(getByTestId("draggable")).to(1, 1) - await pointer.to(50, 50) - pointer.end() - - await nextFrame() - - // Verify drag works with ref-based constraints - expect(x.get()).toBeGreaterThan(0) - expect(y.get()).toBeGreaterThan(0) - }) - - /** - * Note: Full testing of constraint updates on element resize requires - * E2E tests (Playwright) since the Jest ResizeObserver stub doesn't - * trigger callbacks. See issue #2458 for the bug this addresses. - */ - test("cleans up resize observers on unmount", async () => { - const x = motionValue(0) - const y = motionValue(0) - - const Component = () => { - const constraintsRef = useRef(null) - return ( - - - - - - ) - } - - const { unmount, rerender } = render() - rerender() - - await nextFrame() - - // Should not throw when unmounting (cleanup of resize observers) - expect(() => unmount()).not.toThrow() - }) -}) From a4df97a6c2e99776d94dae04a85befd1d0942cfa Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 3 Feb 2026 15:57:14 +0100 Subject: [PATCH 6/9] fix(drag): Fix resize observer setup and constraint recalculation Resolve issues with the initial ResizeObserver fix for #2458: - Skip initial ResizeObserver callbacks (fires on observe()) to prevent corrupting element position on mount - Reset constraints before recalculating in scalePositionWithinConstraints so the cached-constraint guard doesn't prevent fresh measurement - Add render flush after repositioning to prevent visual flash - Skip rebasing for ref-based constraints (already in correct coordinate space) Co-Authored-By: Claude Opus 4.5 --- .../drag/VisualElementDragControls.ts | 93 ++++++++++++------- 1 file changed, 61 insertions(+), 32 deletions(-) diff --git a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts index 46cfe87201..8a4d73980a 100644 --- a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts +++ b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts @@ -374,10 +374,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 @@ -620,6 +623,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() /** @@ -638,6 +647,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() { @@ -670,10 +686,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() + ) + } } } @@ -699,35 +731,6 @@ export class VisualElementDragControls { this.scalePositionWithinConstraints() ) - const { dragConstraints } = this.getProps() - - /** - * If using ref-based constraints, observe both the draggable element and - * the constraint container for size changes. This ensures constraints - * are recalculated when either element resizes (e.g., via CSS resize). - */ - let stopElementResizeObserver: VoidFunction | undefined - let stopContainerResizeObserver: VoidFunction | undefined - - if (isRefObject(dragConstraints) && dragConstraints.current) { - const onResize = () => { - // Update the layout before recalculating constraints - if (projection) { - projection.updateLayout() - } - measureDragConstraints() - } - - // Observe the draggable element for size changes - stopElementResizeObserver = resize(element, onResize) - - // Observe the constraint container for size changes - stopContainerResizeObserver = resize( - dragConstraints.current, - onResize - ) - } - /** * If the element's layout changes, calculate the delta and apply that to * the drag gesture's origin point. @@ -756,8 +759,7 @@ export class VisualElementDragControls { stopPointerListener() stopMeasureLayoutListener() stopLayoutUpdateListener && stopLayoutUpdateListener() - stopElementResizeObserver && stopElementResizeObserver() - stopContainerResizeObserver && stopContainerResizeObserver() + stopResizeObservers && stopResizeObservers() } } @@ -783,6 +785,33 @@ export class VisualElementDragControls { } } +function startResizeObservers( + element: HTMLElement, + constraintsElement: HTMLElement, + onResize: VoidFunction +): VoidFunction { + /** + * ResizeObserver fires on initial observation, but we only want to + * respond to actual resizes. Track how many initial callbacks remain + * (one per observed element) and skip them. + */ + let initialCallbacksRemaining = 2 + const handler = () => { + if (initialCallbacksRemaining > 0) { + initialCallbacksRemaining-- + return + } + onResize() + } + + const stopElement = resize(element, handler) + const stopContainer = resize(constraintsElement, handler) + return () => { + stopElement() + stopContainer() + } +} + function shouldDrag( direction: DragDirection, drag: boolean | DragDirection | undefined, From fb58e185369d6d935f5906a2fcd048de1a36a1fe Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 3 Feb 2026 17:01:54 +0100 Subject: [PATCH 7/9] refactor(drag): Extract skipFirstCall helper for ResizeObserver init Each ResizeObserver fires on initial observation. Replace shared counter with per-observer skipFirstCall wrapper so the skip logic doesn't depend on knowing the total number of observed elements. Co-Authored-By: Claude Opus 4.5 --- .../drag/VisualElementDragControls.ts | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts index 8a4d73980a..e0c78cc2d7 100644 --- a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts +++ b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts @@ -785,27 +785,24 @@ 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 { - /** - * ResizeObserver fires on initial observation, but we only want to - * respond to actual resizes. Track how many initial callbacks remain - * (one per observed element) and skip them. - */ - let initialCallbacksRemaining = 2 - const handler = () => { - if (initialCallbacksRemaining > 0) { - initialCallbacksRemaining-- - return - } - onResize() - } - - const stopElement = resize(element, handler) - const stopContainer = resize(constraintsElement, handler) + const stopElement = resize(element, skipFirstCall(onResize)) + const stopContainer = resize(constraintsElement, skipFirstCall(onResize)) return () => { stopElement() stopContainer() From 179a447ab9d9bfb4269b7467c42e6cd882f595ae Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 4 Feb 2026 13:11:52 +0100 Subject: [PATCH 8/9] Updating changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 From eae358f7ab3a65f1f33616fa162095a11fee7c91 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 4 Feb 2026 13:12:06 +0100 Subject: [PATCH 9/9] v12.31.1 --- dev/html/package.json | 6 +++--- dev/next/package.json | 4 ++-- dev/react-19/package.json | 4 ++-- dev/react/package.json | 4 ++-- lerna.json | 2 +- packages/framer-motion/package.json | 2 +- packages/motion/package.json | 4 ++-- yarn.lock | 16 ++++++++-------- 8 files changed, 21 insertions(+), 21 deletions(-) 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/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/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/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