Skip to content

fix: TabController - clear stuck press feedback when a tap is cancelled on iOS - #4042

Merged
mika-bejerano merged 1 commit into
masterfrom
fix/tabcontroller-stuck-press-feedback-ios
Sep 6, 2026
Merged

fix: TabController - clear stuck press feedback when a tap is cancelled on iOS#4042
mika-bejerano merged 1 commit into
masterfrom
fix/tabcontroller-stuck-press-feedback-ios

Conversation

@mika-bejerano

@mika-bejerano mika-bejerano commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Description

On iOS a tab keeps its activeBackgroundColor painted on it indefinitely after a
cancelled tap — including after another tab is selected. It is intermittent, and it
accumulates: every cancelled tap leaves another tab tinted, because each item owns its
own isPressed.

Root cause. TabBarItem latched its press feedback in isPressed from
onTouchesDown and cleared it only in onFinalize — a single point of failure.

On iOS a cancelled tap (e.g. when the enclosing horizontal FadedScrollView 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 for it manually on FAILED (twice —
in interactionsMoved and again in reset), never on CANCELLED. So no state change
event reaches JS and onFinalize never runs.

This gesture also sets no maxDistance, so finger drift cannot produce FAILED (which
would emit an event) — the silent cancel is the only terminal path.

Android is unaffected: GestureHandler.cancel() goes through moveToState, which
dispatches the state change, so onFinalize runs.

The fix. The cancel path does still reach JS — as a touch event; only the state
change is lost. So the clear is hooked onto the channel that works:

.onTouchesCancelled(() => {
  isPressed.value = false;
});

Every terminal path is now covered: iOS ENDED/FAILED via onFinalize, iOS
CANCELLED via onTouchesCancelled, Android via both. The extra call on Android is
idempotent (both write false), and cancelPointers() has exactly one call site,
guarded to terminal transitions — so the feedback can never be released mid-press.

Verification. Reproduced in the TabController demo screen on an iOS 26.4 simulator
with the pre-fix code (three tabs left tinted at once), then confirmed the tint no
longer sticks with the fix, after a full app restart — not Fast Refresh, which remounts
and would clear isPressed regardless. tsc clean; ESLint output byte-identical to the
pre-fix file (3 pre-existing react-hooks/exhaustive-deps warnings, 0 errors); 951
tests / 60 suites / 65 snapshots passing.

No automated test guards this path: reproducing it requires simulating a missing
state-change event from iOS native code, which a jest mock of
react-native-gesture-handler cannot do — such a test would assert the mock, not the
bug.

Changelog

TabController.TabBar — fixed an iOS-only bug where a tab stayed highlighted with
activeBackgroundColor after a tap was cancelled (for example by the tab bar scrolling
under your finger). The highlight is press feedback and is meant to be momentary, but it
stayed painted on the item indefinitely, including after a different tab was selected,
and accumulated across taps. Affects any TabController.TabBar that sets
activeBackgroundColor (or activeOpacity) and is horizontally scrollable.

Additional info

The underlying gap is upstream in react-native-gesture-handler (2.24.0):
apple/Handlers/RNTapHandler.m sets self.state = UIGestureRecognizerStateCancelled
from POSSIBLE without a manual [self triggerAction], and its reset only re-triggers
if (self.state == UIGestureRecognizerStateFailed) — so the cancelled transition emits
nothing. This affects anyone using Gesture.Tap() with onTouchesDown inside a
ScrollView, not just this component. Worth filing against
software-mansion/react-native-gesture-handler; the fix here is correct independently.

No Jira ticket — reported from apps using the shared top navigation bar.

🤖 Generated with Claude Code

…ed 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) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

✅ PR Description Validation Passed

All required sections are properly filled out:

  • Description
  • Changelog
  • Additional info

Your PR is good for review! 🚀


This validation ensures all sections from the PR template are properly filled.

@adids1221 adids1221 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀

@mika-bejerano
mika-bejerano enabled auto-merge (squash) September 3, 2026 14:57
@mika-bejerano
mika-bejerano merged commit 0cde300 into master Sep 6, 2026
3 of 4 checks passed
@mika-bejerano
mika-bejerano deleted the fix/tabcontroller-stuck-press-feedback-ios branch September 6, 2026 07:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants