Skip to content

iOS: toggling tabBarHidden crashes with UIViewControllerHierarchyInconsistency when a tab hosts a native stack #591

Description

@ak-wake

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

  1. Build a createNativeBottomTabNavigator with at least one tab whose component is a createNativeStackNavigator.
  2. Register a full-screen route in that stack (headerShown: false).
  3. 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.
  4. On a device or simulator running iOS 16 or 17, push that route and navigate back, a few times.
  5. 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:538hideTabBar(_:) 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions