Hacktoberfest 2026: as issues que os mantenedores marcaram para outubro, abertas e boas para iniciantes. Ver issues do Hacktoberfest

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

Aberta
#591 1 comentário 0 reações 0 responsáveis Ver no GitHub

Mantenedores costumam responder em até 2 dias

Ninguém assumiu esta issue ainda.

Avaliação

Dificuldade
4/5
Tempo estimado
3-5 dias
Facilidade para iniciantes
68/100
Tipo de issue
Bug
Clareza
Claramente especificada
Status de atividade
Ativa
Stack de tecnologia
ios, react, swift
Domínio
mobile

Direção de pesquisa

Start with ios/TabViewImpl.swift:538 and trace hideTabBar through ios/TabView/LegacyTabView.swift, ios/TabView/NewTabView.swift, ios/RepresentableView.swift, and TabViewProvider.swift:199. Run the supplied native bottom-tab/native-stack reproduction on iOS 16 or 17, then verify that toggling tabBarHidden no longer crashes and that both legacy and modern tab paths preserve expected tab-bar behavior.

Escrita pelo modelo de indexação a partir do texto da issue.

Descrição

Before submitting a new issue
  • I tested using the latest version of the library (1.4.0), and the code in question is unchanged on main (982f87ebd).
  • I tested using a supported version of React Native (0.81.5).
  • I checked for possible duplicate issues — no existing report mentions UIViewControllerHierarchyInconsistency. Related but distinct: #521, #525.
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: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.

Linguagem predominante
TypeScript
Estrelas
1.5k
Forks
108
Merge médio
22h 36min
PRs com merge (30d)
10

Preparar o ambiente

Primeiros passos

  1. Leia a issue inteira e depois o guia de contribuição do projeto.
  2. Comente na issue dizendo que vai assumir — evita que duas pessoas façam o mesmo trabalho.
  3. Faça um fork do repositório e trabalhe em uma branch.
  4. Abra um pull request que referencie o número da issue.

Mais de callstack/react-native-bottom-tabs

Todas as issues de callstack/react-native-bottom-tabs

Issues semelhantes

Mais issues de TypeScript

Receba novas issues na sua caixa de entrada

Um resumo curto de issues do GitHub para quem está começando.