Skip to content

Instantly share code, notes, and snippets.

@patosullivan
Created March 12, 2026 23:17
Show Gist options
  • Select an option

  • Save patosullivan/29b59d2eb93930449ac99b15ef116bd8 to your computer and use it in GitHub Desktop.

Select an option

Save patosullivan/29b59d2eb93930449ac99b15ef116bd8 to your computer and use it in GitHub Desktop.
Android physical device touch unresponsiveness investigation (TLON-5436/TLON-5365)

Android Physical Device Touch Unresponsiveness Investigation

Bug Summary

On physical Android devices only (not emulator), buttons inside a ScrollView stop responding to taps when the view is scrolled. This is the root cause behind TLON-5436/TLON-5365.

Key constraint: Physical taps involve slight finger movement (2-8px). Emulator clicks have zero movement. The slight movement triggers Android's native touch interception, which steals the touch from JS-based Pressable components before onPress can fire.

Files Involved

  • packages/app/ui/components/ManageChannels/EditChannelScreenView.tsx — the affected screen, all experiments modify this file
  • packages/ui/src/components/Pressable.tsx — Tamagui Pressable wraps Tamagui Stack (NOT RN Pressable). Has isWeb branching.
  • packages/ui/src/components/ButtonV2.tsx — the exported Button in @tloncorp/ui; composes shared Pressable
  • packages/ui/src/components/Button.tsx — legacy ButtonV1; extends a styled Stack directly and does NOT use shared Pressable
  • packages/app/ui/components/Form/inputs.tsxRadioControl uses checked prop (not selected). ControlFrame has explicit height: '$3xl'.

Probable Root Cause: Android ScrollView touch regression, likely amplified by Reanimated + Fabric

Likely Mechanism

Research found a near-exact match in react-native-reanimated #6935:

  1. JS-based touchables inside a scrolled Android ScrollView/FlatList can stop receiving presses on physical devices
  2. The failure is strongly associated upstream with Reanimated + Android + Fabric/new architecture in multiple issue threads
  3. Physical taps involve slight finger movement (natural on device, absent in emulator clicks), which appears to push Android into native gesture interception before JS press handlers complete
  4. Native controls (Switch, TextInput) are largely immune because they use Android's native touch/focus system rather than JS press responders

Important nuance: the exact "react-native-reanimated installs a custom onInterceptTouchEvent and that is the sole cause" explanation is a plausible interpretation from upstream issue reports, but it is not proven from this codebase alone. Local source inspection did not establish a single definitive native hook inside our installed Reanimated version. Treat the symptom pattern and workaround as confirmed; treat the exact native mechanism as likely but not fully proven.

New Architecture / Fabric Relevance

  • This app runs with newArchEnabled=true on Android
  • Related upstream reports explicitly mention Fabric / new architecture:
  • Best current interpretation: new architecture is likely a contributing factor or amplifier, not yet a proven sole root cause

Symptoms

  • Buttons inside a ScrollView work when at scroll offset 0 (top of content)
  • The same buttons stop responding to taps when the user scrolls down
  • Only on physical Android devices (not emulator)
  • Text inputs still work (native Android focus system)
  • Native controls (Switch) work regardless of scroll position
  • Components outside the ScrollView always work (e.g., fixed footer buttons)

Related Issues


The Fix: RNGH Pressable

What works

Use Pressable from react-native-gesture-handler (RNGH) instead of from react-native or Tamagui. RNGH's Pressable handles touches through the native gesture system, bypassing the onInterceptTouchEvent issue entirely.

Confirmed in EXP Z9 on physical Android:

  • All buttons work at all scroll positions
  • All RadioControl toggles work at all scroll positions
  • No regressions on iOS or web

Web compatibility

RNGH's Pressable falls back to react-native-web's Pressable on web — no issues. The project already uses RNGH (GestureDetector, GestureHandlerRootView) in other components.

Button wrapping pattern

For Tamagui Buttons inside a ScrollView on Android:

import { Pressable as GHPressable } from 'react-native-gesture-handler';

<GHPressable onPress={handler}>
    <Button preset="primary" label="Save" centered pointerEvents="none" />
</GHPressable>;

pointerEvents="none" on the Button prevents it from intercepting the touch, letting GHPressable handle it entirely. This preserves Button's visual styling while RNGH handles the interaction.

For non-Button interactive elements (icons, radio controls, etc.):

<GHPressable onPress={handler}>
    <RadioControl checked={value} />
</GHPressable>

Why This Bug Wasn't Encountered Before

EditChannelScreenView is the main screen where this surfaced clearly with this specific combination:

  1. A full-screen form with enough content to require scrolling (the permission table)
  2. Multiple action buttons AND toggles inside the scrollable area
  3. Users needing to interact with these elements after scrolling down

