Skip to content

Commit 8748b03

Browse files
authored
fix(Tabs, iOS): reconcile navigation state on implicit UIKit selection changes (#3877)
## Description On iPad, when the app transitions from compact to regular horizontal size class (e.g. user resizes the window), the More navigation controller disappears. UIKit restores the previously selected tab by calling `setSelectedIndex:` internally, but no `UITabBarControllerDelegate` methods fire — leaving `_navigationState` out of sync with UIKit's actual selection. This PR ensures `_navigationState` stays consistent with UIKit regardless of how the selection changes. Closes software-mansion/react-native-screens-labs#1107 ## Changes - Override `setSelectedIndex:` and `setSelectedViewController:` on `RNSTabBarController` to detect untracked selection changes - Add a guard flag (`_isHandlingExplicitSelectionUpdate`) set during all known selection flows (container updates, delegate handling) to prevent double state progression - Add `reconcileNavigationStateWithUIKitState` — when the overrides fire without the flag, this method detects the mismatch and updates `_navigationState` + notifies the delegate - Add `RNSTabsNavigationStateUpdateSourceImplicit` enum value to distinguish UIKit-initiated side-effect changes from explicit user taps and JS prop updates - Update `progressNavigationState:withSource:` condition from `== User` to `!= External` so that both `User` and `Implicit` sources update `_lastUINavigationState` (needed for correct stale update rejection) ## Test plan Using `test-tabs-more-navigation-controller.tsx` on iPad simulator: 1. Navigate to "Second" tab 2. Tap "More" tab bar item → select "Sixth" from the More list 3. Tap "More" tab bar item again to see the More list 4. Resize the app window to regular width (More controller disappears) 5. Verify `onTabSelected` event fires with `selectedScreenKey: "Second"` 6. Verify normal tab switching (tap, JS-driven) still works — no double events, no provenance skips 7. Verify repeated selection still triggers special effects Also another scenario: if we skip steps 2 & 3, no state update should be sent! ## Checklist - [x] Included code example that can be used to test this change. - [ ] For visual changes, included screenshots / GIFs / recordings documenting the change. - [x] For API changes, updated relevant public types. - [ ] Ensured that CI passes
1 parent 01375a5 commit 8748b03

3 files changed

Lines changed: 88 additions & 3 deletions

File tree

ios/tabs/host/RNSTabBarController.mm

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,18 @@ @implementation RNSTabBarController {
6363
RNSTabsNavigationState *_Nullable _navigationState;
6464

6565
/// Holds last state that has been a result of UI-side navigation (user request).
66+
/// This one is also updated in cases where UIKit modifies the selected tab implicitly,
67+
/// e.g. when user resizes the app and more tab disappears.
6668
///
6769
/// This property is nullable until first container update. Later it MUST NOT be nil.
6870
RNSTabsNavigationState *_Nullable _lastUINavigationState;
6971

7072
RNSTabsNavigationState *_Nullable _pendingOperation;
7173

74+
/// When YES, the controller is inside an explicit selection-changing code path (container update,
75+
/// delegate handling). Setter overrides skip reconciliation while this flag is set.
76+
BOOL _isHandlingExplicitSelectionUpdate;
77+
7278
#if !RCT_NEW_ARCH_ENABLED
7379
BOOL _isControllerFlushBlockScheduled;
7480
#endif // !RCT_NEW_ARCH_ENABLED
@@ -117,6 +123,22 @@ - (void)tabBar:(UITabBar *)tabBar didSelectItem:(UITabBarItem *)item
117123
RNSLog(@"TabBar: %@ didSelectItem: %@", tabBar, item);
118124
}
119125

126+
- (void)setSelectedIndex:(NSUInteger)selectedIndex
127+
{
128+
[super setSelectedIndex:selectedIndex];
129+
if (!_isHandlingExplicitSelectionUpdate) {
130+
[self reconcileNavigationStateWithUIKitState];
131+
}
132+
}
133+
134+
- (void)setSelectedViewController:(__kindof UIViewController *)selectedViewController
135+
{
136+
[super setSelectedViewController:selectedViewController];
137+
if (!_isHandlingExplicitSelectionUpdate) {
138+
[self reconcileNavigationStateWithUIKitState];
139+
}
140+
}
141+
120142
#pragma mark - Signals
121143

122144
- (void)setPendingNavigationStateUpdate:(nullable RNSTabsNavigationState *)navState
@@ -176,8 +198,11 @@ - (void)reactMountingTransactionDidMount
176198

177199
- (void)performContainerUpdate
178200
{
201+
_isHandlingExplicitSelectionUpdate = YES;
179202
[self updateChildViewControllersIfNeeded];
180203
[self updateSelectedViewControllerIfNeeded];
204+
_isHandlingExplicitSelectionUpdate = NO;
205+
181206
[self updateTabBarAppearanceIfNeeded];
182207
[self updateTabBarA11yIfNeeded];
183208
[self updateOrientationIfNeeded];
@@ -351,6 +376,7 @@ - (BOOL)tabBarController:(UITabBarController *)tabBarController
351376
}
352377
}
353378

379+
_isHandlingExplicitSelectionUpdate = YES;
354380
return YES;
355381
}
356382

