Skip to content

Touch target resolution is shadow-DOM-blind: scrollers inside shadow roots are invisible to the lock (frozen entirely on iOS WKWebView) #166

Description

@pranshugupta54

Summary

react-remove-scroll resolves the touch target through event.target, which for a touch inside an open shadow root is retargeted to the shadow host. Every decision downstream of that — shouldCancelEvent → locationCouldBeScrolled, handleScroll's ancestor walk, shard membership via node.contains(event.target) — therefore walks the light DOM only and can never see a scroller that lives inside a shadow root.

Consequence: for touches over a shadow-internal scroller, the lock's verdicts are computed against the wrong element chain. In a WKWebView (iOS hybrid app) we observed the practical worst case: with the lock mounted (vaul drawer → Radix Dialog → RemoveScroll), a scroller inside a shadow root would not touch-scroll at all — every touchmove reached the page cancelable and unprevented, zero scroll events fired — while an identical light-DOM scroller in the same sheet scrolled normally. Keeping the same DOM but preventing the touchmoves from reaching RemoveScroll's document-level listener (a stopPropagation shield below document) restored native scrolling immediately, and that shield is the workaround we shipped.

Repro

Two strips inside any RemoveScroll-locked surface, identical geometry/content:

// strip A (scrolls): plain light-DOM div, overflow-y: auto, tall content
// strip B (frozen on iOS WKWebView): same div, but created inside
//   host.attachShadow({mode:'open'}) — content populated before insertion

Flick both on an iOS device/simulator. A scrolls, B does not. Desktop wheel is unaffected (different code path).

Proposed fix

Resolve the target via the composed path instead of the retargeted target, consistently across the pipeline:

  • React capture handlers (scrollTouchMove, scrollWheel): event.nativeEvent?.composedPath?.()[0] ?? event.target
  • Document consumers (shouldPrevent): event.composedPath?.()[0] ?? event.target
  • Use the composed target in shouldCancelEvent / locationCouldBeScrolled / handleScroll, and make lock/shard membership composed-path-aware (node.contains does not express shadow-internal membership; the queue's existing getOutermostShadowParent correlation shows shadow awareness is already precedented in this codebase).

The walk itself also needs to cross shadow boundaries upward (parentElement ?? getRootNode().host) so a scroller inside a shadow root is visible from a row inside it.

Happy to open a PR along these lines if the approach sounds right.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions