Before submitting a new issue
Bug summary
On iOS, toggling tabBarHidden while a tab hosts a native stack (react-native-screens / @react-navigation/native-stack) aborts the app:
*** Terminating app due to uncaught exception 'UIViewControllerHierarchyInconsistency',
reason: 'child view controller:<RNSNavigationController: 0x1090b5400>
should have parent view controller:<_TtGC7SwiftUI19UIHostingControllerVS_14_ViewList_View_: 0x109b9b400>
but actual parent is:<_TtGC7SwiftUI19UIHostingControllerVS_14_ViewList_View_: 0x109989400>'
This is the documented way to hide the bar for a full-screen route (a camera screen, in our case), so any app that does it is exposed. We hit it five times in ~15 minutes of ordinary use on a physical iPhone 11 / iOS 16.6, in a Release build, from two unrelated routes — the common factor is only "a stack screen is pushed in a tab while the flag flips".
The first-throw backtrace is entirely CoreFoundation/UIKit with a single app frame and nothing from JS, which is consistent with the abort happening inside UIKit's parent/child check during a SwiftUI-driven view move rather than in any React work.
Library version
1.4.0 (react-native-bottom-tabs and @bottom-tabs/react-navigation)
Environment info
OS: macOS 15.6.1
Xcode: 26.3 (17C529)
Device: iPhone 11, iOS 16.6, physical, Release build
react-native: 0.81.5 (New Architecture / Fabric enabled, Hermes)
react: 19.1.0
expo: 54.0.0 (dev client, prebuild)
react-native-bottom-tabs: 1.4.0
@bottom-tabs/react-navigation: 1.4.0
react-native-screens: 4.16.0
@react-navigation/native: 7.2.4
@react-navigation/native-stack: 7.15.1
react-native-safe-area-context: 5.6.2
(react-native info is unavailable in this project — it is an Expo project without @react-native-community/cli — so the above is assembled from the resolved lockfile versions.)
Steps to reproduce
- Build a
createNativeBottomTabNavigator with at least one tab whose component is a createNativeStackNavigator.
- Register a full-screen route in that stack (
headerShown: false).
- Drive the navigator's
tabBarHidden prop from navigation state, so pushing that route sets it true and leaving it sets it false — the usual "hide the bar for an immersive screen" pattern.
- On a device or simulator running iOS 16 or 17, push that route and navigate back, a few times.
- The app aborts with the exception above. It is not deterministic on the first push, but reproduces within a handful of transitions.
Reproducible sample code
const Tab = createNativeBottomTabNavigator();
const Stack = createNativeStackNavigator();
function RecordStack() {
// `focus` is the authoritative signal; the setter drives AppTabs' state.
const screenListeners = ({ route }) => ({
focus: () => setTabBarHidden(route.name === "FullScreenRoute"),
});
return (
<Stack.Navigator screenListeners={screenListeners}>
<Stack.Screen name="Hub" component={HubScreen} />
<Stack.Screen name="FullScreenRoute" component={FullScreen}
options={{ headerShown: false }} />
</Stack.Navigator>
);
}
function AppTabs() {
const [tabBarHidden, setTabBarHidden] = useState(false);
return (
<Tab.Navigator tabBarHidden={tabBarHidden}>
<Tab.Screen name="Record" component={RecordStack} />
<Tab.Screen name="Other" component={OtherScreen} />
</Tab.Navigator>
);
}
I do not have a standalone repro repository — the above is reduced from a production app, and the crash is in the interaction between this library and react-native-screens rather than in app code. I am happy to build one against the example app if that would help; the analysis below is precise enough that it may not be necessary.
Root cause
ios/TabViewImpl.swift:538 — hideTabBar(_:) is a @ViewBuilder whose branch is chosen by a runtime flag:
@ViewBuilder
func hideTabBar(_ flag: Bool) -> some View {
#if !os(macOS)
if flag {
if #available(iOS 16.0, tvOS 16.0, *) {
self.toolbar(.hidden, for: .tabBar)
} else {
// We fallback to isHidden on UITabBar
self
}
} else {
self
}
#else
self
#endif
}
A @ViewBuilder if/else compiles to _ConditionalContent, so flipping flag is a structural identity change: SwiftUI tears down the subtree it wraps and builds a new one, rather than updating it in place.
On the pre-iOS-18 path (ios/TabView/LegacyTabView.swift:33) that modifier wraps the entire TabView, so every tab's content is rebuilt and each gets a fresh UIHostingController<_ViewList_View>. ios/RepresentableView.swift then runs makeUIView again:
func makeUIView(context: Context) -> PlatformView {
let wrapper = UIView()
wrapper.addSubview(view) // `view` is the persistent RN subview
return wrapper
}
view here is not SwiftUI-owned — TabViewProvider.swift:199 sets props.children = reactSubviews().map(IdentifiablePlatformView.init), so these are the React-owned UIView instances, which survive the rebuild. When one of them contains an RNSScreenStackView, its RNSNavigationController is still registered as a child view controller of the old hosting controller at the moment it is added as a subview of the new one. That is precisely the state UIKit's parent/child consistency check rejects, and it is why the exception names two different UIHostingController<_ViewList_View> instances.
Proposed fix
Make the modifier value-changing rather than branch-changing, so the only remaining branch is #available, which cannot flip during the process lifetime:
@ViewBuilder
func hideTabBar(_ flag: Bool) -> some View {
#if !os(macOS)
if #available(iOS 16.0, tvOS 16.0, *) {
self.toolbar(flag ? .hidden : .automatic, for: .tabBar)
} else {
self
}
#else
self
#endif
}
.automatic rather than .visible so that the iOS 26 tabBarMinimizeBehavior default is not overridden — though that is the part of this suggestion I am least sure of, since it changes the flag == false case from "no modifier at all" to "an explicit .automatic", and I have not yet been able to check on an iOS 26 device whether the bar still minimizes on scroll.
We are validating a local patch of exactly this shape now and I will report back here with the result. If it holds and you would like it as a PR, I am glad to open one.
Note on iOS 18+
ios/TabView/NewTabView.swift:45 applies .hideTabBar(props.tabBarHidden) from the same global flag inside each Tab { }, directly wrapping RepresentableView. The blast radius is smaller — one tab's content rather than the whole TabView — but the mechanism looks identical, so I would not assume the modern path is safe. Our only device evidence is from 16.6, so I am reporting that as an inspection of the code rather than an observation.
Before submitting a new issue
main(982f87e).UIViewControllerHierarchyInconsistency. Related but distinct:tabBarHiddennavigator prop is silently ignored — hardcoded in JS wrapper #521, TabAppearModifier hijacks the 5th tab (index 4) selection when a hidden tab bar re-appears #525.Bug summary
On iOS, toggling
tabBarHiddenwhile a tab hosts a native stack (react-native-screens/@react-navigation/native-stack) aborts the app:This is the documented way to hide the bar for a full-screen route (a camera screen, in our case), so any app that does it is exposed. We hit it five times in ~15 minutes of ordinary use on a physical iPhone 11 / iOS 16.6, in a Release build, from two unrelated routes — the common factor is only "a stack screen is pushed in a tab while the flag flips".
The first-throw backtrace is entirely CoreFoundation/UIKit with a single app frame and nothing from JS, which is consistent with the abort happening inside UIKit's parent/child check during a SwiftUI-driven view move rather than in any React work.
Library version
1.4.0 (
react-native-bottom-tabsand@bottom-tabs/react-navigation)Environment info
(
react-native infois unavailable in this project — it is an Expo project without@react-native-community/cli— so the above is assembled from the resolved lockfile versions.)Steps to reproduce
createNativeBottomTabNavigatorwith at least one tab whose component is acreateNativeStackNavigator.headerShown: false).tabBarHiddenprop from navigation state, so pushing that route sets ittrueand leaving it sets itfalse— the usual "hide the bar for an immersive screen" pattern.Reproducible sample code
I do not have a standalone repro repository — the above is reduced from a production app, and the crash is in the interaction between this library and
react-native-screensrather than in app code. I am happy to build one against the example app if that would help; the analysis below is precise enough that it may not be necessary.Root cause
ios/TabViewImpl.swift:538—hideTabBar(_:)is a@ViewBuilderwhose branch is chosen by a runtime flag:A
@ViewBuilderif/elsecompiles to_ConditionalContent, so flippingflagis a structural identity change: SwiftUI tears down the subtree it wraps and builds a new one, rather than updating it in place.On the pre-iOS-18 path (
ios/TabView/LegacyTabView.swift:33) that modifier wraps the entireTabView, so every tab's content is rebuilt and each gets a freshUIHostingController<_ViewList_View>.ios/RepresentableView.swiftthen runsmakeUIViewagain:viewhere is not SwiftUI-owned —TabViewProvider.swift:199setsprops.children = reactSubviews().map(IdentifiablePlatformView.init), so these are the React-ownedUIViewinstances, which survive the rebuild. When one of them contains anRNSScreenStackView, itsRNSNavigationControlleris still registered as a child view controller of the old hosting controller at the moment it is added as a subview of the new one. That is precisely the state UIKit's parent/child consistency check rejects, and it is why the exception names two differentUIHostingController<_ViewList_View>instances.Proposed fix
Make the modifier value-changing rather than branch-changing, so the only remaining branch is
#available, which cannot flip during the process lifetime:.automaticrather than.visibleso that the iOS 26tabBarMinimizeBehaviordefault is not overridden — though that is the part of this suggestion I am least sure of, since it changes theflag == falsecase from "no modifier at all" to "an explicit.automatic", and I have not yet been able to check on an iOS 26 device whether the bar still minimizes on scroll.We are validating a local patch of exactly this shape now and I will report back here with the result. If it holds and you would like it as a PR, I am glad to open one.
Note on iOS 18+
ios/TabView/NewTabView.swift:45applies.hideTabBar(props.tabBarHidden)from the same global flag inside eachTab { }, directly wrappingRepresentableView. The blast radius is smaller — one tab's content rather than the wholeTabView— but the mechanism looks identical, so I would not assume the modern path is safe. Our only device evidence is from 16.6, so I am reporting that as an inspection of the code rather than an observation.