@@ -367,6 +393,7 @@ - (void)tabBarController:(UITabBarController *)tabBarController
367393
viewController.class);
368394

369395
[self userDidSelectViewController:viewController];
396+
_isHandlingExplicitSelectionUpdate = NO;
370397
}
371398

372399
#pragma mark - UINavigationControllerDelegate
@@ -560,7 +587,7 @@ - (void)progressNavigationState:(nonnull NSString *)newSelectedScreenKey
560587
_navigationState = [RNSTabsNavigationState stateWithSelectedScreenKey:newSelectedScreenKey
561588
provenance:_navigationState.provenance + 1];
562589

563-
if (updateSource == RNSTabsNavigationStateUpdateSourceUser) {
590+
if (updateSource != RNSTabsNavigationStateUpdateSourceExternal) {
564591
_lastUINavigationState = [_navigationState cloneState];
565592
}
566593
}
@@ -596,6 +623,58 @@ - (nonnull NSString *)screenKeyForSelectedViewController
596623
return [self screenKeyForViewController:self.selectedViewController];
597624
}
598625

626+
/**
627+
* Detect and fix any mismatch between `_navigationState` and UIKit's actual selected view controller.
628+
*
629+
* This is called from the `setSelectedIndex:` / `setSelectedViewController:` overrides when
630+
* the change was NOT initiated by a known code path (container update, delegate handling).
631+
* The primary case is UIKit restoring a tab when the More navigation controller disappears
632+
* during a horizontal size class transition on iPad.
633+
*/
634+
- (void)reconcileNavigationStateWithUIKitState
635+
{
636+
if (_navigationState == nil) {
637+
// Before the first container update, _navigationState is nil — there is no established baseline
638+
// to drift from. The normal initialization path (performContainerUpdate → progressNavigationState:)
639+
// handles the nil → first state transition. Reconciling here would prematurely initialize state
640+
// and emit a delegate notification before the controller is fully set up.
641+
return;
642+
}
643+
644+
if ([self isSelectedViewControllerTheMoreNavigationController]) {
645+
// We don't want to progress the state in case of more navigation controller.
646+
// If we're reconciling here, it means that it won't be handled correctly.
647+
// I'm not aware of any flow where this could happen, hence assertion.
648+
RCTAssert(NO, @"[RNScreens] Unexpected state reconciliation with More Navigation Controller");
649+
return;
650+
}
651+
652+
if (![self.selectedViewController isKindOfClass:RNSTabsScreenViewController.class]) {
653+
RCTAssert(
654+
NO,
655+
@"[RNScreens] Unexpected controller type during state reconciliation: %@",
656+
self.selectedViewController.class);
657+
return;
658+
}
659+
660+
NSString *selectedScreenKey = [self screenKeyForSelectedViewController];
661+
if ([_navigationState.selectedScreenKey isEqualToString:selectedScreenKey]) {
662+
return;
663+
}
664+
665+
RNSLog(
666+
@"TabBarCtrl reconcileNavigationStateWithUIKitState: %@ -> %@",
667+
_navigationState.selectedScreenKey,
668+
selectedScreenKey);
669+
[self progressNavigationState:selectedScreenKey withSource:RNSTabsNavigationStateUpdateSourceImplicit];
670+
671+
auto *context = [[RNSTabsNavigationStateUpdateContext alloc] initWithNavState:_navigationState
672+
isRepeated:NO
673+
hasTriggeredSpecialEffect:NO
674+
isNativeAction:YES];
675+
[self.tabsHostComponentView tabBarController:self didUpdateStateTo:_navigationState withContext:context];
676+
}
677+
599678
/**
600679
* This function assumes that the source of the state is NOT user. In current model, user update is never stale.
601680
*/

ios/tabs/host/RNSTabsNavigationState.h

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ typedef NS_ENUM(NSInteger, RNSTabsNavigationStateUpdateSource) {
5252
/** Update initiated by a native user interaction (e.g. tab tap). */
5353
RNSTabsNavigationStateUpdateSourceUser = 0,
5454
/** Update initiated externally (e.g. from JS via props). */
55-
RNSTabsNavigationStateUpdateSourceExternal
55+
RNSTabsNavigationStateUpdateSourceExternal,
56+
/** Update detected implicitly — UIKit changed the selection as a side effect of another operation
57+
* (e.g. More navigation controller disappearing during a horizontal size class transition on iPad). */
58+
RNSTabsNavigationStateUpdateSourceImplicit
5659
};
5760

5861
/** Reason why a navigation state update was rejected by the container. */

src/components/tabs/host/TabsHost.types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,10 @@ export type TabSelectedEvent = {
5959
isRepeated: boolean;
6060
/** Whether the selection triggered a special effect (e.g. scroll-to-top on repeated selection). */
6161
hasTriggeredSpecialEffect: boolean;
62-
/** Whether the selection was initiated by a native user action (tap) as opposed to a JS-driven update. */
62+
/**
63+
* False in case the event is a result of JS-driven update. True otherwise, e.g. in case of user action (tap)
64+
* or implicit UIKit action (app resize, orientation change, etc.).
65+
*/
6366
isNativeAction: boolean;
6467
};
6568

0 commit comments

Comments
 (0)