Other screens often avoid the failure in practice through:

  • FlatList/FlashList — most scrollable lists use these. Note: FlatList is built on ScrollView internally and IS equally vulnerable, but list items typically navigate away rather than requiring repeated taps at various scroll positions
  • Buttons in fixed headers/footers — screens like EditProfileScreenView put action buttons outside the scroll area
  • Content fits without scrolling — screens like SettingsScreenView have compact enough content that users rarely scroll past interactive elements
  • ActionSheet uses BottomSheetScrollView — from @gorhom/bottom-sheet, which integrates with RNGH natively
  • Navigation links, not action buttons — most interactive items in scrollable areas navigate to other screens (quick tap-and-leave) rather than requiring repeated reliable taps

Other screens where this could potentially occur

Code audit found several other screens/components that render JS-handled press targets inside a ScrollView (or a scrollable list built on similar primitives). These have not all been reproduced as broken, but they should be treated as potentially vulnerable on physical Android, especially after the user has scrolled:

  • packages/app/features/groups/RoleFormScreen.tsx — in-scroll Pressable plus Save / Delete role buttons
  • packages/app/features/settings/ThemeScreen.tsx — theme option rows are Pressables inside a ScrollView
  • packages/app/ui/components/SettingsScreenView.tsx — settings rows are Pressables inside a ScrollView
  • packages/app/features/groups/GroupRolesScreen.tsx — role rows are Pressables inside a ScrollView
  • packages/app/features/settings/UserBugReportScreen.tsxSend Report button is inside a ScrollView
  • packages/app/features/settings/AppInfoScreen.tsx — conditional Upload logs button is inside a ScrollView
  • packages/app/features/settings/PushNotificationSettingsScreen.tsx — interactive override rows / remove buttons appear inside a ScrollView
  • packages/app/ui/components/Form/inputs.tsx — horizontal ScrollViews contain Button.Frame tabs and color-picker actions

Why these may not have surfaced yet:

  • they often require little or no scrolling on most devices
  • many interactions are large row taps followed by immediate navigation away
  • some controls are conditional or low-traffic
  • they have not been stress-tested on physical Android with repeated taps after scrolling

Potential Systematic Fix: Patching Tamagui Pressable

Rather than wrapping individual components with GHPressable per-screen, the fix could be applied at the component level in packages/ui/src/components/Pressable.tsx.

Current Pressable Architecture

// packages/ui/src/components/Pressable.tsx
// Web path: <Stack onPress={...} cursor="pointer">
// Mobile path: <Stack onPress={...} pressStyle={{ opacity: 0.5 }}>

The Tamagui Pressable wraps Stack (a Tamagui component) — it does NOT use RN's Pressable. On mobile, it passes onPress/onLongPress/onPressIn/onPressOut directly to Stack, which uses RN's JS touch system under the hood. This is exactly what breaks on Android.

Proposed Change

On Android, wrap the Stack in RNGH's Pressable to handle touches natively:

import { Pressable as RNGHPressable } from 'react-native-gesture-handler';
import { Platform } from 'react-native';

// Android mobile path:
if (Platform.OS === 'android') {
  return (
    <RNGHPressable
      onPress={onPress}
      onLongPress={longPressHandler}
      onPressIn={onPressIn}
      onPressOut={onPressOut}
    >
      <Stack ref={ref} pressStyle={{ opacity: 0.5 }} {...stackProps}>
        {children}
      </Stack>
    </RNGHPressable>
  );
}

Considerations and Risks (from agent review)

An agent code review of the codebase identified the following risks and considerations:

  1. pressStyle will likely break — Tamagui's pressStyle (e.g. opacity: 0.5) relies on RN's JS touch system to detect "pressed" state. With RNGH handling the touch, the Stack will probably never enter pressed state, losing visual feedback. Would need explicit pressed-state bridging from RNGH back into Tamagui. Same issue affects ButtonV2.tsx pressed visuals.

  2. Event contract mismatch (biggest risk) — The shared Pressable currently exposes GestureResponderEvent (from react-native), but RNGH's Pressable uses a different event type ({ nativeEvent }). Existing call sites use e.stopPropagation(), which exists on GestureResponderEvent but NOT on RNGH's event object. Known call sites that would break:

    • packages/app/features/groups/GroupTypeSelectionSheet.tsx — uses stopPropagation
    • packages/app/ui/components/ListItem/ChannelListItem.tsx — uses stopPropagation
    • packages/app/ui/components/LongPressDisclosure.tsx — uses stopPropagation
  3. Scope to Android only — iOS doesn't have this bug. Broadening to iOS increases blast radius with no benefit. Web should remain untouched (already has its own path via isWeb).

  4. Nested Pressables — Existing code has nested Pressable patterns. RNGH gesture arbitration is not identical to JS responder bubbling, so behavior could change.

  5. Navigation path — the to/action variant of Pressable (for useLinkProps) needs link/accessibility props to land on the actual interactive RNGH element on Android, not just the inner Stack.

  6. Button coverage — The exported Button is actually ButtonV2, and ButtonV2 composes the shared Pressable (via Button.Frame). So patching Pressable.tsx would automatically cover most buttons. No need to patch Button.tsx separately. However, some components bypass shared Pressable and would remain vulnerable:

    • packages/ui/src/components/FloatingActionButton.tsx
    • packages/ui/src/components/IconButton.tsx
  7. RNGH dependencypackages/ui already has RNGH as a peer dependency in package.json, so this is NOT a package-boundary blocker.

