Skip to content

Conversation

@mattgperry
Copy link
Collaborator

Summary

  • Fixes drag element detaching from cursor when scrolling a container with layoutScroll during an active drag
  • Tracks scroll positions of layoutScroll ancestors when drag starts
  • Adjusts origin point on scroll events to compensate for scroll movement

Fixes #1691

Test plan

  • Verify drag element stays attached to cursor when scrolling during drag
  • Verify normal drag behavior is unaffected
  • Verify cleanup occurs properly when drag ends

🤖 Generated with Claude Code

@claude
Copy link

claude bot commented Jan 5, 2026

Code Review: Fix drag element detaching from cursor during scroll

Summary

This PR addresses issue #1691 where dragging elements would detach from the cursor when scrolling a container with layoutScroll enabled. The solution tracks scroll positions of layoutScroll ancestors and adjusts the origin point during scroll events.

Positive Aspects ✅

  1. Well-targeted solution: The fix correctly identifies that layoutScroll ancestors need special handling during drag operations.

  2. Clean architecture: The addition of getScrollableAncestors(), startScrollTracking(), and onScroll() methods follows the existing class structure well.

  3. Proper cleanup: The scroll listeners are correctly cleaned up in the cancel() method (lines 321-324).

  4. Type safety improvement: Converting to import type for LayoutUpdateData (line 22) is a good practice.

  5. Documentation: Clear JSDoc comments explaining the purpose of each new method.

Issues & Concerns 🚨

1. Memory Leak Risk - WeakMap Not Properly Cleared

Severity: Medium

The initialAncestorScrolls WeakMap is never cleared in the cleanup logic. While WeakMaps allow garbage collection when keys are removed, you should explicitly clear stored values when drag ends to prevent potential issues if elements are reused.

Recommendation:

cancel() {
    this.isDragging = false

    const { projection, animationState } = this.visualElement

    if (projection) {
        projection.isAnimationBlocked = false
    }

    this.panSession && this.panSession.end()
    this.panSession = undefined

    const { dragPropagation } = this.getProps()

    if (!dragPropagation && this.openDragLock) {
        this.openDragLock()
        this.openDragLock = null
    }

    if (this.removeScrollListeners) {
        this.removeScrollListeners()
        this.removeScrollListeners = null
    }

    // Clear scroll tracking data
    const ancestors = this.getScrollableAncestors()
    for (const element of ancestors) {
        this.initialAncestorScrolls.delete(element)
    }

    animationState && animationState.setActive("whileDrag", false)
}

2. Logic Error in Scroll Adjustment Calculation

Severity: High

Lines 618-623 contain redundant and potentially confusing logic:

const newOrigin = this.originPoint[axis] - scrollDelta
if (newOrigin !== this.originPoint[axis]) {
    const adjustment = this.originPoint[axis] - newOrigin
    this.originPoint[axis] = newOrigin
    motionValue.set(motionValue.get() + adjustment)
}

The adjustment calculation is simply scrollDelta (since adjustment = originPoint - (originPoint - scrollDelta) = scrollDelta). This can be simplified:

Recommendation:

if (scrollDelta !== 0) {
    this.originPoint[axis] -= scrollDelta
    motionValue.set(motionValue.get() + scrollDelta)
}

3. Inefficient Repeated DOM Queries

Severity: Medium

In onScroll() (line 598), getScrollableAncestors() is called which traverses the entire projection path on every scroll event. Since the ancestors don't change during a drag session, this should be cached.

Recommendation:

// Add private field
private scrollableAncestors: Element[] = []

// In startScrollTracking()
private startScrollTracking(): void {
    this.scrollableAncestors = this.getScrollableAncestors()

    // Capture initial scroll positions
    for (const element of this.scrollableAncestors) {
        this.initialAncestorScrolls.set(element, {
            x: element.scrollLeft,
            y: element.scrollTop,
        })
    }

    // Attach scroll listeners
    const cleanupFns: VoidFunction[] = this.scrollableAncestors.map((element) =>
        addDomEvent(element, "scroll", this.onScroll, { passive: true })
    )

    this.removeScrollListeners = () => cleanupFns.forEach((fn) => fn())
}

// In onScroll()
private onScroll = (): void => {
    if (!this.isDragging) return

    eachAxis((axis) => {
        const motionValue = this.getAxisMotionValue(axis)
        if (!motionValue) return

        let scrollDelta = 0
        for (const element of this.scrollableAncestors) {
            // ...
        }
        // ...
    })

    // Update stored positions
    for (const element of this.scrollableAncestors) {
        this.initialAncestorScrolls.set(element, {
            x: element.scrollLeft,
            y: element.scrollTop,
        })
    }

    this.visualElement.render()
}

// In cancel()
cancel() {
    // ... existing code ...
    
    if (this.removeScrollListeners) {
        this.removeScrollListeners()
        this.removeScrollListeners = null
    }
    
    // Clear cached data
    for (const element of this.scrollableAncestors) {
        this.initialAncestorScrolls.delete(element)
    }
    this.scrollableAncestors = []

    // ... rest of cleanup ...
}

4. Missing Edge Case: Scroll During Constraint Resolution

Severity: Low

If scrolling occurs between onStart (line 133) and when constraints are resolved (line 152), the initial scroll positions might not accurately reflect the true starting state. Consider moving startScrollTracking() after constraint resolution.

Current order (line 191):

addValueToWillChange(this.visualElement, "transform")
this.startScrollTracking()  // Before animationState

Suggested order:

this.resolveConstraints()  // Line 152
// ... existing origin recording code ...
this.startScrollTracking()  // After constraints are resolved
addValueToWillChange(this.visualElement, "transform")

5. Potential Race Condition with isDragging Flag

Severity: Low

The onScroll handler checks isDragging at line 599, but isDragging is set to true at line 148, before startScrollTracking() is called at line 191. There's a timing window where scroll events could fire after isDragging = true but before listeners are attached. While this is unlikely to cause issues (listeners aren't attached yet), it's worth noting for defensive programming.

Performance Considerations ⚡

  1. Scroll event frequency: Scroll events can fire very frequently. The current implementation is reasonable with passive: true, but consider throttling if performance issues arise on low-end devices.

  2. Unnecessary re-renders: this.visualElement.render() is called on every scroll event (line 634). Consider debouncing or only rendering when the scroll delta exceeds a threshold.

Testing Recommendations 🧪

The PR's test plan checklist is incomplete. Consider adding:

  1. Unit test for scroll tracking lifecycle:

    • Verify listeners are attached on drag start
    • Verify listeners are cleaned up on drag end
    • Verify WeakMap is cleared properly
  2. Integration test for scroll during drag:

    • Test with nested layoutScroll containers
    • Test with both x and y scrolling
    • Test rapid scrolling during drag
    • Test drag cancellation during scroll
  3. Edge case tests:

    • Drag with no layoutScroll ancestors (should no-op cleanly)
    • Drag start/stop rapidly before scroll events fire
    • Multiple simultaneous drags (if supported)

Example test structure:

test("scroll listeners are properly cleaned up after drag", async () => {
    const removeScrollSpy = jest.fn()
    // ... setup component with layoutScroll container ...
    
    const pointer = await drag(element).to(50, 50)
    // Verify listeners attached
    
    pointer.end()
    await nextFrame()
    
    // Verify listeners removed and WeakMap cleared
})

Security Considerations 🔒

No security concerns identified. The use of passive: true for scroll listeners is appropriate and prevents scroll blocking.

Style & Convention Notes 📝

  1. ✅ Uses interface types (line 22 change)
  2. ✅ Arrow function for event handler (onScroll)
  3. ✅ Proper use of private members
  4. ✅ Consistent with existing code style

Conclusion

This is a solid fix for a real UX issue, but there are some implementation details that should be addressed before merging:

Must fix:

Should fix:

Nice to have:

  • Consider throttling/debouncing for performance
  • Move scroll tracking after constraint resolution

Overall assessment: Approve with changes requested ✏️

@claude
Copy link

claude bot commented Jan 6, 2026

Pull Request Review

Great work on fixing the scroll-while-drag issue! This is a well-structured solution that addresses a real UX problem. Here's my detailed review:

🎯 Code Quality: Excellent

Strengths:

  • Clean separation of concerns with dedicated methods (getScrollableAncestors, startScrollTracking, onScroll)
  • Proper cleanup in the cancel method
  • Good use of WeakMap for tracking scroll positions (prevents memory leaks)
  • Arrow function for onScroll ensures proper this context
  • Comprehensive JSDoc comments

Minor suggestion:

  • In VisualElementDragControls.ts:576, you're excluding node.root from ancestors. Consider adding a brief inline comment explaining why, as it's not immediately obvious to future maintainers.

🐛 Potential Issues

1. Scroll event race condition (Medium Priority)

In onScroll (line 610-643), there's a potential issue with the incremental delta calculation:

// Calculate delta from initial positions
scrollDelta += current - initial[axis]
// ...
// Then update stored positions
this.initialAncestorScrolls.set(element, { x: element.scrollLeft, y: element.scrollTop })

If multiple scroll events fire before a render completes, you might compound the adjustment. Consider:

  • Debouncing/throttling the scroll handler, OR
  • Using requestAnimationFrame to batch updates

2. Missing null check (Low Priority)

Line 614-615: getAxisMotionValue could theoretically return undefined for external motion values. While you have a return guard, TypeScript might not catch this in all scenarios. Consider more explicit typing or a runtime assertion.

Performance Considerations

Good:

  • ✅ Passive scroll listeners ({ passive: true })
  • ✅ Early return in onScroll when not dragging
  • ✅ Efficient WeakMap usage

Concern:

  • Calling visualElement.render() on every scroll event (line 642) could be expensive during rapid scrolling. Consider:
    • Throttling with requestAnimationFrame
    • Only rendering if scrollDelta !== 0 (you check this per-axis, but could aggregate)

