fix: TabController - clear stuck press feedback when a tap is cancelled on iOS - #4042
Merged
Merged
Conversation
…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>
Contributor
✅ PR Description Validation PassedAll required sections are properly filled out:
Your PR is good for review! 🚀 This validation ensures all sections from the PR template are properly filled. |
mika-bejerano
enabled auto-merge (squash)
September 3, 2026 14:57
mika-bejerano
disabled auto-merge
September 3, 2026 14:58
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
On iOS a tab keeps its
activeBackgroundColorpainted on it indefinitely after acancelled 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.
TabBarItemlatched its press feedback inisPressedfromonTouchesDownand cleared it only inonFinalize— a single point of failure.On iOS a cancelled tap (e.g. when the enclosing horizontal
FadedScrollViewclaims thetouch after a few pixels of finger drift) transitions the recognizer straight from
POSSIBLEtoCANCELLED. UIKit emits no action message for that transition, andRNGestureHandler's
RNTapHandleronly compensates for it manually onFAILED(twice —in
interactionsMovedand again inreset), never onCANCELLED. So no state changeevent reaches JS and
onFinalizenever runs.This gesture also sets no
maxDistance, so finger drift cannot produceFAILED(whichwould emit an event) — the silent cancel is the only terminal path.
Android is unaffected:
GestureHandler.cancel()goes throughmoveToState, whichdispatches the state change, so
onFinalizeruns.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:
Every terminal path is now covered: iOS
ENDED/FAILEDviaonFinalize, iOSCANCELLEDviaonTouchesCancelled, Android via both. The extra call on Android isidempotent (both write
false), andcancelPointers()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
isPressedregardless.tscclean; ESLint output byte-identical to thepre-fix file (3 pre-existing
react-hooks/exhaustive-depswarnings, 0 errors); 951tests / 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-handlercannot do — such a test would assert the mock, not thebug.
Changelog
TabController.TabBar— fixed an iOS-only bug where a tab stayed highlighted withactiveBackgroundColorafter a tap was cancelled (for example by the tab bar scrollingunder 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.TabBarthat setsactiveBackgroundColor(oractiveOpacity) and is horizontally scrollable.Additional info
The underlying gap is upstream in
react-native-gesture-handler(2.24.0):apple/Handlers/RNTapHandler.msetsself.state = UIGestureRecognizerStateCancelledfrom
POSSIBLEwithout a manual[self triggerAction], and itsresetonly re-triggersif (self.state == UIGestureRecognizerStateFailed)— so the cancelled transition emitsnothing. This affects anyone using
Gesture.Tap()withonTouchesDowninside aScrollView, 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