hotwired / hotwired/hotwire-native-android

Pull-to-refresh re-arms mid-gesture during multi-touch on elements with data-native-prevent-pull-to-refresh

Open
#206 0 comments 0 reactions 0 assignees View on GitHub

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

  1. 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.
  2. Put two fingers on the map and pinch/rotate.
  3. Lift one finger and keep dragging downward with the other.
  4. 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.