🔒 Security: No concerns

  • No user input is directly processed
  • No dynamic property access on untrusted objects
  • Proper event listener cleanup prevents memory leaks

🧪 Test Coverage: Excellent

Strengths:

  • Comprehensive E2E tests covering multiple scenarios
  • Tests for vertical, horizontal, and multiple scrolls
  • Baseline test for normal drag behavior
  • Good use of wait() for animation frames

Minor suggestions:

  1. Test edge case: What happens if layoutScroll ancestor is removed from DOM during drag?
  2. Test nested scrollables: Multiple layoutScroll ancestors at different nesting levels
  3. Consider adding a comment in the test about why you use expect(left).to.equal(150) (lines 34-35) - the comment explains the logic but could be clearer about expected viewport coordinates

📝 Code Style Adherence

✅ Uses interface for types (line 100: WeakMap with Point interface)
✅ No default exports
✅ Arrow functions for callbacks
✅ Proper type imports (type { LayoutUpdateData })
✅ No var declarations

🎨 Architecture & Design

Excellent design choices:

  • Using the projection path to find layoutScroll ancestors is elegant and leverages existing infrastructure
  • The compensation strategy (adjusting both origin and motion value) is mathematically sound
  • Cleanup lifecycle is properly managed

Question for consideration:

  • Should this feature be opt-in/opt-out? Some users might want the current behavior. Consider adding a prop like dragIgnoreScroll in a future iteration (not blocking for this PR).

🔍 Specific Line-by-Line Notes

  • Line 196: ✅ Good placement - called after origin is recorded but before whileDrag animation state
  • Line 576: Consider node.options.layoutScroll === true for more explicit boolean check (minor nitpick)
  • Line 629-630: The math is correct, but a comment explaining why origin decreases when scroll increases would help: // Scroll down → element appears to move up in viewport → decrease origin to compensate

Summary

This is high-quality code that solves a legitimate user problem. The implementation is clean, well-tested, and follows best practices. My main concerns are:

  1. Performance: Consider throttling the scroll handler
  2. Edge cases: Test nested scrollables and DOM removal during drag

These are minor issues that could be addressed in follow-up PRs. I'd recommend approving with minor suggestions.

Suggested action items:

  • Add requestAnimationFrame throttling to onScroll
  • Add inline comment at line 576 explaining root exclusion
  • Add test for nested layoutScroll ancestors
  • Consider future opt-out mechanism for the feature

Great work overall! 🚀

@claude
Copy link

claude bot commented Jan 6, 2026

Pull Request Review: Fix drag element detaching from cursor during scroll

Overview

This PR addresses issue #1691 where draggable elements detach from the cursor when scrolling occurs during an active drag operation. The solution tracks scroll positions of ancestor containers and adjusts the drag origin point to compensate.

Code Quality & Best Practices ✅

Strengths:

  1. Clean architecture: The solution is well-encapsulated within the VisualElementDragControls class
  2. Good separation of concerns: Scroll tracking logic is isolated in dedicated methods
  3. Proper cleanup: Event listeners are properly removed in the cancel() method
  4. Clear documentation: JSDoc comments explain the purpose of new properties and methods
  5. Follows existing patterns: Uses same patterns as the codebase (e.g., eachAxis, addDomEvent)
  6. Type safety: Uses proper TypeScript types throughout

Minor Style Notes:

  • Code follows the repo's conventions (interfaces, arrow callbacks, named exports)
  • Proper use of passive: true for scroll listeners (performance optimization)

Potential Issues & Bugs 🔍

1. Scroll Delta Accumulation Logic (Medium Priority)

Location: packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts:619-626

The current implementation accumulates scroll deltas from ALL scrollable ancestors:

let scrollDelta = 0
for (const target of this.scrollableAncestors) {
    const initial = this.initialAncestorScrolls.get(target)
    if (!initial) continue
    
    const current = getScrollPosition(target)
    scrollDelta += current[axis] - initial[axis]
}

Issue: This assumes scroll deltas are independent, but nested scrollable containers create a hierarchical relationship. If both a parent and child container scroll, you might be double-counting the scroll offset.

Recommendation: Consider testing with deeply nested scrollable containers to verify the delta calculation is correct.

2. Performance Consideration (Low Priority)

Location: packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts:612

The onScroll handler runs on every scroll event (potentially high frequency) and:

  • Iterates through all scrollable ancestors
  • Reads scroll positions (forces layout)
  • Calls visualElement.render()

Recommendation: While passive: true helps, consider throttling/debouncing if performance issues arise with many scrollable ancestors.

3. Edge Case: Dynamic DOM Changes

Location: packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts:591-606

Scrollable ancestors are computed once when drag starts. If the DOM structure changes during drag (e.g., a parent becomes scrollable), the tracking won't update.

Assessment: This is likely acceptable since DOM changes during drag are rare, but worth documenting.

4. Memory Leak Risk (Low)

Location: packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts:100-110

The Map and arrays are cleared in cancel(), but if cancel() is never called due to an error, these could persist.

Assessment: The existing code already has this pattern with other properties, so this is consistent with current risk tolerance.

Test Coverage 🧪

Strengths:

  1. Comprehensive E2E tests: 7 test cases covering various scenarios
  2. Both element and window scrolling: Tests cover both types of scrollable containers
  3. Multiple scroll events: Tests verify handling of multiple scrolls during a single drag
  4. Control tests: Includes tests for normal drag behavior without scrolling

Suggestions:

  1. Add test for nested scrollable containers: Test a draggable inside multiple nested scrollable divs
  2. Test horizontal + vertical scroll together: Current tests do both separately but not simultaneously
  3. Unit tests: Consider adding unit tests for isScrollable() and getScrollPosition() helper functions
  4. Test edge case: Draggable element inside a transformed container that also scrolls

Security Concerns 🔒

No security issues identified. The implementation:

  • Doesn't introduce any DOM manipulation vulnerabilities
  • Uses existing event handling patterns from the codebase
  • Doesn't expose user input to injection risks

Performance Considerations ⚡

Current Implementation:

  • Scroll listeners: Added with passive: true
  • DOM queries: window.getComputedStyle() and element.scrollWidth/Height in isScrollable() (called once per ancestor at drag start) ✅
  • Per-scroll overhead: Map lookups and scroll position reads (acceptable for typical use cases) ✅

Recommendations:

  1. If users report performance issues with many scrollable ancestors (unlikely), consider:
    • Caching computed styles
    • Limiting the number of ancestor levels checked
    • Using IntersectionObserver API as an alternative approach

Additional Recommendations 📝

1. Consider layoutScroll Integration

The issue mentions layoutScroll specifically. Verify this solution works correctly with Framer Motion's layout projection system when layoutScroll is enabled.

2. Documentation

Consider adding:

  • A note in user-facing docs about scroll-during-drag support
  • JSDoc example showing expected behavior
  • Comment explaining the delta compensation formula at line 630

3. Browser Compatibility

The implementation uses:

  • window.scrollX/scrollY (supported by all modern browsers)
  • scrollLeft/scrollTop (universal support)

No compatibility issues expected. ✅

Summary

This is a solid implementation that effectively solves the reported issue. The code is clean, well-tested, and follows repository conventions. The main areas for follow-up are:

  1. Approve and merge - The core implementation is sound
  2. 🔍 Test nested containers - Add a test case for deeply nested scrollable containers
  3. 📚 Document behavior - Add inline comment explaining delta compensation
  4. 🎯 Monitor performance - Watch for reports of scroll performance issues (unlikely)

Overall Assessment: Ready to merge with minor suggestions for follow-up improvements. Great work! 🎉

@claude
Copy link

claude bot commented Jan 6, 2026

Pull Request Review

I've reviewed PR #3449 that fixes drag element detaching from cursor during scroll. Overall, this is a well-implemented solution with comprehensive test coverage. Below are my findings:


Strengths

  1. Clean Architecture: The solution elegantly tracks scroll ancestors and adjusts the origin point to compensate for scroll movement during drag.

  2. Comprehensive Test Coverage: Excellent Cypress test suite covering:

    • Container scrolling during drag
    • Window scrolling during drag
    • Vertical/horizontal scroll separately
    • Multiple scrolls during a single drag
    • Normal drag behavior (regression testing)
  3. Proper Cleanup: Scroll listeners are correctly removed in the cancel() method, preventing memory leaks.

  4. Performance Considerations: Using passive scroll listeners ({ passive: true }) is the right choice for scroll performance.


⚠️ Issues & Recommendations

1. Potential Bug: Cumulative Scroll Delta Calculation

Location: packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts:619-626

The current implementation sums scroll deltas across ALL ancestors:

let scrollDelta = 0
for (const target of this.scrollableAncestors) {
    const initial = this.initialAncestorScrolls.get(target)
    if (!initial) continue
    
    const current = getScrollPosition(target)
    scrollDelta += current[axis] - initial[axis]
}

Problem: When you have nested scrollable containers, the scroll offset of a parent already includes the visual effect of its children's scroll positions. By summing them, you may be double-counting the scroll adjustment.

Example scenario:

  • Window scrolls 100px down
  • Inner container (child of window) scrolls 50px down
  • Current code adjusts by 150px total
  • But the visual displacement is only ~100px (window) + 50px (container relative to window)

Recommendation: Test with deeply nested scrollable containers to verify behavior. You may need to adjust the calculation to account for the DOM hierarchy.


2. Performance: Unnecessary Window Inclusion

Location: packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts:582-583

// Always include window for document-level scrolling
ancestors.push(window)

Issue: Window is always added even when the document isn't scrollable (document.documentElement.scrollHeight <= window.innerHeight).

Impact: Adds an unnecessary scroll listener on every drag operation.

Recommendation:

// Only include window if document is scrollable
if (document.documentElement.scrollHeight > window.innerHeight ||
    document.documentElement.scrollWidth > window.innerWidth) {
    ancestors.push(window)
}

