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.
Summary
react-remove-scrollresolves the touch target throughevent.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 vianode.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
touchmovereached the page cancelable and unprevented, zeroscrollevents 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 (astopPropagationshield belowdocument) restored native scrolling immediately, and that shield is the workaround we shipped.Repro
Two strips inside any RemoveScroll-locked surface, identical geometry/content:
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:
scrollTouchMove,scrollWheel):event.nativeEvent?.composedPath?.()[0] ?? event.targetshouldPrevent):event.composedPath?.()[0] ?? event.targetshouldCancelEvent/locationCouldBeScrolled/handleScroll, and make lock/shard membership composed-path-aware (node.containsdoes not express shadow-internal membership; the queue's existinggetOutermostShadowParentcorrelation 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.