hotwired / hotwired/hotwire-native-android
Pull-to-refresh re-arms mid-gesture during multi-touch on elements with data-native-prevent-pull-to-refresh
Nobody has claimed this yet.
- Dominant language
- Kotlin
- Stars
- 163
- Forks
- 37
- PR merge metrics
- No merged PRs in 30d
Description
Description
The touch tracking that backs data-native-prevent-pull-to-refresh (added in #98) is single-touch. During a multi-finger gesture — e.g. a two-thumb pinch/rotate on a map — pull-to-refresh can re-arm while a finger is still down on the protected element, and a refresh fires from inside it.
Steps to reproduce
- Render a full-screen pannable element (a MapLibre/Leaflet/Google map is the natural case) wrapped in data-native-prevent-pull-to-refresh, on a screen with pull_to_refresh_enabled: true.
- Put two fingers on the map and pinch/rotate.
- Lift one finger and keep dragging downward with the other.
- Intermittently, the SwipeRefreshLayout spinner appears and a refresh triggers, despite the remaining finger never having left the protected element. A second finger landing outside the protected element (e.g. on a floating button) while the first is on the map reproduces it too.
Cause
In core/src/main/assets/js/turbo.js, the adapter listens for touchstart/touchend and reports a single boolean:
- touchstart fires once per new finger, and elementTouchStart evaluates only that finger's target — so a second finger on an unprotected element overwrites true with false.
- touchend fires when any finger lifts, and elementTouchEnd unconditionally clears the flag — even if another finger is still on the protected element.
On the Kotlin side (Session.elementTouchStarted / elementTouchEnded), webView.elementTouchPreventsPullsToRefresh is a last-event-wins boolean, so once one finger of a two-finger gesture lifts, the SwipeRefreshLayout is free to intercept the remaining finger's drag.
(touchcancel is also unhandled, but that failure mode is benign — the flag stays stuck true until the next touch.)
Suggested fix
Derive the flag from all active touches instead of the last event. On each touchstart/touchend/touchcancel, recompute over event.touches (each Touch.target is stable for the life of the touch, so the existing per-element walk can be reused unchanged):
const updateTouchState = (event) => {
const prevents = Array.from(event.touches).some((touch) => touchPreventsPullToRefresh(touch.target))
TurboSession.elementTouchStarted(prevents)
}
document.addEventListener("touchstart", updateTouchState)
document.addEventListener("touchend", updateTouchState)
document.addEventListener("touchcancel", updateTouchState)
Environment
- dev.hotwire:core 1.2.8 (bug present on current main, 27374c6)
- Reproduced on a Pixel 9a, Android 16
- iOS is unaffected (doesn't use this JS touch tracking)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in core/src/main/assets/js/turbo.js, then trace Session.elementTouchStarted and Session.elementTouchEnded on the Kotlin side. Reproduce the two-finger map gesture and inspect existing touch-related tests or test entry points. Done means protected pull-to-refresh remains disabled while any active touch is on the protected element, including after touchend and touchcancel.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, kotlin
- Domain
- mobile-dev
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100