3. Edge Case: Mid-drag Ancestor Scroll State Changes

Location: packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts:591-606

Issue: Scrollable ancestors are determined only at drag start. If CSS changes during drag (e.g., a container becomes scrollable via dynamic class), new scrollable ancestors won't be tracked.

Severity: Low (rare scenario)

Recommendation: Document this limitation in code comments, or consider re-checking on scroll events if performance allows.


4. Code Quality: Magic Number

Location: packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts:841-848

The isScrollable function checks scrollWidth > clientWidth without a threshold. In practice, floating-point rounding can cause 1px differences.

Recommendation:

const threshold = 1
const canScrollX =
    (overflowX === "scroll" || overflowX === "auto") &&
    element.scrollWidth > element.clientWidth + threshold

5. Test Gap: Constraints + Scroll

Missing test case: Verify that dragConstraints still work correctly when scrolling occurs during drag. The scroll adjustment might interact unexpectedly with constraint boundaries.

Recommendation: Add a test with dragConstraints prop set while scrolling.


6. Test Gap: layoutScroll Property

Issue: The PR description mentions fixing issues with layoutScroll ancestors, but I don't see any tests specifically using the layoutScroll prop mentioned in #1691.

Recommendation: Add a test that explicitly uses layoutScroll on ancestor containers to verify the fix for the original issue.


🔒 Security

No security concerns identified. The code:

  • ✅ Uses passive listeners (no preventDefault needed)
  • ✅ Properly cleans up event listeners
  • ✅ Doesn't execute user-provided code in event handlers
  • ✅ No XSS or injection vectors

🎯 Final Verdict

Status: Approve with minor improvements recommended

