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.
packages/app/ui/components/ManageChannels/EditChannelScreenView.tsx— the affected screen, all experiments modify this filepackages/ui/src/components/Pressable.tsx— TamaguiPressablewraps TamaguiStack(NOT RN Pressable). HasisWebbranching.packages/ui/src/components/ButtonV2.tsx— the exportedButtonin@tloncorp/ui; composes sharedPressablepackages/ui/src/components/Button.tsx— legacyButtonV1; extends a styledStackdirectly and does NOT use sharedPressablepackages/app/ui/components/Form/inputs.tsx—RadioControlusescheckedprop (notselected).ControlFramehas explicitheight: '$3xl'.
Research found a near-exact match in react-native-reanimated #6935:
- JS-based touchables inside a scrolled Android
ScrollView/FlatListcan stop receiving presses on physical devices - The failure is strongly associated upstream with Reanimated + Android + Fabric/new architecture in multiple issue threads
- 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
- 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.
- This app runs with
newArchEnabled=trueon 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
- 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)
- react-native-reanimated #6935 — Primary match. Pressable/Button/TouchableOpacity inside ScrollView stop responding after scrolling on physical Android
- react-native-reanimated #6070 — Touchables in Animated.ScrollView not responsive on Android with Fabric
- facebook/react-native #47740 — Touch events unresponsive in ScrollView under new architecture (Fabric)
- react-native-gesture-handler #2585 — Nested touchables don't work with ScrollView on Android
- react-native-gesture-handler #3227 — Touch events unresponsive under new architecture
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
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.
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>EditChannelScreenView is the main screen where this surfaced clearly with this specific combination:
- A full-screen form with enough content to require scrolling (the permission table)
- Multiple action buttons AND toggles inside the scrollable area
- 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
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-scrollPressableplusSave/Delete rolebuttonspackages/app/features/settings/ThemeScreen.tsx— theme option rows arePressables inside aScrollViewpackages/app/ui/components/SettingsScreenView.tsx— settings rows arePressables inside aScrollViewpackages/app/features/groups/GroupRolesScreen.tsx— role rows arePressables inside aScrollViewpackages/app/features/settings/UserBugReportScreen.tsx—Send Reportbutton is inside aScrollViewpackages/app/features/settings/AppInfoScreen.tsx— conditionalUpload logsbutton is inside aScrollViewpackages/app/features/settings/PushNotificationSettingsScreen.tsx— interactive override rows / remove buttons appear inside aScrollViewpackages/app/ui/components/Form/inputs.tsx— horizontalScrollViews containButton.Frametabs 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
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.
// 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.
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>
);
}An agent code review of the codebase identified the following risks and considerations:
-
pressStylewill likely break — Tamagui'spressStyle(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 affectsButtonV2.tsxpressed visuals. -
Event contract mismatch (biggest risk) — The shared Pressable currently exposes
GestureResponderEvent(fromreact-native), but RNGH's Pressable uses a different event type ({ nativeEvent }). Existing call sites usee.stopPropagation(), which exists onGestureResponderEventbut NOT on RNGH's event object. Known call sites that would break:packages/app/features/groups/GroupTypeSelectionSheet.tsx— usesstopPropagationpackages/app/ui/components/ListItem/ChannelListItem.tsx— usesstopPropagationpackages/app/ui/components/LongPressDisclosure.tsx— usesstopPropagation
-
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). -
Nested Pressables — Existing code has nested Pressable patterns. RNGH gesture arbitration is not identical to JS responder bubbling, so behavior could change.
-
Navigation path — the
to/actionvariant of Pressable (foruseLinkProps) needs link/accessibility props to land on the actual interactive RNGH element on Android, not just the inner Stack. -
Button coverage — The exported
Buttonis actuallyButtonV2, andButtonV2composes the sharedPressable(viaButton.Frame). So patchingPressable.tsxwould automatically cover most buttons. No need to patchButton.tsxseparately. However, some components bypass shared Pressable and would remain vulnerable:packages/ui/src/components/FloatingActionButton.tsxpackages/ui/src/components/IconButton.tsx
-
RNGH dependency —
packages/uialready has RNGH as a peer dependency inpackage.json, so this is NOT a package-boundary blocker.
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
stopPropagationusage, nested pressables,onPressIn/out, long press, and link behavior on physical Android devices - Testing on physical Android, iOS, and web
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.
Add two feature flags to android/gradle.properties:
updateRuntimeShadowNodeReferencesOnCommit=true
useShadowNodeStateOnClone=true- facebook/react-native#49694 — the RN issue describing the stale shadow node clone problem
- facebook/react-native#50773 — the PR introducing the feature flags
- facebook/react-native#50753 — related PR: "Move shadow node reference updates to tree commit"
- 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
This reframes the problem:
- The root cause is not Reanimated's
onInterceptTouchEvent— it's React Native's shadow tree cloning stale state during Fabric commits - Reanimated (and animated views generally) likely amplifies or triggers the stale state more frequently, which is why the bug correlates with Reanimated usage
- The gradle.properties flags are the proper framework-level fix — no component changes, no event contract issues, no pressStyle concerns
- The RNGH Pressable workaround works because RNGH bypasses the JS Pressable hitbox calculation entirely, sidestepping the stale shadow node state
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.
EXP Z7 proved that the earlier "height breaks touch dispatch" finding was a red herring:
- Full original Tamagui table restored (YStack/XStack, explicit
height: 68on 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.
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 |
| 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 |
| 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 |
| 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 |
- The root cause is a single issue: react-native-reanimated's
onInterceptTouchEventhandler steals touches from JS-based components inside a scrolled ScrollView on physical Android devices. - Not caused by specific components -- affects Tamagui Button, RN TouchableOpacity, Tamagui Pressable equally (all JS-based).
- Not caused by ScrollView variant -- affects Tamagui ScrollView, RN ScrollView, AND RNGH ScrollView.
- Native controls are immune -- RN
Switchworks regardless of scroll position (uses native Android touch handling). - Components outside the ScrollView always work -- fixed footer buttons work perfectly.
- Physical device only -- the slight finger movement during a physical tap (2-8px) is interpreted as a scroll gesture; emulator clicks have zero movement.
- RNGH Pressable fixes it -- handles touches through the native gesture system, bypassing the JS touch pipeline entirely.
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.
android:releasescript inapps/tlon-mobile/package.jsonupdated torm -rf android/app/build/generated/assets/createBundleProductionReleaseJsAndAssets && pnpm exec expo run:android --variant=productionReleaseto prevent stale JS bundle caching.
- Was passing
selected={...}(renders blank circles). Fixed tochecked={...}.
- 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:
GHPressablewrappingRadioControl - RoleChip X buttons:
GHPressablewrappingIcon
- "Add roles" button:
- Full Tamagui PermissionTable with YStack/XStack, height:68 rows
- Toggle logic fixes (handleToggleWriter, handleToggleReader)
checkedprop fix on RadioControl (was incorrectlyselected)- Dead experiment code removed (PermissionTableContainer, PermissionTableHeaderCell, PermissionTableRow, tableStyles, checkboxColumnWidth)
- Unused imports removed (StyleSheet, useTheme)
- Debug label removed