From 3ca3739b2fa37bb49c31cae264e87a6eb7d4251c Mon Sep 17 00:00:00 2001 From: Mika Bejerano Date: Thu, 3 Sep 2026 13:06:35 +0300 Subject: [PATCH] fix: TabController - clear stuck press feedback when a tap is cancelled on iOS TabBarItem latches its press feedback in the `isPressed` shared value from `onTouchesDown` and cleared it only in `onFinalize`. On iOS a cancelled tap (e.g. when the enclosing horizontal ScrollView claims the touch after a few pixels of finger drift) transitions the recognizer straight from POSSIBLE to CANCELLED. UIKit emits no action message for that transition, and RNGestureHandler's RNTapHandler only compensates manually for FAILED - so no state change event reaches JS and `onFinalize` never runs. `isPressed` stayed true, leaving `activeBackgroundColor` painted on the item indefinitely, also after another tab was selected (every item owns its own `isPressed`). Android is unaffected: `GestureHandler.cancel()` goes through `moveToState`, which dispatches the state change, so `onFinalize` runs. Clearing `isPressed` from `onTouchesCancelled` as well covers the cancel path, which does reach JS as a touch event. The extra call on Android is idempotent (both handlers write `false`) and can only fire on a terminal transition, so the feedback is never released mid-press. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/tabController/TabBarItem.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/react-native-ui-lib/src/components/tabController/TabBarItem.tsx b/packages/react-native-ui-lib/src/components/tabController/TabBarItem.tsx index ac6d7780c6..331c995238 100644 --- a/packages/react-native-ui-lib/src/components/tabController/TabBarItem.tsx +++ b/packages/react-native-ui-lib/src/components/tabController/TabBarItem.tsx @@ -224,6 +224,13 @@ export default function TabBarItem({ }) .onTouchesDown(() => { isPressed.value = true; + }) + // NOTE: On iOS a cancelled tap (i.e. when the enclosing ScrollView claims the touch) transitions the + // recognizer straight from POSSIBLE to CANCELLED, which emits no state change event, so onFinalize + // is never called and the press feedback stays on the item. + // Releasing it from the touch stream as well makes sure it is always cleared. + .onTouchesCancelled(() => { + isPressed.value = false; }); return (