Recommendation

Keep the current per-screen RNGH wrappers as the safe hotfix for this PR. The systematic Pressable.tsx patch should be done separately with:

  • Android-only scope (Platform.OS === 'android')
  • Explicit pressed-state bridging (RNGH pressed state → Tamagui visual feedback)
  • A targeted audit of stopPropagation usage, nested pressables, onPressIn/out, long press, and link behavior on physical Android devices
  • Testing on physical Android, iOS, and web

Potential Upstream Fix: React Native Feature Flags

A Software Mansion engineer (patrycjakalinska's comment on reanimated #6070) identified the root cause as a React Native bug, not a Reanimated bug: outdated shadow node state is cloned during commits, resulting in incorrect hitboxes on Pressable components after scrolling.

The Fix

Add two feature flags to android/gradle.properties:

updateRuntimeShadowNodeReferencesOnCommit=true
useShadowNodeStateOnClone=true

Upstream References

Availability

  • These flags were introduced in React Native 0.79.2 (released May 1, 2025)
  • This project uses React Native 0.76.9 — 3 minor versions behind
  • The flags are not available without upgrading RN
  • The project already has newArchEnabled=true (Fabric), which is consistent with the bug manifesting — the stale shadow node issue is specific to the new architecture

Why This Matters

This reframes the problem:

  1. The root cause is not Reanimated's onInterceptTouchEvent — it's React Native's shadow tree cloning stale state during Fabric commits
  2. Reanimated (and animated views generally) likely amplifies or triggers the stale state more frequently, which is why the bug correlates with Reanimated usage
  3. The gradle.properties flags are the proper framework-level fix — no component changes, no event contract issues, no pressStyle concerns
  4. The RNGH Pressable workaround works because RNGH bypasses the JS Pressable hitbox calculation entirely, sidestepping the stale shadow node state

Recommendation

When the project upgrades to RN 0.79+, add the two feature flags to apps/tlon-mobile/android/gradle.properties and test on physical Android. If the flags fix the issue, the per-screen RNGH wrappers in EditChannelScreenView.tsx can be reverted back to standard Tamagui Pressable/Button components.

Until then, the per-screen RNGH wrappers remain the correct fix.


Experiment Log

Critical Experiment: Z7 (Red Herring Discovery)

EXP Z7 proved that the earlier "height breaks touch dispatch" finding was a red herring:

  • Full original Tamagui table restored (YStack/XStack, explicit height: 68 on rows, Pressable, RadioControl)
  • Save/Delete buttons moved to a fixed footer outside the ScrollView
  • Result: Save/Delete work perfectly. "Add roles" works at scroll top, fails when scrolled down.

This means the PermissionTable content (heights, Tamagui components, Pressable, etc.) was never the direct cause. The real issue: when the PermissionTable made content taller, it forced the user to scroll down to reach the Save/Delete buttons, and buttons don't work inside a scrolled ScrollView on physical Android.

How the Red Herring Developed

All the "height on mapped Views" experiments can be reinterpreted through the scroll lens:

Experiment Why it appeared to confirm "height is the cause" Actual explanation
EXP T (height: 48, broken) Adding height made rows taller Taller rows -> Save/Delete pushed below fold -> user scrolls -> buttons break
EXP Q (no height, worked) Removing height made rows compact Compact rows -> Save/Delete visible without scrolling -> buttons work
EXP N (buttons above table, worked) Buttons above table always worked Buttons near top of view -> no scrolling needed -> buttons work
EXP V/W (Pressable+RadioControl, partial) "Add roles" worked, Save/Delete didn't "Add roles" visible without scroll, Save/Delete require scroll
EXP G (500px dummy View, worked) A tall view didn't break things 500px view may not have pushed buttons off-screen, OR user tested without scrolling

Phase 1: PermissionTable content experiments (red herrings)

Exp What was tested Result Reinterpretation
A3 No table content Pass No extra content -> no scrolling needed
D PermissionTable disabled Pass Less content -> no scrolling needed
G 500px dummy View Pass May not have required scrolling to reach buttons
I Flat Text only Pass Compact -> no scrolling needed
H Minimal YStack/XStack table Fail Tamagui rows have implicit height -> content taller -> scrolling
K Nested Views with borders Partial "Add roles" above fold works, Save/Delete below fold don't
M K + collapsable={false} Partial Same -- collapsable irrelevant
N Buttons moved above table Pass Buttons visible without scrolling
O pointerEvents wrapper Fail Wrapper added height/complexity, didn't help scroll issue
P Rows with height:48, alignItems, borders Fail Taller rows -> scrolling needed
Q Rows with flexDirection only Pass Compact rows -> no scrolling
R Q + borderBottom Pass Borders don't add significant height
S Q + height:48 + alignItems Fail Height pushes content down
T Q + height:48 only Fail Height pushes content down
U Q + alignItems only Pass No extra height
V U + Tamagui Pressable + RadioControl Partial RadioControl has height -> taller rows -> scrolling
W U + RN Pressable + RadioControl Partial Same -- RadioControl height
X U + RN Pressable + 24x24 View Fail Added height to rows
Y TouchableOpacity + emoji + fixed footer Footer Pass, Toggles Fail Toggles in scrollable area don't fire
Z Native Switch + fixed footer Footer Pass, Toggles Pass Native controls bypass the issue

Phase 2: ScrollView experiments (identifying the real cause)

Exp ScrollView Button Component Table Content "Add roles" when scrolled
Z4 Tamagui ScrollView Tamagui Button Text only (passive) Fail
Z5 RN ScrollView Tamagui Button Text only (passive) Fail
Z6 RN ScrollView RN TouchableOpacity Switch toggles Fail
Z7 Tamagui ScrollView Tamagui Button Full Tamagui table Fail (but footer buttons Pass)
Z8 RNGH ScrollView Tamagui Button Full Tamagui table Fail -- "Add roles" AND RadioControls fail when scrolled
Z9 RNGH ScrollView RNGH Pressable Full Tamagui table w/ RNGH Pressable PASS -- all interactions work at all scroll positions

Other experiments

Exp What Result Notes
FIX TEST Conditional RoleSelectionSheet Fail Sheet was not the cause
C Remove overflow:hidden Fail Overflow not the cause
L keyboardShouldPersistTaps="always" Worse Made ALL buttons stop working

Key Findings

  1. The root cause is a single issue: react-native-reanimated's onInterceptTouchEvent handler steals touches from JS-based components inside a scrolled ScrollView on physical Android devices.
  2. Not caused by specific components -- affects Tamagui Button, RN TouchableOpacity, Tamagui Pressable equally (all JS-based).
  3. Not caused by ScrollView variant -- affects Tamagui ScrollView, RN ScrollView, AND RNGH ScrollView.
  4. Native controls are immune -- RN Switch works regardless of scroll position (uses native Android touch handling).
  5. Components outside the ScrollView always work -- fixed footer buttons work perfectly.
  6. Physical device only -- the slight finger movement during a physical tap (2-8px) is interpreted as a scroll gesture; emulator clicks have zero movement.
  7. RNGH Pressable fixes it -- handles touches through the native gesture system, bypassing the JS touch pipeline entirely.

Other Fixes Made During Investigation

Toggle Logic Fixes

  • handleToggleWriter: Now adds to readers for ALL roles when enabling write (write implies read), not just MEMBERS_MARKER.
  • handleToggleReader: Now works for ALL roles (not just MEMBERS_MARKER). Removing read also removes write.

Build Caching Fix

  • android:release script in apps/tlon-mobile/package.json updated to rm -rf android/app/build/generated/assets/createBundleProductionReleaseJsAndAssets && pnpm exec expo run:android --variant=productionRelease to prevent stale JS bundle caching.

RadioControl prop fix

  • Was passing selected={...} (renders blank circles). Fixed to checked={...}.

Current State of the Code (cleaned up)

  • RNGH ScrollView (GHScrollView) for the main scrollable area
  • All interactive components inside the ScrollView wrapped in GHPressable:
    • "Add roles" button: <GHPressable><Button preset="positive" label="Add roles" pointerEvents="none" /></GHPressable>
    • Save/Delete buttons: Inside ScrollView, each wrapped in <GHPressable><Button ... pointerEvents="none" /></GHPressable>
    • PermissionTable toggles: GHPressable wrapping RadioControl
    • RoleChip X buttons: GHPressable wrapping Icon
  • Full Tamagui PermissionTable with YStack/XStack, height:68 rows
  • Toggle logic fixes (handleToggleWriter, handleToggleReader)
  • checked prop fix on RadioControl (was incorrectly selected)
  • Dead experiment code removed (PermissionTableContainer, PermissionTableHeaderCell, PermissionTableRow, tableStyles, checkboxColumnWidth)
  • Unused imports removed (StyleSheet, useTheme)
  • Debug label removed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment