Hacktoberfest 2026:维护者为十月标记出来的 issue,仍然开放、适合新手。 浏览 Hacktoberfest issue

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

未关闭
#591 1 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

维护者通常 1 天内回复

还没有人认领这个 Issue。

评估

难度
4/5
预计耗时
3-5 天
新手友好度
68/100
Issue 类型
缺陷
描述清晰度
描述清楚
活跃度
活跃
技术栈
ios, react, swift
领域
mobile

调研方向

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.

由索引模型根据 Issue 内容生成。

描述

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.

主要语言
TypeScript
星标
1.5k
派生
108
平均合并
22 小时 36 分钟
30 天内合并 PR
10

环境准备

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

callstack/react-native-bottom-tabs 的其他 Issue

查看 callstack/react-native-bottom-tabs 的全部 Issue

相似的 Issue

更多 TypeScript Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。