Priority fixes:

  1. Test/verify nested scrollable container behavior (issue Initial API (WIP) #1)
  2. Add test for dragConstraints + scroll interaction
  3. Add explicit layoutScroll test case

Nice to have:

Great work on this feature! The implementation is solid and the test coverage is thorough. The issues I've identified are mostly edge cases worth considering.


Test Plan Status:

  • ✅ Verify drag element stays attached to cursor when scrolling during drag
  • ✅ Verify normal drag behavior is unaffected
  • ✅ Verify cleanup occurs properly when drag ends

All test plan items are covered by the Cypress tests.

@claude
Copy link

claude bot commented Jan 6, 2026

Code Review

Thank you for addressing issue #1691! This PR implements scroll compensation during drag operations. I've reviewed the implementation and have the following feedback:

Critical Issues

1. Console Logs in Production Code ⚠️

The implementation contains extensive debug logging (lines 573, 577, 581, 591, 605, 611, 624, 638, 645, 648, 864-873) that should be removed before merging:

  • packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts:573-591 - getScrollableAncestors()
  • packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts:605, 611 - startScrollTracking()
  • packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts:624, 638, 645, 648 - onScroll()
  • packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts:864-873 - isScrollable()

Recommendation: Remove all console.log statements or wrap them in a development-only check.

2. Potential Memory Leak ⚠️

If the component unmounts while dragging, scroll listeners may not be cleaned up. The cancel() method handles this, but component unmount should also trigger cleanup.

Recommendation: Consider storing the cleanup function as part of addListeners() return value or ensure cancel() is called on unmount.

Performance Considerations

3. Scroll Event Performance

The onScroll handler (lines 623-658) runs on every scroll event with passive: true, which is good. However:

  • It iterates through all ancestors and recalculates scroll deltas
  • Calls this.visualElement.render() on every scroll

Recommendation: Consider throttling or using requestAnimationFrame if performance issues arise in deeply nested scenarios.

4. Redundant Scroll Position Updates

Lines 653-655 update initialAncestorScrolls after every scroll event. This converts the system from delta-based to position-based tracking, which is correct but could be clarified with a comment.

Code Quality

5. Type Safety

Good use of TypeScript types throughout. The Map<Element | Window, Point> type is appropriate.

6. Code Style

Follows repository conventions (using interface per CLAUDE.md rules would apply to new interfaces, none added here).

7. Edge Case Handling

The implementation handles several edge cases well:

  • Empty element check (line 572)
  • Window vs Element scroll position (lines 881-889)
  • Both horizontal and vertical scrolling
  • Multiple scrollable ancestors

Test Coverage

8. Comprehensive E2E Tests

The Cypress tests cover:

  • ✅ Element scroll during drag
  • ✅ Window scroll during drag
  • ✅ Vertical/horizontal scroll only
  • ✅ Multiple scrolls during drag
  • ✅ Normal drag without scroll

Great test coverage!

9. Missing Test Case

Consider adding a test for:

  • Drag element with nested scrollable containers (multiple ancestors)
  • Component unmount during active drag

Architectural Considerations

10. Scroll Detection Logic

The isScrollable() function (lines 852-876) checks both overflow style AND actual scrollable content. This is correct and prevents attaching listeners to elements that can't scroll.

11. Coordinate System Handling

The implementation correctly compensates by adjusting originPoint (line 646), which maintains the relationship between cursor and element across scroll events.

Security

No security concerns identified. This is purely DOM manipulation without user input sanitization risks.

Summary

Strengths:

  • Solves the reported issue effectively
  • Comprehensive test coverage
  • Handles window and element scrolling
  • Proper cleanup of event listeners

Required Changes:

  • ❌ Remove all console.log statements

Suggested Improvements:

  • Consider performance optimizations for deeply nested scenarios
  • Add comment explaining incremental scroll position updates

Verdict: Approve after removing debug logging. The implementation is solid and well-tested.

@claude
Copy link

claude bot commented Jan 6, 2026

PR Review: Fix drag element detaching from cursor during scroll

Summary

This PR addresses issue #1691 by preventing drag elements from detaching from the cursor when scrolling a container with layoutScroll during an active drag operation. The implementation tracks scroll positions of ancestors and adjusts the origin point accordingly.

Code Quality & Best Practices ✅

Strengths

  • Clean architecture: The scroll tracking logic is well-encapsulated within the VisualElementDragControls class
  • Clear naming: Method names like startScrollTracking(), getScrollableAncestors(), and onScroll() are self-documenting
  • Good documentation: Comprehensive JSDoc comments explain the purpose of new class properties and methods
  • Proper cleanup: Scroll listeners are correctly removed in the cancel() method
  • Consistent style: Follows the codebase conventions (arrow functions for callbacks, interfaces over types)

Minor Suggestions

  1. Line 196: Consider adding a comment explaining why scroll tracking starts after addValueToWillChange (if there's a specific reason)
  2. Lines 600-605: The cleanup function creation could be simplified:
const cleanupFns = this.scrollableAncestors.map(target =>
    addDomEvent(target, "scroll", this.onScroll, { passive: true })
)
this.removeScrollListeners = () => cleanupFns.forEach(fn => fn())

Potential Bugs & Issues 🔍

Critical Issues

None identified.

Observations

  1. Memory leak potential (low risk): If cancel() is never called (edge case), scroll listeners could remain attached. However, this is already handled properly in the existing lifecycle.

  2. Scroll accumulation logic (lines 619-626): The current implementation accumulates scroll deltas from all ancestors. This is correct for nested scrollable containers, but worth verifying with nested scroll tests.

  3. Race condition consideration: If a scroll event fires after isDragging becomes false but before listeners are removed, the early return on line 613 handles it correctly. ✅

Performance Considerations ⚡

Potential Optimizations

  1. Scroll event throttling: Currently, every scroll event triggers a full recalculation. Consider throttling with requestAnimationFrame:
private onScroll = (): void => {
    if (!this.isDragging || this.scheduledScrollUpdate) return
    
    this.scheduledScrollUpdate = true
    requestAnimationFrame(() => {
        this.scheduledScrollUpdate = false
        this.handleScrollUpdate()
    })
}
  1. getScrollableAncestors() optimization: This walks the entire DOM tree on every drag start. For deeply nested elements, consider:

    • Caching results if the DOM structure is stable
    • Early exit if layoutScroll isn't being used
  2. isScrollable() calls: window.getComputedStyle() is expensive. The current implementation is fine for drag start, but if performance becomes an issue, consider caching computed styles.

Positive Performance Aspects

  • ✅ Scroll listeners use passive: true (line 602) - excellent for scroll performance
  • ✅ Early returns prevent unnecessary calculations (line 613, 617)
  • ✅ Only tracks scroll during active drag (not all the time)

Security Concerns 🔒

No security issues identified. The code:

  • Doesn't execute arbitrary code
  • Doesn't manipulate the DOM unsafely
  • Doesn't expose sensitive data
  • Uses standard browser APIs appropriately

Test Coverage 🧪

Strengths

  • Comprehensive E2E tests: The Cypress tests cover multiple scenarios:

    • ✅ Container scrolling during drag
    • ✅ Window scrolling during drag
    • ✅ Vertical and horizontal scroll independently
    • ✅ Multiple scrolls during a single drag
    • ✅ Normal drag behavior without scrolling (regression test)
  • Good test structure: Tests follow a clear arrange-act-assert pattern

  • Both test contexts: Tests cover both element scrolling and window scrolling

Suggestions for Additional Tests

  1. Unit tests: Consider adding Jest tests for the new helper functions:
describe("isScrollable", () => {
    it("returns true for overflow:scroll with scrollable content", () => { ... })
    it("returns false for overflow:hidden", () => { ... })
    it("returns false for overflow:auto without scrollable content", () => { ... })
})

describe("getScrollPosition", () => {
    it("returns window scroll position for window", () => { ... })
    it("returns element scroll position for elements", () => { ... })
})
  1. Edge cases to test:

    • Dragging with constraints + scrolling (do constraints still work?)
    • Nested scrollable containers (multiple ancestors scrolling simultaneously)
    • Scroll in opposite direction of drag
    • Programmatic scrolling (e.g., scrollTo()) during drag
    • Drag with dragSnapToOrigin enabled + scrolling
  2. Performance test: Verify no memory leaks after multiple drag sessions

Specific Code Review

packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts

Lines 100-110: New class properties

  • Well-documented and appropriately typed ✅

Lines 569-586: getScrollableAncestors()

  • Correctly walks the DOM tree
  • Always includes window for document-level scrolling ✅
  • Question: Should this handle document.documentElement or document.body separately? (May not be needed if window.scrollX/Y covers it)

Lines 612-642: onScroll() - Core logic

  • Line 619-626: The scroll delta accumulation looks correct
  • Line 631-632: Both originPoint and motion value are updated - this maintains consistency ✅
  • Line 637-639: Updating initial positions for next delta calculation is correct ✅
  • Consider: Should this respect dragDirectionLock? Currently it adjusts both axes regardless.

Lines 836-862: Helper functions

  • isScrollable(): Clean and correct ✅
  • getScrollPosition(): Type-safe handling of Window vs Element ✅
  • Could extract overflow check into a helper for readability

Test Files

dev/react/src/tests/drag-scroll-while-drag.tsx

  • Clean test component
  • Good use of query params to toggle behavior
  • Styles are appropriate for testing

packages/framer-motion/cypress/integration/drag-scroll-while-drag.ts

  • Tests are thorough and well-structured
  • Wait times (50ms, 200ms) might be flaky in CI - consider using Cypress retries or explicit assertions instead of fixed waits
  • Force clicks ({ force: true }) are necessary but consider if they mask any issues

Recommendations

Must Fix

None - the PR is in good shape!

Should Consider

  1. Add throttling to scroll event handler for better performance
  2. Add unit tests for helper functions
  3. Test edge case: drag with constraints + scrolling
  4. Consider respecting dragDirectionLock in scroll compensation

Nice to Have

  1. Performance test for memory leaks
  2. Test with deeply nested scrollable containers
  3. Cache getScrollableAncestors() result if DOM is stable

Conclusion

This is a well-implemented fix for a tricky interaction bug. The code is clean, well-documented, and properly handles cleanup. The test coverage is solid for E2E scenarios. The main areas for improvement are:

  1. Performance optimization (scroll event throttling)
  2. Additional unit test coverage
  3. Testing more edge cases

Recommendation: Approve with minor suggestions

The PR successfully fixes the reported issue while maintaining code quality standards. The suggested improvements are primarily optimizations and additional test coverage, not blockers.


Great work on tackling this complex interaction issue! 🎉

@claude
Copy link

claude bot commented Jan 6, 2026

PR Review: Fix drag element detaching from cursor during scroll

Overview

This PR addresses issue #1691 where draggable elements would detach from the cursor when a scrollable ancestor container scrolls during an active drag operation. The solution tracks scroll positions of ancestors and compensates by adjusting the origin point.

Code Quality & Best Practices ✅

Positive aspects:

  • Clean separation of concerns with dedicated methods (startScrollTracking, getScrollableAncestors, onScroll)
  • Good use of TypeScript with proper types (Map<Element | Window, Point>, Array<Element | Window>)
  • Consistent with existing code style (interface usage, arrow functions, proper cleanup patterns)
  • Excellent documentation with clear JSDoc comments
  • Follows the repository's pattern of cleanup in cancel() method
  • Uses passive: true for scroll listeners (performance best practice)

Minor style note:

  • Line 22: Good use of type import for LayoutUpdateData - consistent with TypeScript best practices

Logic & Implementation 🔍

The approach is sound:

  1. On drag start, walks DOM tree to find scrollable ancestors
  2. Captures initial scroll positions
  3. Attaches scroll listeners to all scrollable ancestors + window
  4. On scroll, calculates cumulative delta and adjusts both originPoint and motion value
  5. Updates stored positions for incremental tracking

Potential issue - Cumulative scroll delta calculation (lines 619-626):

let scrollDelta = 0
for (const target of this.scrollableAncestors) {
    const initial = this.initialAncestorScrolls.get(target)
    if (!initial) continue
    
    const current = getScrollPosition(target)
    scrollDelta += current[axis] - initial[axis]
}

This calculates the total scroll delta from all ancestors, but this could be incorrect if you have nested scrollable containers. When a child container scrolls, its scroll offset is already relative to its parent, so adding parent scroll deltas might double-count the movement.

Example scenario:

Window (scrolled 100px down)
  └─ Outer div (scrolled 50px down)
      └─ Inner div (scrolled 20px down)
          └─ Draggable element

The element's viewport position changes by the sum, but the compensation might be over-applied. However, given that scroll positions are viewport-relative for scrollable elements, this might actually be correct. Would benefit from testing with deeply nested scrollable containers.

Line 637-638: Position updates after adjustment:

for (const target of this.scrollableAncestors) {
    this.initialAncestorScrolls.set(target, getScrollPosition(target))
}

✅ Good - this enables incremental tracking, preventing accumulation errors on subsequent scrolls.

Performance Considerations ⚡

Concerns:

  1. isScrollable() calls during DOM traversal (line 576):

    • Calls getComputedStyle() for every ancestor
    • getComputedStyle() triggers style recalculation (expensive)
    • Called once per drag start, so acceptable for typical scenarios
    • Could be optimized with caching if performance issues arise
  2. Scroll event handler (lines 612-642):

    • Runs on every scroll event during drag
    • Iterates through all scrollable ancestors on each scroll
    • Calls getScrollPosition() and manipulates motion values
    • Triggers render() on every scroll event
    • Uses passive: true ✅ (good for scroll performance)
    • Recommendation: Consider throttling or using requestAnimationFrame if performance issues occur with many ancestors
  3. Memory:

    • Map and Array cleaned up properly in cancel()
    • Event listeners properly removed ✅

Security Concerns 🔒

No security issues identified:

  • No user input is processed unsafely
  • No dynamic code execution
  • No XSS vectors
  • DOM manipulation is safe and controlled

Test Coverage 📋

Excellent test coverage:

  • ✅ Element scroll during drag (basic case)
  • ✅ Window scroll during drag
  • ✅ Vertical scroll only
  • ✅ Horizontal scroll only
  • ✅ Multiple scrolls during single drag
  • ✅ Normal drag without scroll (regression check)

Missing test cases (nice-to-have):

  • Nested scrollable containers (multiple levels)
  • Scroll in opposite direction during drag
  • Very rapid scrolling during drag
  • Drag with dragConstraints + scroll
  • Drag with dragElastic + scroll

Potential Bugs 🐛

  1. Window is always added to scrollable ancestors (line 583):

    ancestors.push(window)

    This is fine, but if window isn't actually scrollable (no overflow content), scroll events still get tracked. This is likely acceptable as the scroll delta would be 0, but adds a tiny overhead.

  2. No check for whether drag is constrained to a specific axis:
    The scroll compensation applies to both axes, but if drag="x" is set, should we skip Y-axis scroll compensation? Current implementation seems fine as it only applies to axes being dragged via shouldDrag() check (line 342), but the scroll handler recalculates for both axes regardless.

  3. Race condition potential:
    If cancel() is called while onScroll is executing, scrollableAncestors gets cleared mid-iteration. However, this should be safe in JavaScript's single-threaded execution model.

Recommendations 📌

Critical:

  • ✅ None - the implementation is solid

High Priority:

  • Test nested scrollable containers to verify the cumulative delta calculation is correct
  • Consider adding a test with dragConstraints to ensure scroll compensation doesn't violate constraints

Nice to Have:

  • Add performance optimization if scroll events become a bottleneck (RAF throttling)
  • Consider adding a flag to disable scroll tracking if not needed
  • Could memoize getComputedStyle() results if the same ancestor is checked multiple times

Summary

This is a well-implemented fix that addresses a real usability issue. The code is clean, well-documented, and follows project conventions. Test coverage is comprehensive. The main consideration is verifying behavior with nested scrollable containers in real-world usage.

Recommendation: ✅ Approve with minor testing suggestion

Great work! The implementation is thoughtful and the test coverage is excellent.

🤖 Generated with Claude Code

@claude
Copy link

claude bot commented Jan 6, 2026

Code Review Summary

This PR implements a fix for drag elements detaching from the cursor when scrolling during drag operations. Overall, the implementation is solid and well-tested, but there are a few considerations worth addressing.


✅ Strengths

1. Clean Architecture

  • The scroll tracking is properly encapsulated within VisualElementDragControls
  • Uses a single event listener with capture phase for efficiency
  • Proper cleanup in the cancel() method

2. Excellent Test Coverage

  • Comprehensive Cypress tests covering multiple scenarios:
    • Container scroll during drag
    • Window scroll during drag
    • Vertical-only and horizontal-only scrolling
    • Multiple scrolls during a single drag
    • Normal drag without scrolling (regression test)
  • Good use of test app in dev/react for manual testing

3. Performance Considerations

  • Uses passive: true for scroll listener (prevents blocking)
  • Uses capture: true to catch all scroll events efficiently with a single listener
  • Early return in onScroll if not dragging

⚠️ Potential Issues & Suggestions

1. Memory Leak Risk (Medium Priority)

Location: VisualElementDragControls.ts:584-589

The onScroll method is defined as an arrow function, which means it creates a new bound function reference. However, the cleanup looks correct. The potential issue is if cancel() is never called due to an error.

Suggestion: Consider adding defensive cleanup in a destructor or ensuring cancel() is always called, even in error scenarios.

2. Edge Case: Nested Scrollable Containers (Low Priority)

Location: VisualElementDragControls.ts:563-575

The current implementation tracks all ancestors, which is good. However, if there are nested scrollable containers and both scroll simultaneously, the order of event processing might matter.

Question: Have you tested with nested scrollable containers where both parent and child scroll during drag? The current implementation should handle this, but it would be good to verify.

3. Type Safety (Minor)

Location: VisualElementDragControls.ts:600-604

The type check for event.target could be more type-safe:

const eventTarget = event.target
const target: Element | Window =
    eventTarget === document || eventTarget === document.documentElement
        ? window
        : (eventTarget as Element)

Suggestion: Consider adding a type guard or handling the case where event.target might be null or other EventTarget types.

4. Potential Race Condition (Low Priority)

Location: VisualElementDragControls.ts:596-628

If multiple scroll events fire rapidly, there could be a race between:

  1. Setting the motion value (line 621)
  2. Updating the stored position (line 625)
  3. Rendering (line 627)

This is probably fine due to the event loop, but worth considering if you see any jitter.

5. Missing Null Check (Minor)

Location: VisualElementDragControls.ts:616-617

While unlikely, getAxisMotionValue(axis) could theoretically return undefined. The code checks for this, but it might be worth considering what happens if this occurs mid-drag.


🧪 Test Considerations

Missing Test Scenarios (Optional Enhancements)

  1. Rapid scroll events - Test with programmatic rapid scrolling to ensure no race conditions
  2. Nested scrollable containers - Both scroll simultaneously
  3. Scroll during constraint application - Scroll while element is constrained by dragConstraints
  4. Cleanup verification - Ensure listeners are removed properly (could add a test that starts drag, cancels without ending, and verifies cleanup)

Test Code Quality

The test code looks good, but has some minor style issues:

In drag-scroll-while-drag.ts:26-31, 47-56, etc.

  • The assertions use expect(left).to.equal(150) which is checking exact pixel positions. This could be brittle if there are sub-pixel rendering differences across browsers.
  • Suggestion: Consider using a tolerance like expect(left).to.be.closeTo(150, 1)

🔒 Security

No security concerns identified. The implementation doesn't introduce any XSS, injection vulnerabilities, or unsafe DOM manipulation.


📋 Code Style & Best Practices

Follows Repository Conventions ✅

  • Uses interface for types (though not applicable here)
  • Named exports used
  • Arrow callbacks used appropriately
  • Strict equality used
  • No var declarations

Documentation

  • Good JSDoc comments for new methods
  • Clear inline comments explaining the approach

🎯 Recommendations

Priority 1 (Should Fix)

  • Add a tolerance to the Cypress assertions to prevent flakiness across browsers

Priority 2 (Nice to Have)

  • Add a test for nested scrollable containers
  • Consider adding a test that verifies listener cleanup
  • Add type guard for event.target in onScroll

Priority 3 (Future Consideration)

  • Monitor for any performance issues with many draggable elements (though unlikely given the passive listener)

Overall Assessment

Verdict:Approve with minor suggestions

This is a well-implemented fix for a real user issue (#1691). The approach is sound:

  • Tracking initial scroll positions on drag start
  • Adjusting origin point when scroll occurs
  • Proper cleanup on drag end

The test coverage is excellent and the code follows the repository's conventions. The suggestions above are mostly defensive improvements and edge case considerations rather than critical issues.

Great work! 🚀

@mattgperry mattgperry force-pushed the scroll-while-drag branch 2 times, most recently from fce5557 to 3b613ff Compare January 7, 2026 08:35
@claude
Copy link

claude bot commented Jan 7, 2026

Code Review for PR #3449: Fix drag element detaching from cursor during scroll

Thanks for working on this fix! Overall, this is a solid implementation that addresses the scroll-while-drag issue effectively. Here's my detailed review:

✅ Strengths

  1. Clean Architecture: The scroll compensation logic is well-encapsulated within the VisualElementDragControls class
  2. Efficient Event Handling: Using a single capture-phase listener with passive: true is a performance-conscious approach
  3. Good Documentation: Comments clearly explain the purpose of each method and property
  4. Proper Cleanup: Scroll listeners and data are correctly cleaned up in the cancel() method
  5. Comprehensive Testing: Both manual test page and Cypress E2E tests are included

🐛 Potential Issues & Suggestions

1. Memory Leak Risk with Arrow Function (Medium Priority)

Location: packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts:596

The onScroll method is defined as an arrow function property, which is good for maintaining this context. However, there's a subtle issue with the cleanup:

this.removeScrollListener = () =>
    window.removeEventListener("scroll", this.onScroll, { capture: true })

The removeEventListener call should include the same options object to properly remove the listener. While capture: true is specified, it should match the registration exactly:

// Current (line 588-589)
window.addEventListener("scroll", this.onScroll, {
    capture: true,
    passive: true,
})

// Should be:
this.removeScrollListener = () =>
    window.removeEventListener("scroll", this.onScroll, { 
        capture: true 
        // Note: passive doesn't need to be specified for removal, but capture must match
    })

Actually, looking at the code again, this is correct! The passive option doesn't need to be in the removal call. Good job here.

2. Type Safety Improvement (Low Priority)

Location: packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts:600-604

The type narrowing for event.target could be more explicit:

const eventTarget = event.target
const target: Element | Window =
    eventTarget === document || eventTarget === document.documentElement
        ? window
        : (eventTarget as Element)

This assumes event.target is always an Element, but scroll events can theoretically have null targets. Consider adding a guard:

const eventTarget = event.target
if (!eventTarget) return

const target: Element | Window =
    eventTarget === document || eventTarget === document.documentElement
        ? window
        : (eventTarget as Element)

3. Edge Case: Nested Scrollable Containers (Low Priority)

The current implementation tracks all ancestor scroll positions and handles multiple scroll containers correctly. However, when multiple ancestors scroll simultaneously (unlikely but possible), the compensation is applied incrementally. This is actually the correct behavior - nice work!

4. Performance: Unnecessary Render on Zero Delta (Low Priority)

Location: packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts:612-627

The code correctly returns early if delta === 0 for each axis, but still calls this.visualElement.render() at the end even if both deltas were zero:

eachAxis((axis) => {
    const delta = current[axis] - initial[axis]
    if (delta === 0) return  // ✅ Good early return
    // ... update logic
})

// Update stored position for incremental delta calculation
this.initialAncestorScrolls.set(target, current)

this.visualElement.render()  // ⚠️ Called even if no updates were made

Consider tracking whether any updates occurred:

let hasUpdates = false
eachAxis((axis) => {
    const delta = current[axis] - initial[axis]
    if (delta === 0) return
    hasUpdates = true
    // ... rest of logic
})

this.initialAncestorScrolls.set(target, current)

if (hasUpdates) {
    this.visualElement.render()
}

5. Test Coverage Gap (Medium Priority)

Location: packages/framer-motion/cypress/integration/drag-scroll-while-drag.ts

The Cypress test acknowledges that it cannot reliably test the actual scroll-during-drag behavior:

/**
 * Note: Testing actual scroll-during-drag is difficult in Cypress as
 * scrollTo doesn't reliably trigger scroll events during pointer capture.
 * The scroll compensation feature has been manually verified to work.
 */

While manual testing is mentioned, consider:

  • Adding a Playwright test instead (mentioned in CLAUDE.md: yarn test-playwright)
  • Or documenting the manual test procedure in the PR
  • Or adding a unit test that directly calls onScroll during a simulated drag

🔒 Security Considerations

No security concerns identified. The implementation:

  • Doesn't execute arbitrary code
  • Doesn't manipulate the DOM unsafely
  • Properly validates that motion values exist before using them
  • Uses passive event listeners which can't call preventDefault()

⚡ Performance Considerations

Positive:

  • ✅ Single capture-phase listener instead of multiple listeners
  • ✅ Passive event listener prevents blocking the scroll thread
  • ✅ Early returns for non-tracked elements and zero deltas
  • ✅ Incremental delta calculation (not absolute position checks every time)

Minor Optimization Opportunity:

📋 Code Style Compliance

The code follows the repository's conventions per CLAUDE.md:

  • ✅ Uses interface (though only types are needed here - appropriate)
  • ✅ Named exports only
  • ✅ Arrow callbacks
  • ✅ Strict equality (===)
  • ✅ No var declarations

🧪 Test Coverage Assessment

Manual Test Page (dev/react/src/tests/drag-scroll-while-drag.tsx):

  • ✅ Tests both element and window scrolling
  • ✅ Clear test setup with URL parameters
  • ✅ Good visual feedback with styling

E2E Tests:

  • ⚠️ Tests verify drag positioning but cannot reliably test scroll-during-drag
  • ✅ Tests cover both scenarios (element scroll, window scroll)
  • 💡 Consider adding Playwright tests or unit tests for better coverage

📝 Recommendations

  1. High Priority: Add the null check for event.target (Issue Fixing types and es references #2)
  2. Medium Priority: Consider adding more robust automated tests for the scroll compensation behavior (Issue Initial event setup #5)
  3. Low Priority: Add the conditional render optimization (Issue adding circleci config #4)
  4. Documentation: Consider adding a comment explaining why layoutScroll ancestors need compensation

✨ Summary

This is a well-thought-out solution to a tricky problem. The implementation is clean, efficient, and follows best practices. The main areas for improvement are:

  1. Minor type safety enhancement
  2. A small performance optimization
  3. Better automated test coverage

The fix should work correctly for the reported issue (#1691). Great job! 🎉

Overall Assessment: ✅ Approved with minor suggestions

@claude
Copy link

claude bot commented Jan 7, 2026

Code Review

I've reviewed PR #3449 which fixes drag elements detaching from the cursor during scroll. Overall, this is a well-implemented solution with good test coverage. Here's my detailed feedback:


✅ Strengths

1. Clean Architecture

  • The scroll tracking is properly encapsulated within VisualElementDragControls
  • Single responsibility: each method does one thing well
  • Good separation of concerns between tracking initialization and event handling

2. Efficient Event Handling

  • Uses a single capture-phase listener for all scroll events (VisualElementDragControls.ts:584)
  • passive: true flag optimizes scroll performance
  • Incremental delta calculation avoids cumulative drift

3. Proper Cleanup

  • Listener removal in stop() method (VisualElementDragControls.ts:321-324)
  • Map cleared to prevent memory leaks (VisualElementDragControls.ts:327)

4. Comprehensive Test Coverage

  • Tests both element and window scrolling scenarios
  • Includes regression tests for normal drag behavior
  • Tests verify the actual fix (element staying attached to cursor)

🔍 Potential Issues & Improvements

1. Performance: Unnecessary Ancestor Tracking (Medium Priority)

Location: VisualElementDragControls.ts:567-575

The current implementation stores scroll positions for all ancestors, even non-scrollable ones:

let current = element.parentElement
while (current) {
    this.initialAncestorScrolls.set(current, {
        x: current.scrollLeft,
        y: current.scrollTop,
    })
    current = current.parentElement
}

Issue: For deeply nested DOM trees, this creates unnecessary Map entries and checks during scroll events.

Suggestion: Only track scrollable ancestors:

let current = element.parentElement
while (current) {
    const isScrollable = current.scrollHeight > current.clientHeight || 
                        current.scrollWidth > current.clientWidth
    if (isScrollable) {
        this.initialAncestorScrolls.set(current, {
            x: current.scrollLeft,
            y: current.scrollTop,
        })
    }
    current = current.parentElement
}

2. Edge Case: Scroll During Constraints Calculation (Low Priority)

Location: VisualElementDragControls.ts:619-621

If an element has dragConstraints, scrolling could push the element beyond those constraints. The current code doesn't re-apply constraints after scroll compensation.

Consideration: Should constraints be re-evaluated after scroll compensation? Or is the current behavior (element stays with cursor even if outside constraints) the desired UX?

3. Type Safety: Event Target Casting (Low Priority)

Location: VisualElementDragControls.ts:600-604

const eventTarget = event.target
const target: Element | Window =
    eventTarget === document || eventTarget === document.documentElement
        ? window
        : (eventTarget as Element)

Issue: The cast (eventTarget as Element) assumes the target is always an Element if not document, but scroll events can have null targets in edge cases.

Suggestion: Add a null check:

if (\!eventTarget || \!(eventTarget instanceof Element || eventTarget === document || eventTarget === document.documentElement)) return

4. Missing Edge Case Test (Low Priority)

The test suite doesn't cover:

  • Rapid scrolling during drag (multiple scroll events in quick succession)
  • Bidirectional scrolling (both X and Y axes)
  • Nested scrollable containers

Suggestion: Add a test for nested scrollables:

it("Handles nested scrollable containers", () => {
    // Test with outer and inner scrollable divs
})

5. Code Style: Arrow Function Consistency (Very Low Priority)

Location: VisualElementDragControls.ts:596

onScroll is defined as a class property arrow function, which differs from other event handlers in the class (like onStart, onMove). While this ensures proper this binding for the event listener, consider documenting why this pattern is used here.


🔒 Security Considerations

✅ No security concerns identified:

  • No user input is directly processed
  • No XSS vectors (only reads scroll positions)
  • No DOM injection
  • Proper event listener cleanup prevents memory leaks

🎯 Test Coverage Assessment

Good coverage overall, but could be enhanced:

Covered:

  • Element scroll compensation
  • Window scroll compensation
  • Normal drag without scroll
  • Visual position verification

⚠️ Missing:

  • Nested scrollable containers
  • Rapid scrolling edge cases
  • Bidirectional scrolling (X and Y simultaneously)
  • Drag with constraints + scroll interaction
  • Cleanup verification (listeners removed after drag ends)

📝 Documentation & Code Quality

Excellent:

  • Clear JSDoc comments explain the purpose of each method
  • Test descriptions are self-documenting
  • Variable names are descriptive

Minor suggestion: Add a comment explaining why window is always tracked even if the document doesn't scroll.


✅ Recommendation

APPROVE with minor suggestions.

This PR successfully fixes issue #1691 with a clean, well-tested implementation. The suggestions above are optimizations and edge cases that don't block merging. The core functionality is solid and the fix works as intended.

Priority of follow-ups:

  1. Consider filtering non-scrollable ancestors (performance optimization)
  2. Add test for nested scrollables (robustness)
  3. Document constraint interaction behavior (clarity)

Great work on this fix! 🎉

@claude
Copy link

claude bot commented Jan 7, 2026

Pull Request Review

Overview

This PR addresses issue #1691 by fixing a bug where draggable elements would detach from the cursor when a scrollable ancestor container scrolls during an active drag operation. The solution tracks scroll positions of ancestor elements and compensates for scroll deltas by adjusting the origin point.


Strengths

  1. Well-designed solution: The approach is clean and efficient - using a single capture-phase scroll listener to handle all scrolling ancestors is optimal.

  2. Good documentation: Comments clearly explain the implementation, including the important distinction between element scroll (which needs compensation) and window scroll (already handled by PanSession's pageX/pageY).

  3. Proper cleanup: Memory management is solid with Map.clear() and event listener removal in the cancel() method.

  4. Comprehensive test coverage: The Cypress tests cover the main use case, edge cases, and regression testing for normal drag behavior.

  5. Follows codebase conventions: Uses interface (not type), proper naming, arrow functions, and follows the established patterns in the file.


🔍 Issues & Concerns

1. Critical: Memory Leak Risk

Severity: High

The onScroll method is defined as an arrow function (line 595):

private onScroll = (event: Event): void => {

This creates a new function instance for every instance of VisualElementDragControls. When the same function reference needs to be used for both addEventListener and removeEventListener, this is fine. However, if multiple drag controls exist simultaneously and scroll tracking is started/stopped multiple times, you could accumulate listeners.

Recommendation: The current implementation is actually correct because you're storing the removal function. However, consider adding a safety check in startScrollTracking():

private startScrollTracking(): void {
    const element = this.visualElement.current
    if (\!element) return
    
    // Prevent adding duplicate listeners
    if (this.removeScrollListener) {
        return
    }
    
    // ... rest of implementation
}

2. Potential Performance Issue: Traversing All Ancestors

Severity: Medium

In startScrollTracking() (lines 573-580), the code traverses all ancestor elements up to the root:

let current = element.parentElement
while (current) {
    this.initialAncestorScrolls.set(current, {
        x: current.scrollLeft,
        y: current.scrollTop,
    })
    current = current.parentElement
}

Issues:

  • For deeply nested DOM structures, this could create large Maps with many entries
  • Most ancestor elements won't actually be scrollable
  • Memory overhead for storing every ancestor's scroll position

Recommendation: Only track scrollable ancestors:

let current = element.parentElement
while (current) {
    const style = window.getComputedStyle(current)
    const overflowX = style.overflowX
    const overflowY = style.overflowY
    
    if (overflowX === 'auto' || overflowX === 'scroll' || 
        overflowY === 'auto' || overflowY === 'scroll') {
        this.initialAncestorScrolls.set(current, {
            x: current.scrollLeft,
            y: current.scrollTop,
        })
    }
    current = current.parentElement
}

3. Edge Case: Document/Window Scroll Handling

Severity: Low

In onScroll (line 601), you check if the element is being tracked:

const initial = this.initialAncestorScrolls.get(target)
if (\!initial) return

The comment mentions ignoring window/document scroll, but when event.target is document or window, casting to Element might cause issues. The Map.get() will return undefined, so it's safe, but it's worth being explicit.

Recommendation: Add explicit guard:

const target = event.target as Element
if (\!target || target === document as any || target === window as any) return

4. Test Coverage Gap: Horizontal Scroll

Severity: Low

The Cypress tests only verify vertical scrolling (scrollTop). The implementation handles both axes, but there's no test for horizontal scroll (scrollLeft).

Recommendation: Add a test case for horizontal scrolling to ensure both axes work correctly.

5. Test Coverage Gap: Multiple Scrollable Ancestors

Severity: Low

The test case only has one scrollable ancestor. Real-world scenarios might have nested scrollable containers.

Recommendation: Add a test with nested scrollable containers to verify cumulative scroll compensation works correctly.

6. Race Condition: Render Timing

Severity: Low

In onScroll (line 621), you call this.visualElement.render() after updating motion values. If multiple scroll events fire rapidly (e.g., smooth scrolling or programmatic scrolling), this could trigger excessive renders.

Recommendation: Consider debouncing or using requestAnimationFrame:

private scheduleRender() {
    if (this.renderScheduled) return
    this.renderScheduled = true
    requestAnimationFrame(() => {
        this.visualElement.render()
        this.renderScheduled = false
    })
}

🎯 Code Quality

  • Readability: ✅ Excellent - clear variable names, good comments
  • Maintainability: ✅ Good - well-structured, follows existing patterns
  • Type Safety: ✅ Excellent - proper TypeScript usage
  • Error Handling: ⚠️ Minimal - no explicit error handling for edge cases

🔒 Security

No security concerns identified. The code:

  • Doesn't expose sensitive data
  • Doesn't execute untrusted code
  • Properly cleans up event listeners
  • Doesn't introduce XSS or injection risks

🧪 Testing

Strengths:

  • Covers main use case (scroll during drag)
  • Tests position accuracy with scroll compensation
  • Includes regression test for normal drag
  • Uses appropriate test patterns from the codebase

Gaps:

  • Missing horizontal scroll test
  • Missing nested scrollable containers test
  • Missing rapid scroll event test
  • Missing test for edge cases (null elements, document scroll)

📋 Summary

This is a solid implementation that solves the reported issue effectively. The main concerns are:

  1. Must fix: Add safety check to prevent duplicate listeners
  2. Should fix: Optimize to only track scrollable ancestors
  3. Nice to have: Add missing test coverage for edge cases

The implementation demonstrates good understanding of the codebase and follows established patterns. With the suggested optimizations, this will be production-ready.

Recommendation: ✅ Approve with minor changes


📝 Suggested Changes

// In startScrollTracking()
private startScrollTracking(): void {
    const element = this.visualElement.current
    if (\!element || this.removeScrollListener) return
    
    // Only track scrollable ancestors
    let current = element.parentElement
    while (current) {
        const style = window.getComputedStyle(current)
        const overflowX = style.overflowX
        const overflowY = style.overflowY
        
        if (overflowX === 'auto' || overflowX === 'scroll' || 
            overflowY === 'auto' || overflowY === 'scroll') {
            this.initialAncestorScrolls.set(current, {
                x: current.scrollLeft,
                y: current.scrollTop,
            })
        }
        current = current.parentElement
    }
    
    window.addEventListener('scroll', this.onScroll, {
        capture: true,
        passive: true,
    })
    this.removeScrollListener = () =>
        window.removeEventListener('scroll', this.onScroll, { capture: true })
}

Great work on this fix! 🚀

@claude
Copy link

claude bot commented Jan 7, 2026

PR Review: Fix drag element detaching from cursor during scroll

Overall this is a well-implemented fix for issue #1691. The solution properly addresses the scroll compensation problem with a clean, efficient approach. Here's my detailed feedback:


✅ Strengths

  1. Clean Architecture: The scroll tracking is properly encapsulated within the VisualElementDragControls class with clear separation of concerns.

  2. Efficient Event Handling: Using a single capture-phase listener is much more efficient than adding individual listeners to each ancestor element.

  3. Proper Cleanup: Memory leaks are prevented with proper listener removal and Map cleanup in the cancel() method.

  4. Good Documentation: Comments clearly explain the implementation, especially the important note about window scroll being automatically handled by PanSession.

  5. Comprehensive Testing: Both E2E tests cover the critical scenarios and include helpful documentation about Cypress quirks.


🐛 Potential Issues & Suggestions

1. Memory Leak Risk with Arrow Function (Medium Priority)

Location: VisualElementDragControls.ts:595

The onScroll arrow function is a class property, which is good for binding context. However, there's a subtle issue:

window.addEventListener("scroll", this.onScroll, { capture: true, passive: true })
// ...
window.removeEventListener("scroll", this.onScroll, { capture: true })

The addEventListener uses { capture: true, passive: true } but removeEventListener only uses { capture: true }. In some browsers, the options object needs to match exactly for proper removal.

Recommendation: Make the removal consistent:

this.removeScrollListener = () =>
    window.removeEventListener("scroll", this.onScroll, { 
        capture: true,
        // passive doesn't need to be specified for removal, but capture must match
    })

Actually, looking at the spec, this should work fine as-is since passive only affects addEventListener. This is a minor point.


2. Potential Type Safety Issue (Low Priority)

Location: VisualElementDragControls.ts:598-604

const target = event.target as Element
// ...
const current = { x: target.scrollLeft, y: target.scrollTop }

The event.target could be Window or Document which don't have scrollLeft/scrollTop properties. While the check this.initialAncestorScrolls.get(target) will return undefined for these cases (preventing processing), it would be cleaner to add an explicit guard:

const target = event.target
if (!(target instanceof Element)) return

3. Edge Case: Nested Scrollable Elements (Medium Priority)

Location: VisualElementDragControls.ts:619

When multiple nested ancestors scroll simultaneously (or in rapid succession), the current implementation updates initialAncestorScrolls incrementally. This is correct for handling continuous scrolling, but consider this scenario:

  • Parent scrolls by 100px (delta calculated, stored position updated)
  • Child scrolls by 50px (delta calculated, stored position updated)
  • Parent scrolls back by -50px

The incremental updates should handle this correctly, but it might be worth adding a comment explaining this behavior or adding a test case for nested scrolling.

Recommendation: Add a test case or comment explaining nested scroll behavior.


4. Performance: Unnecessary Render Calls (Low Priority)

Location: VisualElementDragControls.ts:621

The scroll handler always calls this.visualElement.render() at the end, even if no axis was actually updated (all deltas were 0). Consider adding a flag:

private onScroll = (event: Event): void => {
    if (!this.isDragging) return

    const target = event.target as Element
    const initial = this.initialAncestorScrolls.get(target)
    if (!initial) return

    const current = { x: target.scrollLeft, y: target.scrollTop }
    let hasUpdated = false

    eachAxis((axis) => {
        const delta = current[axis] - initial[axis]
        if (delta === 0) return

        const motionValue = this.getAxisMotionValue(axis)
        if (!motionValue) return

        this.originPoint[axis] += delta
        motionValue.set(motionValue.get() + delta)
        hasUpdated = true
    })

    this.initialAncestorScrolls.set(target, current)
    
    if (hasUpdated) {
        this.visualElement.render()
    }
}

This is a micro-optimization and may not be necessary, but it prevents unnecessary render cycles.


5. Test Coverage Gaps (Medium Priority)

The current tests are good but could be expanded:

Missing test scenarios:

  • Nested scrollable containers (scroll both parent and child during drag)
  • Scrolling in only one axis while dragging in both axes
  • Very rapid scrolling (stress test)
  • Starting drag, scrolling, then canceling drag (cleanup verification)
  • Drag with dragDirectionLock enabled while scrolling

Recommendation: Add at least one test for nested scrollable containers since that's the most likely real-world edge case.


6. Race Condition Potential (Low Priority)

Location: VisualElementDragControls.ts:191-194

this.startScrollTracking()

const { animationState } = this.visualElement
animationState && animationState.setActive("whileDrag", true)

If a scroll event fires immediately after startScrollTracking() but before isDragging is set to true (line 148), it will be ignored due to the guard at line 596. However, isDragging is already set to true on line 148, so this should be fine. Just noting for completeness.


🎯 Style & Convention Notes

  1. Import Style: Changed from import { LayoutUpdateData } to import type { LayoutUpdateData } on line 22. This is good practice for type-only imports. ✅

  2. Interface over Type: Code consistently uses Map and type annotations, following the codebase style. ✅

  3. Comments: Excellent inline documentation explaining the "why" not just the "what". ✅


🧪 Test Quality

Strengths:

  • Tests properly wait between actions
  • Include helpful comments about Cypress quirks
  • Test both with and without scrolling
  • Assertions are reasonable given browser quirks

Suggestions:

  • Consider adding a test that verifies cleanup (scroll listener removed after drag ends)
  • Add a test for nested scrollable containers
  • Could add visual regression tests if the project supports them

🔒 Security Considerations

No security concerns identified. The implementation:

  • Doesn't execute user-provided code
  • Doesn't manipulate the DOM in unsafe ways
  • Uses passive event listeners (good for performance and security)
  • Properly cleans up resources

⚡ Performance Considerations

Good:

  • Single capture listener instead of multiple listeners ✅
  • Passive event listener for scroll ✅
  • Early returns in hot paths ✅
  • Efficient Map for O(1) lookups ✅

Could be optimized (minor):

  • Avoid render call when no updates occurred (see issue adding circleci config #4 above)
  • Consider throttling/debouncing if scroll events fire too rapidly (though browser typically handles this)

📝 Summary

This is a solid implementation that solves the reported issue. The code is clean, well-documented, and properly handles cleanup. The main suggestions are:

  1. Add test coverage for nested scrollable containers
  2. Consider the micro-optimization for unnecessary render calls
  3. Add explicit type guard for event.target

Recommendation: ✅ Approve with minor suggestions

The core logic is sound and the fix works as intended. The suggestions above are mostly minor improvements that could be addressed in follow-up PRs if needed.

Great work on this fix! 🎉

@claude
Copy link

claude bot commented Jan 7, 2026

Code Review - PR #3449: Fix drag element detaching from cursor during scroll

Summary

This PR fixes issue #1691 where draggable elements detach from the cursor when scrolling a container with layoutScroll during an active drag. The implementation tracks scroll positions of ancestor elements and compensates for scroll movement by adjusting the origin point and motion values.


✅ Strengths

1. Well-structured solution

  • Clean separation of concerns with dedicated scroll tracking methods
  • Efficient use of a single capture event listener for all scroll events
  • Proper cleanup in the cancel() method

2. Excellent test coverage

  • Comprehensive Cypress tests covering both element scroll and window scroll scenarios
  • Test app with URL parameter support for different test modes
  • Clear test expectations with good documentation of edge cases

3. Good documentation

  • Clear inline comments explaining the approach
  • Distinction between element scroll (handled by this PR) and window scroll (handled by PanSession)

🔍 Code Quality Issues

1. Memory leak potential (lines 567-589)

The startScrollTracking() method walks up the entire ancestor tree and stores references to all parent elements in a Map:

let current = element.parentElement
while (current) {
    this.initialAncestorScrolls.set(current, {
        x: current.scrollLeft,
        y: current.scrollTop,
    })
    current = current.parentElement
}

Issues:

  • This creates strong references to all ancestor DOM elements, which could prevent garbage collection if the drag session isn't properly cleaned up
  • For deeply nested DOM structures, this could store many unnecessary entries
  • No limit on how many ancestors are tracked

Recommendation:
Consider limiting the tree walk or adding a safety check:

let current = element.parentElement
let depth = 0
const MAX_DEPTH = 50 // Prevent runaway loops
while (current && depth < MAX_DEPTH) {
    this.initialAncestorScrolls.set(current, {
        x: current.scrollLeft,
        y: current.scrollTop,
    })
    current = current.parentElement
    depth++
}

2. Arrow function binding issue (line 595)

The onScroll handler is defined as an arrow function:

private onScroll = (event: Event): void => {

Issues:

  • Arrow functions as class properties are created per-instance rather than on the prototype
  • This increases memory overhead when multiple drag controls exist
  • The arrow function is necessary here for proper this binding in event listeners

Status: This is acceptable given the need for proper this binding, but worth noting for awareness.

3. Race condition potential (lines 606-616)

The scroll handler modifies originPoint and motion values synchronously:

eachAxis((axis) => {
    const delta = current[axis] - initial[axis]
    if (delta === 0) return

    const motionValue = this.getAxisMotionValue(axis)
    if (!motionValue) return

    this.originPoint[axis] += delta
    motionValue.set(motionValue.get() + delta)
})

Issues:

  • If multiple scroll events fire rapidly (e.g., smooth scrolling), the compensation might accumulate incorrectly
  • The initialAncestorScrolls.set() at line 619 updates the stored position, which should prevent double-counting, but there's a window between reading and writing where another event could fire

Recommendation:
Consider debouncing or using requestAnimationFrame to batch scroll compensations, or add guards against re-entrancy.

4. Potential null reference (line 610)

const motionValue = this.getAxisMotionValue(axis)
if (!motionValue) return

While there's a null check, this silently fails if a motion value is missing. This might hide bugs where the drag system is in an inconsistent state.

Recommendation:
Consider logging a warning in development mode when this condition occurs.


⚠️ Potential Bugs

1. Document and window scroll handling

The code intentionally ignores window/document scroll (line 600-602):

const initial = this.initialAncestorScrolls.get(target)
if (!initial) return

Issue:
The comment states window scroll is handled by PanSession via pageX/pageY, but what happens if:

  • The document.documentElement or document.body scrolls?
  • There's a combination of element and window scroll?

Recommendation:
Add explicit checks to ignore document.documentElement and document.body, with comments explaining why:

// Ignore document/window scroll (handled by PanSession via pageX/pageY)
if (target === document || target === document.documentElement || target === document.body) {
    return
}

2. Missing scroll listener cleanup check

In cancel() (lines 321-324), the scroll listener is removed, but there's no check in startScrollTracking() to prevent adding multiple listeners if called multiple times.

Recommendation:
Add a guard:

private startScrollTracking(): void {
    if (this.removeScrollListener) return // Already tracking
    // ... rest of the code
}

3. Type safety issue in onScroll (line 598)

const target = event.target as Element

Issue:
event.target could be null or not an Element (e.g., could be a text node in some edge cases).

Recommendation:
Add runtime validation:

const target = event.target
if (!target || !(target instanceof Element)) return

🚀 Performance Considerations

1. Scroll event frequency

The code uses { passive: true } which is good, but scroll events can fire very frequently (60+ times per second during smooth scrolling).

Current approach:

  • Every scroll event triggers Map lookups, delta calculations, and motion value updates
  • this.visualElement.render() is called on every scroll event

Impact:

  • For complex UIs or many draggable elements, this could cause performance issues
  • The render call at line 621 might be expensive

Recommendation:
Consider throttling or using requestAnimationFrame:

private scrollFramePending = false

private onScroll = (event: Event): void => {
    if (!this.isDragging || this.scrollFramePending) return
    
    this.scrollFramePending = true
    frame.postRender(() => {
        this.scrollFramePending = false
        this.handleScrollEvent(event)
    })
}

2. Map operations

The initialAncestorScrolls Map could grow large for deeply nested DOMs.

Recommendation:
Consider using a WeakMap instead of Map to allow garbage collection of removed elements:

private initialAncestorScrolls: WeakMap<Element, Point> = new WeakMap()

🔒 Security Concerns

No security issues identified. The code:

  • Doesn't execute user-provided code
  • Doesn't manipulate dangerous DOM properties
  • Uses standard event handling APIs safely

✅ Test Coverage Assessment

Excellent test coverage with:

  • Element scroll compensation test
  • Window scroll test
  • Baseline tests without scroll
  • Both test scenarios in Cypress E2E tests

Suggestions:

  1. Add a test for rapid scroll events (stress test)
  2. Test deeply nested scrollable containers (multiple scrollable ancestors)
  3. Test cleanup - verify listeners are removed after drag ends
  4. Test edge case: scroll event fires after drag ends but before cleanup

📋 Checklist Items

The PR description includes a test plan, but the checkboxes are unchecked:

  • Verify drag element stays attached to cursor when scrolling during drag
  • Verify normal drag behavior is unaffected
  • Verify cleanup occurs properly when drag ends

Recommendation: Run through this checklist and check off items, or remove it if already verified.


🎯 Overall Assessment

Quality: Good ⭐⭐⭐⭐

This is a well-implemented fix for a real usability issue. The approach is sound, tests are comprehensive, and the code is generally clean. The main concerns are:

  1. Potential memory leaks from ancestor references
  2. Performance impact from frequent scroll events
  3. Missing guards against edge cases (multiple listener registration, null checks)

Recommendation: Approve with minor changes

The issues identified are not blockers but should be addressed to ensure robustness in production. Consider the memory leak and performance optimizations as they could affect apps with complex DOM structures or many draggable elements.


💡 Suggested Improvements (Optional)

  1. Add development-mode warnings when unexpected conditions occur
  2. Consider making scroll compensation opt-in via a prop for performance-sensitive apps
  3. Add a comment in CHANGELOG.md about the performance characteristics of this feature
  4. Document the behavior with layoutScroll prop in the test app comments

Great work on fixing this long-standing issue! 🎉

When dragging an element inside a scrollable container (or the window),
the element would detach from the cursor if the container scrolled during
the drag operation.

This fix:
- Detects scrollable ancestors by walking the DOM tree (checking overflow
  and scroll dimensions)
- Always includes window for document-level scrolling
- Attaches scroll listeners when drag starts
- Adjusts originPoint and motionValue when scroll events fire to keep
  the element under the cursor
- Cleans up listeners when drag ends

Includes e2e tests for both element scroll and window scroll scenarios.

Fixes #1691

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
mattgperry and others added 4 commits January 8, 2026 10:47
Move scroll compensation logic from VisualElementDragControls into
PanSession where it belongs. This ensures scroll events during drag
flow through the normal onMove pipeline, so constraints, elastic
boundaries, and callbacks all work correctly.

- Add scrollableElement option to PanSession
- Element scroll: adjust history[0] to simulate pointer movement
- Window scroll: adjust lastMoveEventInfo.point for pageX/pageY change
- Remove 100+ lines of scroll handling from VisualElementDragControls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Only track ancestors with scrollable overflow styles
- DRY up scroll handlers with unified handleScroll method
- Use single scrollPositions map for elements and window
- Rename scrollableElement option to element

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@mattgperry mattgperry merged commit f764c18 into main Jan 8, 2026
1 of 4 checks passed
@mattgperry mattgperry deleted the scroll-while-drag branch January 8, 2026 10:35
@claude
Copy link

claude bot commented Jan 8, 2026

Pull Request Review

Summary

This PR fixes issue #1691 where draggable elements detach from the cursor when scrolling occurs during an active drag. The solution tracks scroll positions of layoutScroll ancestors and compensates for scroll movements by adjusting the drag origin point.


Code Quality and Implementation

Strengths

  1. Well-structured solution: The implementation cleanly separates concerns by handling element scroll and window scroll differently, which is appropriate given their different coordinate systems.

  2. Good documentation: Comments clearly explain the different behaviors for element vs window scroll compensation (lines 224-226, 242-254 in PanSession.ts).

  3. Proper cleanup: Memory leaks are avoided with proper listener removal and map clearing in the end() method (lines 327-330).

  4. Comprehensive test coverage: Both Cypress E2E tests and a dev test app cover element scroll, window scroll, and baseline scenarios.

  5. Pure marker usage: The overflowStyles Set uses /#PURE/ for tree-shaking optimization (line 40).


Potential Issues and Concerns

CRITICAL: Scroll Event Listener Scope

Location: PanSession.ts:195-203

The scroll listeners are attached to window globally, which means they will fire for ALL scroll events in the document, not just the tracked ancestors. The onElementScroll handler will be called for every scroll event, even for unrelated elements. While handleScroll returns early if the element is not in scrollPositions, this is inefficient and could cause performance issues with many scroll events.

Recommendation: Consider attaching listeners directly to each tracked scrollable element instead of using a global capture listener.


MEDIUM: Potential Race Condition with frame.update

Location: PanSession.ts:257

The scroll handler schedules updatePoint to run on the next frame, but there is no guarantee about the timing relative to the next pointermove event. Consider debouncing scroll compensation or ensuring idempotency.


MEDIUM: Missing Null Check

Location: PanSession.ts:250-253

Direct mutation of history array without checking if it is still valid. If the drag session ends between scroll compensation calls, this could mutate stale data. Recommend adding a safety check for this.startEvent !== null.


MEDIUM: getComputedStyle Performance

Location: PanSession.ts:175

getComputedStyle forces a style recalculation and can be expensive. Called for every ancestor during drag start. Worth profiling for deeply nested DOM structures.


MINOR: Type Safety

Location: PanSession.ts:214, 234-237

The cast as Element assumes the scroll target is an Element, but it could be a Document or other EventTarget. Recommend adding a type guard.


Testing Concerns

The Cypress tests acknowledge coordinate quirks and use lenient tolerances. While pragmatic, this could mask real issues. Consider adding tests for:

  1. Rapid scrolling with multiple rapid scroll events
  2. Diagonal scrolling with both X and Y scroll simultaneously
  3. Nested scrollable containers with multiple scrollable ancestors
  4. Scroll during constraints when dragConstraints are active

Security and Performance

Security: No concerns - passive event listeners used appropriately, no XSS vectors, proper cleanup prevents memory leaks.

Performance: Main concern is the global scroll listener. Also consider profiling for drag with continuous scrolling, deeply nested DOM structures, and multiple simultaneous drag sessions.


Final Verdict

Recommendation: Approve with minor changes

This is a well-implemented solution to a long-standing issue. The core algorithm is sound, tests are comprehensive, and the code is clean. The main concern is the global scroll listener pattern which could be optimized.

Before Merging:

  1. Must fix: Optimize scroll listener attachment (use per-element listeners)
  2. Should fix: Add type guard for scroll event target
  3. Nice to have: Add tests for edge cases (nested scrollables, rapid scrolling)

Great work on tackling this complex interaction issue!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Scrolling while dragging elements causes the element to detach from the cursor.

2 participants