Skip to content

[$250] [Sentry: APP-N5H] HybridApp Android: native tab bar crashes with more than 6 items #104505

Open

Description

@mountiny

Sentry

https://expensify.sentry.io/issues/APP-N5H

Impact (snapshot at filing)

  • Users (total since first seen): 39
  • Events: 143
  • Users (last 14d, all versions): 39
  • Users (last 14d, production 9.5.11 to 9.5.13): 1
  • First seen: 2026-10-16 (4 days ago)
  • Last seen: 2026-10-20 (ongoing, Sentry marks it as escalating)
  • Platform: Android HybridApp
  • App version(s): 9.5.13-0 (31 users) and 9.5.13-1 (4 users), plus 1 user each on 9.5.9-4, 9.5.10-0, 9.5.11-0, 9.5.13-3 and 9.5.14-0
  • Affected route(s) (parameterized): native screen; the sample event was on Settings_Troubleshoot
  • Mechanism: UncaughtExceptionHandler, level fatal

Most users were on 9.5.13-0, a staging build. 9.5.13-6 is now rolling out to production on Android, so this may grow.

Stack trace (top frames, first-party only)

com.swmansion.rnscreens.ScreenStack.onUpdate (ScreenStack.kt:323)
com.swmansion.rnscreens.gamma.tabs.container.TabsContainer.onAttachedToWindow (TabsContainer.kt:311)
com.swmansion.rnscreens.gamma.tabs.container.TabsContainer.flushPendingUpdates (TabsContainer.kt:212)
com.swmansion.rnscreens.gamma.tabs.container.TabsContainer.updateNavigationMenuStructure (TabsContainer.kt:545)
com.swmansion.rnscreens.gamma.tabs.container.MenuHelpersKt.getOrCreateMenuItemForFragment (MenuHelpers.kt:8)
androidx.appcompat.view.menu.MenuBuilder.add
IllegalArgumentException: Maximum number of items supported by CustomBottomNavigationView is 6

Suspected cause

The native tab container adds a seventh item to the Android bottom navigation menu, which supports at most 6. It happens when a screen stack attaches the tab container again, so the tab menu items may be added again instead of reused.

Reproduction

Unknown. See the Sentry events linked from the Sentry issue.

Related

Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @wildan-m

Activity

  1. self-assigned this
    on Oct 20, 2026
  2. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Oct 20, 2026
  3. melvin-bot commented on Oct 20, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @mananjadhav (External)

  4. changed the title [-][Sentry: APP-N5H] HybridApp Android: native tab bar crashes with more than 6 items[/-] [+][$175] [Sentry: APP-N5H] HybridApp Android: native tab bar crashes with more than 6 items[/+] on Oct 20, 2026
  5. melvin-bot commented on Oct 20, 2026

    @melvin-bot
  6. RajGahoi commented on Oct 20, 2026

    @RajGahoi

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    HybridApp on Android crashes with IllegalArgumentException: Maximum number of items supported by CustomBottomNavigationView is 6 when a screen stack re-attaches the native tab container or when more than 6 navigation items are added.

    What is the root cause of that problem?

    Android's BottomNavigationView / CustomBottomNavigationView enforces a hard limit of maximum 6 items (MAX_ITEM_COUNT = 6).

    In PR #101339 and patch @react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch, the app registers 6 tab routes:
    HOME, REPORTS_SPLIT_NAVIGATOR, SEARCH_FULLSCREEN_NAVIGATOR, INSIGHTS, WORKSPACE_NAVIGATOR, SETTINGS_SPLIT_NAVIGATOR.
    Although getTabWithoutBarItem designates one route as abBarItemHidden: true to stay at 5 items, when a screen stack re-attaches (ScreenStack.kt:onUpdate -> TabsContainer.kt:onAttachedToWindow -> flushPendingUpdates -> updateNavigationMenuStructure), menu items from the previous fragment attachment can persist or re-accumulate without clearing or recycling if lushPendingUpdates runs repeatedly during stack transitions. Adding the new items pushes the menu size to 7 (or above), throwing IllegalArgumentException: Maximum number of items supported by CustomBottomNavigationView is 6.

    What changes do you think we should make in order to solve the problem?

    1. In TabsContainer.kt (or via patch to
      eact-native-screens), before calling updateNavigationMenuStructure / getOrCreateMenuItemForFragment, ensure menu.clear() is called or check that existing menu items matching the fragment tabs are reused/updated rather than re-added.
    2. In @react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch, strictly guard that routes marked with abBarItemHidden: true are never passed as native tab fragments to RNScreens under any transition state.

    Automated Tests

    Add a test in ScreenStackTest / navigation tests that mounts the native tab navigator, performs stack pushes and pops (such as opening Settings_Troubleshoot), and verifies that the menu items count in BottomNavigationView never exceeds 5.

    Contributor details
    Your Expensify account email: gahoiraj01@gmail.com

  7. RajGahoi commented on Oct 20, 2026

    @RajGahoi

    Contributor details update:
    Upwork Profile Link: https://www.upwork.com/freelancers/~014898525b9c53fc32

  8. jloa-dev commented on Oct 21, 2026

    @jloa-dev

    Contributor details
    Your Expensify account email: jloa.dev@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~0163955a2ba221e513

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On Android HybridApp, navigating between screens (e.g., visiting Settings_Troubleshoot or during screen transitions inside a ScreenStack) crashes fatally with:
    IllegalArgumentException: Maximum number of items supported by CustomBottomNavigationView is 6
    thrown from:

    com.swmansion.rnscreens.ScreenStack.onUpdate (ScreenStack.kt:323)
    com.swmansion.rnscreens.gamma.tabs.container.TabsContainer.onAttachedToWindow (TabsContainer.kt:311)
    com.swmansion.rnscreens.gamma.tabs.container.TabsContainer.flushPendingUpdates (TabsContainer.kt:212)
    com.swmansion.rnscreens.gamma.tabs.container.TabsContainer.updateNavigationMenuStructure (TabsContainer.kt:545)
    com.swmansion.rnscreens.gamma.tabs.container.MenuHelpersKt.getOrCreateMenuItemForFragment (MenuHelpers.kt:8)
    androidx.appcompat.view.menu.MenuBuilder.add
    

    What is the root cause of that problem?

    1. In react-native-screens (gamma tabs on Android), TabsContainer manages bottom navigation tabs using CustomBottomNavigationView (based on Android Material's BottomNavigationView). Android's bottom navigation menu enforces a strict hardware constraint:
      MAX_ITEM_COUNT = 6 (or 5 standard, 6 custom). Adding a 7th item to MenuBuilder throws IllegalArgumentException.
    2. In ScreenStack.kt:286, when screen fragments are updated or re-appended in stack order, the fragment containing the tab bar is detached and re-attached to the window (transaction.remove(wrapper.fragment) followed by transaction.add(...)).
    3. When TabsContainer.onAttachedToWindow() executes, it calls flushPendingUpdates() -> updateNavigationMenuStructure().
    4. In updateNavigationMenuStructure(), TabsContainer iterates through its tab fragments and calls getOrCreateMenuItemForFragment(fragment) which delegates to bottomNavigationView.menu.add(...).
    5. However, updateNavigationMenuStructure() does not clear or synchronize the existing bottomNavigationView.menu prior to re-populating items. Because items from the initial layout pass are already present in the menu, re-adding the tabs appends duplicate menu items to the MenuBuilder. As soon as menu.size() reaches 7 (e.g. 4 tabs added twice = 8 items), Android throws IllegalArgumentException: Maximum number of items supported by CustomBottomNavigationView is 6.

    What alternative solutions did you explore? (Optional)

    None, clearing or diffing existing menu items in TabsContainer.updateNavigationMenuStructure before adding items is the cleanest, idiomatic solution.

    What changes do you think we should make in order to solve the problem?

    Add a patch in patches/react-native-screens/ (or update existing gamma patches, e.g. react-native-screens+4.28.0+006+clear-tab-menu-before-update.patch):

    In node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt:
    Before populating fragments in updateNavigationMenuStructure(), clear the existing menu:

    --- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt
    +++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt
    @@ -540,6 +540,7 @@ class TabsContainer(
         }
     
         private fun updateNavigationMenuStructure() {
    +        bottomNavigationView.menu.clear()
             fragments.forEachIndexed { index, fragment ->
                 val menuItem = getOrCreateMenuItemForFragment(fragment)
                 // configure item...

    Or in MenuHelpers.kt:
    Ensure that getOrCreateMenuItemForFragment checks bottomNavigationView.menu.findItem(fragment.id) instead of unconditionally calling menu.add(...).

    What automated tests can you add to prevent this from re-occurring?

    Add an Android instrumented/unit test for TabsContainer verifying that calling onAttachedToWindow() and flushPendingUpdates() repeatedly on a TabsContainer with 4-5 tabs never causes bottomNavigationView.menu.size() to exceed the fragment count or throw IllegalArgumentException.

  9. melvin-bot commented on Oct 21, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  10. sijo0218 commented on Oct 21, 2026

    @sijo0218

    Contributor details
    Your Expensify account email: sijo0218@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~011091133bbb09cd1c

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On Android HybridApp, navigating between screens (e.g. visiting sub-pages or during screen transitions in a ScreenStack) fatal crashes with:

    IllegalArgumentException: Maximum number of items supported by CustomBottomNavigationView is 6
    

    thrown from:

    com.swmansion.rnscreens.ScreenStack.onUpdate (ScreenStack.kt:323)
    com.swmansion.rnscreens.gamma.tabs.container.TabsContainer.onAttachedToWindow (TabsContainer.kt:311)
    com.swmansion.rnscreens.gamma.tabs.container.TabsContainer.flushPendingUpdates (TabsContainer.kt:212)
    com.swmansion.rnscreens.gamma.tabs.container.TabsContainer.updateNavigationMenuStructure (TabsContainer.kt:545)
    com.swmansion.rnscreens.gamma.tabs.container.MenuHelpersKt.getOrCreateMenuItemForFragment (MenuHelpers.kt:8)
    androidx.appcompat.view.menu.MenuBuilder.add
    

    What is the root cause of that problem?

    There are two interconnected defects in react-native-screens gamma tabs on Android:

    1. Child Removal Failure in Fabric (TabsHostViewManager):

      • In React Native Fabric, ViewGroupManager manages view additions and removals by inspecting child views via getChildCount(parent) and getChildAt(parent, index).
      • In TabsHostViewManager.kt, TabsHost does not attach TabsScreen views directly as Android ViewGroup children (they are managed in an internal renderedScreens list and encapsulated in TabsScreenFragment inside TabsContainer.tabsModel).
      • However, TabsHostViewManager does not override getChildCount or getChildAt. Consequently, React Native defaults to parent.childCount (which only sees the root container, count = 1).
      • When Fabric attempts to remove an inactive or toggled tab child at index > 0, getChildAt(parent, index) returns out of bounds or mismatches, causing Fabric to consider the view "already removed from parent" and skip calling removeViewAt / removeView.
      • As a result, TabsHost.unmountReactSubviewAt and TabsContainer.removeTabsScreenAt are never called for the old tab. When the tab or replacement screen is re-mounted, TabsHostViewManager.addView calls mountReactSubviewAt -> container.addTabsScreenAt, appending another fragment into tabsModel.
      • Every tab transition or beta re-render leaks stale fragments into tabsModel, causing tabsModel.size to grow beyond 5 to 7 or more.
    2. Uncapped Menu Population against Hardware Limits in updateNavigationMenuStructure():

      • In TabsContainer.kt:532-547:
        private fun updateNavigationMenuStructure() {
            val menu = bottomNavigationView.menu
            val isMenuInSync =
                menu.size == tabsModel.size &&
                    tabsModel.withIndex().all { (index, fragment) ->
                        menu.getItem(index).itemId == fragment.menuItemId
                    }
            if (isMenuInSync) return
            menu.clear()
            tabsModel.forEach { fragment ->
                menu.getOrCreateMenuItemForFragment(fragment)
            }
        }
      • Notice that menu.clear() on line 543 does clear bottomNavigationView.menu. However, tabsModel.forEach iterates over all items in tabsModel without checking bottomNavigationView.maxItemCount (MAX_ITEM_COUNT = 6 in CustomBottomNavigationView / Android Material NavigationBarMenu).
      • In TabsHost.kt:76, mountReactSubviewAt only checks index < bottomNavigationViewMaxItemCount, never checking whether renderedScreens.size >= bottomNavigationViewMaxItemCount or whether the TabsScreen is already mounted.
      • When ScreenStack.onUpdate executes (e.g. re-attaching screens in stack order via transaction.commitNowAllowingStateLoss()), TabsContainer.onAttachedToWindow calls flushPendingUpdates() -> updateNavigationMenuStructure(). Because tabsModel accumulated 7 or more fragments, tabsModel.forEach attempts to add the 7th item into bottomNavigationView.menu.
      • NavigationBarMenu.addInternal detects size() + 1 > maxItemCount (7 > 6) and throws IllegalArgumentException: Maximum number of items supported by CustomBottomNavigationView is 6.

    What alternative solutions did you explore? (Optional)

    Attempting to only cap at the JS layer (NativeBottomTabView.native.js) is insufficient because Fabric view re-ordering and remount cycles on Android will still leak native fragments inside TabsHost.renderedScreens and TabsContainer.tabsModel if TabsHostViewManager cannot report its children. Both the view manager lifecycle and native container menu bounds must be secured.

    What changes do you think we should make in order to solve the problem?

    Add a patch in patches/react-native-screens/react-native-screens+4.28.0+006+fix-tabs-host-child-lifecycle-and-menu-limit.patch:

    1. In TabsHostViewManager.kt:
      Override getChildCount and getChildAt to reflect renderedScreens so Fabric can successfully unmount child screens:

      override fun getChildCount(parent: TabsHost): Int = parent.renderedScreensCount
      
      override fun getChildAt(parent: TabsHost, index: Int): View? = parent.getRenderedScreenAt(index)
    2. In TabsHost.kt:
      Expose renderedScreensCount and getRenderedScreenAt(index):

      internal val renderedScreensCount: Int
          get() = renderedScreens.size
      
      internal fun getRenderedScreenAt(index: Int): TabsScreen? =
          renderedScreens.getOrNull(index)

      In mountReactSubviewAt, guard against duplicate insertion and ensure renderedScreens deduplicates any existing entry for the same screen:

      internal fun mountReactSubviewAt(tabsScreen: TabsScreen, index: Int) {
          unmountReactSubview(tabsScreen)
          val maxCount = container.bottomNavigationView.maxItemCount
          require(renderedScreens.size < maxCount) {
              "[RNScreens] Cannot mount TabsScreen: container already reached maximum capacity of $maxCount items"
          }
          renderedScreens.add(index.coerceAtMost(renderedScreens.size), tabsScreen)
          tabsScreen.setTabsScreenDelegate(container)
          container.addTabsScreenAt(index.coerceAtMost(renderedScreens.size - 1), tabsScreen)
      }
    3. In TabsContainer.kt:
      In addTabsScreenAt, deduplicate matching screens:

      internal fun addTabsScreenAt(index: Int, tabsScreen: TabsScreen) {
          tabsModel.removeIf { it.tabsScreen === tabsScreen || it.requireScreenKey == tabsScreen.requireScreenKey }
          tabsModel.add(index.coerceAtMost(tabsModel.size), TabsScreenFragment(tabsScreen))
          invalidationFlags.invalidateAll()
      }

      In updateNavigationMenuStructure(), bound the iteration strictly to bottomNavigationView.maxItemCount:

      private fun updateNavigationMenuStructure() {
          val menu = bottomNavigationView.menu
          val isMenuInSync =
              menu.size == tabsModel.size &&
                  tabsModel.withIndex().all { (index, fragment) ->
                      menu.getItem(index).itemId == fragment.menuItemId
                  }
      
          if (isMenuInSync) {
              return
          }
          menu.clear()
          val maxItems = bottomNavigationView.maxItemCount
          tabsModel.take(maxItems).forEach { fragment ->
              menu.getOrCreateMenuItemForFragment(fragment)
          }
      }

    What automated tests can you add to prevent this from re-occurring?

    1. In Android unit/integration tests (TabsContainerTest / TabsHostTest), verify that repeatedly mounting, unmounting, and re-mounting tabs on TabsHost with Fabric view manager dispatches:
      • Maintains renderedScreens.size == tabsModel.size.
      • Never exceeds bottomNavigationView.maxItemCount.
      • Never throws IllegalArgumentException during onAttachedToWindow() and flushPendingUpdates().
    2. In tests/unit/NativeBottomTabViewTest.tsx, add test cases simulating rapid navigation transitions across tab routes and beta toggles to ensure the number of native Tabs.Screen children mounted to Tabs.Host never exceeds 5.
  11. melvin-bot commented on Oct 21, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  12. 3 remaining items

  13. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on Oct 23, 2026
  14. wildan-m commented on Oct 23, 2026

    @wildan-m
    Contributor

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On Android HybridApp, the app crashes fatally with IllegalArgumentException: Maximum number of items supported by CustomBottomNavigationView is 6, thrown while the native tab container rebuilds its bottom navigation menu after being re-attached by a screen stack (Sentry APP-N5H).

    What is the root cause of that problem?

    The native tab bar is react-native-screens' TabsHost. Tab screens are not regular view children of TabsHost: mountReactSubviewAt stores each TabsScreen in an internal renderedScreens list and hands it to TabsContainer, which wraps it in a fragment and renders it inside the container's own content view:

    https://github.com/software-mansion/react-native-screens/blob/2381ed8d72fe19185fc8415cb61df7b79bbf4376/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt#L85-L112

    When React removes a tab screen, Fabric's SurfaceMountingManager.removeViewAt first verifies the removal: it asks the parent's view manager for the child at that index via getChildAt, and when the tag does not match, it scans the parent's raw view children for the tag. If the tag is not found there either, it logs "view already removed from parent!" and returns without ever calling the view manager's removeViewAt:

    https://github.com/facebook/react-native/blob/a632f9efe24bac8b3a113c78469948f55bde0f5d/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.kt#L455-L484

    TabsHostViewManager does not override getChildCount/getChildAt, so the default implementation reads the raw FrameLayout children of TabsHost — and those contain only the internal TabsContainer, never the TabsScreen views:

    https://github.com/software-mansion/react-native-screens/blob/2381ed8d72fe19185fc8415cb61df7b79bbf4376/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt#L33-L56

    The result is that on Android, every native removal of a tab screen is silently skipped: unmountReactSubviewAt never runs, and the removed screen stays in renderedScreens and in TabsContainer's tabsModel forever. The app registers exactly six tabs — the maximum the Material bottom navigation view supports. Whenever React recreates the tab screens while the TabsHost view itself survives (a navigation state reset regenerates the route keys, so all six screens are torn down and recreated; the sample Sentry event sits on the Troubleshoot screen, whose "Reset and refresh" option wipes and reloads local app state while preserving the session), the six stale entries remain and the replacements are appended. The size guard in mountReactSubviewAt only checks the insertion index (always 0–5), not the total size, so tabsModel silently grows past the cap.

    The crash then surfaces later, on re-attach: flushPendingUpdates is a no-op while the container is detached (for example while a pushed stack screen covers it), so the first menu rebuild runs from onAttachedToWindow — which is why the stack goes through ScreenStack.onUpdate. updateNavigationMenuStructure clears the menu and re-adds one item per tabsModel entry, and the seventh add throws:

    https://github.com/software-mansion/react-native-screens/blob/2381ed8d72fe19185fc8415cb61df7b79bbf4376/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt#L532-L547

    https://github.com/software-mansion/react-native-screens/blob/2381ed8d72fe19185fc8415cb61df7b79bbf4376/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/MenuHelpers.kt#L7-L13

    The defect is still present on the latest react-native-screens nightly (the tabs code moved out of the gamma namespace, but TabsHostViewManager still has no getChildCount/getChildAt overrides and the menu rebuild is unchanged), so a library bump does not fix this.

    What changes do you think we should make in order to solve the problem?

    Add a new sequenced patch to patches/react-native-screens/ (with the matching details.md entry) that lets Fabric see the tab screens as children of TabsHost, the same way react-native-screens already handles its other fragment-based parents — ScreenStackViewManager and ScreenContainerViewManager both override getChildCount and getChildAt to answer from their internal screen lists instead of the raw view children:

    https://github.com/software-mansion/react-native-screens/blob/2381ed8d72fe19185fc8415cb61df7b79bbf4376/android/src/main/java/com/swmansion/rnscreens/ScreenStackViewManager.kt#L55-L62

    Concretely: expose the rendered tab screens from TabsHost through two small internal accessors (the count of renderedScreens and the screen at a given index), and override getChildCount and getChildAt in TabsHostViewManager to delegate to them. With that in place, Fabric's verification in removeViewAt finds the expected tag at the expected index and proceeds to call the view manager's removeViewAt, so unmountReactSubviewAt runs, the removed screen leaves renderedScreens and tabsModel, and any later menu rebuild works with exactly the live tabs. The over-cap menu.add can no longer happen, and stale fragments no longer accumulate across remounts.

    Since the missing overrides are still present upstream, this should also be reported to software-mansion/react-native-screens, and the patch can be dropped once an upstream release ships the fix.

    Compare branch: https://github.com/Expensify/App/compare/main...wildan-m:App:wildan/104505-android-tab-screen-removals?expand=1

    What alternative solutions did you explore? (Optional)

    Making updateNavigationMenuStructure defensive — capping the rebuild at the menu's maximum or deduplicating tabsModel by screen key — hides the crash but keeps the real defect: the stale TabsScreenFragment entries stay registered, keep participating in selection lookups, and the bar could show items for screens React has already dropped. Similarly, tightening the guard in mountReactSubviewAt to check the total size rather than the insertion index would only turn this crash into a different, earlier one. Fixing the child bookkeeping so removals actually reach the view manager addresses the cause rather than the symptom.

  15. David-2002065 commented on Oct 23, 2026

    @David-2002065
    Contributor

    Proposal

    What is the root cause of that problem?

    The bar never gets 7 items from a double add. updateNavigationMenuStructure already does menu.clear() and then adds one item per entry in tabsModel (TabsContainer.kt#L532-L547). So the crash means tabsModel really holds 7 TabsScreens. Native tabs get added but are never removed.

    The reason is that Android can't remove a native tab. TabsHostViewManager overrides removeViewAt but not getChildAt/getChildCount (TabsHostViewManager.kt#L50-L55). Before removing anything, Fabric checks which child is at that index through viewGroupManager.getChildAt (SurfaceMountingManager.kt#L456-L482). The default returns the real Android child of TabsHost, which is the container and not a TabsScreen. The tag doesn't match, so Fabric logs view already removed from parent! and returns. TabsHost.unmountReactSubviewAt is never called, but inserts still go through mountReactSubviewAt (TabsHost.kt#L85-L105). The gamma stack sibling already has these overrides (StackHostViewManager.kt#L52-L57), but the tabs one doesn't.

    In the App, a tab gets removed and inserted whenever the Insights beta flips. We register 6 tabs, and one of them is marked tabBarItemHidden: Account with the beta, Insights without it (getTabWithoutBarItem.ts#L8-L10, TabNavigator.native.tsx#L41-L48). The hidden-tab patch renders null instead of a Tabs.Screen for the hidden route (hidden-tab-items.patch#L68-L70). Each flip inserts one native tab and leaves the stale one behind:

    • start: 5 tabs
    • 1st flip: Insights inserted, Account removal skipped, so 6 (no crash yet, the bar shows a stale extra tab)
    • 2nd flip: Account inserted again as a new view, Insights removal skipped, so 7, and MenuBuilder.add throws

    That's why the sample event is on Settings_Troubleshoot. There are two ways to flip the beta twice from there:

    1. Troubleshoot > Beta overrides, toggle insightsPage twice (DynamicBetaOverridesPage.tsx#L54-L61). This page is only on staging/internal builds, which is why most of the users are on the 9.5.13-0 staging build.
    2. Troubleshoot > Clear cache and restart. BETAS isn't in KEYS_TO_PRESERVE (App.ts#L132-L159), so for an Insights user it goes from on to unknown (off) and back to on after OpenApp.

    The tab container is detached while the RHP covers it, so flushPendingUpdates doesn't run until the stack attaches the container again. That's the ScreenStack.onUpdate -> TabsContainer.onAttachedToWindow -> flushPendingUpdates path in the trace (TabsContainer.kt#L291-L311).

    This is the same native bug as #104230. #104238 worked around it only for the route key change, by keying native tabs by route name (patch 004). That patch landed in 9.5.13-1, which is why 9.5.13-0 has most of the events. A hidden/visible swap still adds and removes a real Tabs.Screen, so 9.5.13-1, 9.5.13-3 and 9.5.14-0 still crash.

    What changes do you think we should make in order to solve the problem?

    Fix the removal itself so Fabric reaches unmountReactSubviewAt. Add a new patch, react-native-screens+4.28.0+006+fix-android-tabs-host-child-removal.patch, that gives TabsHostViewManager the same overrides StackHostViewManager has.

    In TabsHost.kt, expose the rendered screens:

    internal val reactSubviewCount: Int
        get() = renderedScreens.size
    
    internal fun getReactSubviewAt(index: Int): TabsScreen? = renderedScreens.getOrNull(index)

    In TabsHostViewManager.kt:

    override fun getChildCount(parent: TabsHost): Int = parent.reactSubviewCount
    
    override fun getChildAt(
        parent: TabsHost,
        index: Int,
    ): View? = parent.getReactSubviewAt(index)

    Now Fabric finds the matching tag at that index and calls removeViewAt -> unmountReactSubviewAt, so tabsModel stays in sync with what JS renders. The beta flip ends with 5 items, the stale extra tab is gone, and the menu can't reach 7. Nothing changes on iOS, and we don't need to touch the JS hidden-tab logic. I'd keep patch 004 because it still prevents the remount flash, but its "Remove this patch once react-native-screens removes Android tabs correctly" note in details.md would then be satisfied for the Android part. I'd also report it upstream, since StackHost already has the fix.

    For tests, the bug is native, so a jest test can't reach it. I'll cover it with a manual QA step on Android: toggle insightsPage in Beta overrides 3 times and go back, then do Clear cache and restart as an Insights user, and check that the bar always shows exactly 5 tabs without crashing. I'll also add a jest test for the JS contract we rely on: flipping CONST.BETAS.INSIGHTS_PAGE renders exactly 5 Tabs.Screens, with only the hidden route swapped.

    What alternative solutions did you explore? (Optional)

    1. Clear the menu or reuse items before adding them in TabsContainer (as a couple of the proposals above suggest). It's already there: menu.clear() runs before re-adding and getOrCreateMenuItemForFragment uses findItem first (MenuHelpers.kt#L7-L12). The extra item comes from tabsModel, not the menu.
    2. JS-only: keep all 6 Tabs.Screens mounted and let RNScreens hide the hidden one, so a beta flip never adds or removes a native tab. RNScreens has no prop to hide a single Android menu item, so this needs a native patch anyway and does more than the fix above.
    3. Remount the whole Tab.Navigator (key it on tabWithoutBarItem) so the host is recreated through removeAllViews, which works. It stops the crash, but every flip resets the nested split navigators' state, and it's only a workaround for the broken removal.
  16. changed the title [-][$175] [Sentry: APP-N5H] HybridApp Android: native tab bar crashes with more than 6 items[/-] [+][$250] [Sentry: APP-N5H] HybridApp Android: native tab bar crashes with more than 6 items[/+] on Oct 23, 2026
  17. abbasifaizan70 commented on Oct 23, 2026

    @abbasifaizan70
    Contributor

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On Android HybridApp the app crashes (fatal, UncaughtExceptionHandler) inside the native tab bar with:

    IllegalArgumentException: Maximum number of items supported by CustomBottomNavigationView is 6
    com.swmansion.rnscreens.gamma.tabs.container.TabsContainer.updateNavigationMenuStructure (TabsContainer.kt:545)
    com.swmansion.rnscreens.gamma.tabs.container.MenuHelpersKt.getOrCreateMenuItemForFragment (MenuHelpers.kt:8)
    androidx.appcompat.view.menu.MenuBuilder.add
    

    The native Material bottom navigation (the new RNScreens "gamma" native tabs introduced with the liquid‑glass bottom bar, #101339) tries to build a 7th menu item, but CustomBottomNavigationView caps the menu at 6. The crash happens on re‑attach of the tab container, which is why it is intermittent.

    What is the root cause of that problem?

    The native tab model (TabsContainer.tabsModel) accumulates a stale tab fragment that was logically removed on the JS side but never detached on Android. Once tabsModel holds 7 fragments, the next re‑attach rebuilds the menu and MenuBuilder.add throws on the 7th item.

    1. The App drives tabs in and out of the native host. The native bar can show at most 5 of the App's 6 tabs, and which tab has no bar item depends on the Insights beta (Insights with it, Account/Settings without it). The App implements this by giving the hidden route no Tabs.Screen at all (react‑navigation patch 003), so when the beta resolves — or a tab route is re‑keyed for a remount — React Navigation removes one Tabs.Screen and adds another on the native host. The hidden route is chosen here:

    function getTabWithoutBarItem(isInsightsBetaEnabled: boolean) {
    return isInsightsBetaEnabled ? NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR : SCREENS.INSIGHTS;
    }

    The six registered tabs and the hidden‑item option that is applied to the swapped‑out tab:

    const HIDDEN_TAB_OPTIONS = {tabBarItemHidden: true};

    <Tab.Screen
    name={SCREENS.HOME}
    component={HomePage}
    />
    <Tab.Screen
    name={NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}
    component={ReportsSplitNavigator}
    />
    <Tab.Screen
    name={NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR}
    component={SearchFullscreenNavigator}
    />
    <Tab.Screen
    name={SCREENS.INSIGHTS}
    component={InsightsPage}
    // The Insights tab button opens the Spend dashboard, so a tap on the native tab lands on it too.
    initialParams={{dashboardID: CONST.INSIGHTS.DASHBOARD.SPEND}}
    />
    <Tab.Screen
    name={NAVIGATORS.WORKSPACE_NAVIGATOR}
    component={WorkspaceNavigator}
    />
    <Tab.Screen
    name={NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR}
    component={SettingsSplitNavigator}
    />

    The TAB_NAVIGATOR is also unmounted/remounted by the root‑stack slicing optimization, which re‑attaches the native host (this is exactly the path in the crashing stack: onAttachedToWindow → flushPendingUpdates → updateNavigationMenuStructure):

    // This is an optimization to keep mounted only last few screens in the stack.
    export default function useCustomRootStackNavigatorState({state}: CustomStateHookProps) {
    const lastSplitIndex = state.routes.findLastIndex((route) => isFullScreenName(route.name));
    let indexToSlice = Math.max(0, lastSplitIndex);
    const hasPrevRoute = lastSplitIndex > 0;
    const isPrevFullScreen = isFullScreenName(state.routes.at(lastSplitIndex - 1)?.name);
    // If the route before the last full screen is e.g. RHP, we should leave it in the rendered routes,
    // as there may be display issues (blank screen) when navigating back and recreating that route to render.
    if (hasPrevRoute && !isPrevFullScreen) {
    indexToSlice = lastSplitIndex - 1;
    }
    const slicedRoutes = state.routes.slice(indexToSlice, state.routes.length);
    const routesToRender = restoreTabNavigatorRoutes(ensureTabNavigatorRoutes(slicedRoutes, indexToSlice, state.routes));
    return {...state, routes: routesToRender, index: routesToRender.length - 1};

    2. RNScreens never removes the old Android tab. On Android, TabsHostViewManager does not override getChildCount/getChildAt, so Fabric sees the TabsHost's raw children (just the single TabsContainer) instead of the logical TabsScreen children it tracks in renderedScreens. When Fabric tries to remove a TabsScreen, its removeViewAt finds no child at that index ("view already removed from parent!") and skips TabsHost.unmountReactSubviewAt, while the matching insert goes through. So renderedScreens and container.tabsModel keep the removed fragment and grow by one on every swap/remount. This exact defect is documented by the App's own react‑navigation patch 004, whose note says the complete fix belongs in RNScreens:

    - Reason: Identifies each native tab by its route name instead of its route key. The App gives the focused tab route a new key on purpose when it pre-inserts a screen under the RHP (`markFocusedTabRouteForRemount` in `GetStateForActionHandlers.ts`, #90985), so the split navigator remounts without a push-transition flash. Keyed by route key, that new key replaced the whole `Tabs.Screen`. Android never removes the old one: `TabsHostViewManager` does not override `getChildAt`/`getChildCount`, so Fabric's `removeViewAt` finds no child at that index ("view already removed from parent!") and skips `TabsHost.unmountReactSubviewAt`, while the insert goes through. The bar then shows the tab twice, and the stale copy opens a blank page. With this patch the native tab (`key`, `screenKey`, `navStateRequest.selectedScreenKey` and the selection events) uses the route name, which stays the same, and only the tab's content is keyed by the route key, so a re-keyed route still remounts its content inside the same native tab. **Remove this patch** once react-native-screens removes Android tabs correctly and React Navigation's native tab view no longer recreates a tab when its route key changes.
    - Upstream PR/issue: not reported yet. The Android removal bug belongs to react-native-screens (`TabsHostViewManager`).

    Patch 004 only neutralized one trigger (the deliberate route‑key change under markFocusedTabRouteForRemount). The hidden↔visible swap driven by getTabWithoutBarItem (and plain TAB_NAVIGATOR remounts) is a different add/remove on the native host, and that removal is still dropped on Android — so fragments still pile up.

    3. The insert guard doesn't catch it; the menu rebuild does. mountReactSubviewAt only guards index < maxItemCount, and inserts always happen at a low index, so the overflow is invisible at insert time. It only surfaces when the container flushes the whole model to the menu. updateNavigationMenuStructure clears the menu and re‑adds one item per fragment in tabsModel, so with 7 fragments the 7th menu.add throws. The native rebuild loop (RNScreens 4.28.0, TabsContainer.kt):

    menu.clear()
    tabsModel.forEach { fragment ->
        menu.getOrCreateMenuItemForFragment(fragment)  // MenuBuilder.add -> throws on #7
    }

    In short: the App legitimately swaps which tab has a bar item, Android fails to detach the swapped‑out TabsScreen, tabsModel creeps from 5 → 6 → 7, and the next re‑attach overflows Material's 6‑item ceiling.

    What changes do you think we should make in order to solve the problem?

    Fix the actual defect — RNScreens not detaching Android tab children — with a new react-native-screens patch, the same mechanism the App already uses for this native tab bar (patches 001‑005 under patches/react-native-screens/). This is the fix the App's patch‑004 note explicitly defers to ("The Android removal bug belongs to react-native-screens (TabsHostViewManager)").

    The TabsHost already tracks its logical children in renderedScreens: ArrayList<TabsScreen> and mutates it in lockstep with the container in mountReactSubviewAt / unmountReactSubviewAt. The only missing piece is making the view manager report those logical children to Fabric, so Fabric can locate a TabsScreen by index and actually route the removal through unmountReactSubviewAt (which detaches it from container.tabsModel). This is exactly how RNScreens' other hosts (ScreenContainer, ScreenStack) override these methods.

    Add a new patch, e.g. patches/react-native-screens/react-native-screens+4.28.0+006+android-remove-stale-tab-children.patch, that:

    1. Exposes the logical children on TabsHost (host/TabsHost.kt) with small internal accessors over the existing renderedScreens list:
    internal fun getRenderedScreenCount(): Int = renderedScreens.size
    
    internal fun getRenderedScreenAt(index: Int): TabsScreen = renderedScreens[index]
    1. Overrides the child‑reconciliation hooks in the view manager (host/TabsHostViewManager.kt) so Fabric reconciles against the TabsScreen children instead of the single container view:
    override fun getChildCount(parent: TabsHost): Int = parent.getRenderedScreenCount()
    
    override fun getChildAt(parent: TabsHost, index: Int): View = parent.getRenderedScreenAt(index)

    With these overrides, when React Navigation removes a TabsScreen (hidden↔visible swap, remount, or re‑key), Fabric finds the child at that index and calls removeViewAt → unmountReactSubviewAt, which removes the fragment from container.tabsModel. tabsModel then stays equal to the real number of rendered tabs (≤ 6), so updateNavigationMenuStructure never attempts a 7th menu.add and the crash cannot occur. Document the new patch in patches/react-native-screens/details.md with the E/App issue and note it can be removed once RNScreens ships the upstream fix.

    This should also be reported upstream to software-mansion/react-native-screens as the TabsHostViewManager missing getChildCount/getChildAt overrides, so the patch can be dropped on a future bump and the related react‑navigation patch 004 can be simplified.

  18. github-actions commented on Oct 23, 2026

    @github-actions
    Contributor

    🤖 ProposalPolice™ is tracking duplicate proposals for this issue using an OpenAI Conversation. This comment stores that Conversation's ID and can be safely ignored.

  19. samranahm commented on Oct 23, 2026

    @samranahm
    Contributor

    Proposal

    What is the root cause of that problem?

    When HybridApp Android opens a screen over the tabs (Sentry sample Settings_Troubleshoot) and ScreenStack later re-attaches the tab container, the app fatals with IllegalArgumentException: Maximum number of items supported by CustomBottomNavigationView is 6. The user is kicked out of the session.

    The App is already on one native Material tab bar. NativeTabBarShadow/index.tsx only paints a non-interactive shadow, useTabRootScreenWrapperProps/index.android.tsx only inserts androidNativeTabBarSpacer, and getFloatingButtonsBottom only offsets FABs with androidNativeTabBarFloatingButtonsBottom. That chrome does not create menu items, and it stays mounted under the stack.

    style={styles.androidNativeTabBarShadow(bottomInset)}
    pointerEvents="none"

    return {};
    }
    return {bottomContent: <View style={styles.androidNativeTabBarSpacer} />};
    }
    export default useTabRootScreenWrapperProps;

    const getFloatingButtonsBottom = (bottomInset: number) => variables.androidNativeTabBarFloatingButtonsBottom + bottomInset;

    App/src/styles/variables.ts

    Lines 138 to 140 in de7716f

    /** Material's bar row (androidNativeTabBarHeight) plus the 16 dp Material keeps between a FAB and the bar. */
    androidNativeTabBarFloatingButtonsBottom: 96,
    /** UITabBar's row above the home indicator inset, plus the gap to the floating buttons. */

    The throw is the re-attach path in the Sentry stack: ScreenStack.onUpdate -> TabsContainer.onAttachedToWindow -> flushPendingUpdates -> updateNavigationMenuStructure -> getOrCreateMenuItemForFragment -> MenuBuilder.add. The bar already holds the original tabs (max 6). Re-attach still adds a menu item for a fragment that is already in the bar, so add runs with size 6 and the 7th item kills the process.

    What changes do you think we should make in order to solve the problem?

    Leave NativeTabBarShadow/index.tsx, androidNativeTabBarSpacer, and getFloatingButtonsBottom unchanged. They only layout the existing bar.

    1. Patch getOrCreateMenuItemForFragment in react-native-screens (MenuHelpers.kt, the frame that calls MenuBuilder.add). Today a re-attach still adds a new item once the fragment is not reused. Refuse the add when the menu is already at CustomBottomNavigationView's max of 6:
    const existingItem = menu.findItem(fragment.menuItemId);
    if (existingItem) {
        return existingItem;
    }
    if (menu.size >= 6) {
        return undefined;
    }
    return menu.add(fragment.menuItemId);

    That stops the fatal at the exact add call in APP-N5H. Duplicate fragments from onAttachedToWindow can no longer push the bar to 7 items.

    Ship it as a new sequenced patch under patches/react-native-screens so HybridApp Android picks it up without changing the JS tab chrome.

    What alternative solutions did you explore? (Optional)

    Make TabsHostViewManager report tab screens from getChildCount / getChildAt so Fabric can unmount stale screens. That targets accumulation, but it does not stop this Sentry path if onAttachedToWindow still adds into a menu that is already full.

    Reminder: Please use plain English, be brief and avoid jargon. Feel free to use images, charts or pseudo-code if necessary. Do not post large multi-line diffs or write walls of text. Do not create PRs unless you have been hired for this job.

  20. mananjadhav commented on Oct 23, 2026

    @mananjadhav
    Collaborator

    @wildan-m's proposal is good to go.

    🎀 👀 🎀 C+ reviewed.

  21. melvin-bot commented on Oct 23, 2026

    @melvin-bot

    Current assignee @mountiny is eligible for the choreEngineerContributorManagement assigner, not assigning anyone new.

  22. yusufdeveloper2903 commented on Oct 23, 2026

    @yusufdeveloper2903
    Contributor

    Proposal

    What is the root cause of that problem?

    The App has six tab routes; the native bar shows five. The tab without a bar item gets no Tabs.Screen, so when that tab changes (the Insights beta loading after launch or being toggled), React removes one Tabs.Screen from the native host and inserts another. Before #104238 a re-keyed focused tab route (markFocusedTabRouteForRemount) did the same; both triggers were live in 9.5.13-0:

    tabBarTintColor: activeTintColor
    },
    children: state.routes.map((route, index) => {
    + if (isRouteHidden(route)) {
    + return null;
    + }

    On Android the removal never reaches react-native-screens. TabsHost keeps its tabs in renderedScreens and hands them to TabsContainer as fragments, so its only real Android child is the container. TabsHostViewManager implements removeViewAt but not getChildAt/getChildCount:

    https://github.com/software-mansion/react-native-screens/blob/42788c197e817ebfe21c0f5d36e0b0e02df4950e/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt#L50-L59

    Fabric checks the child with getChildAt before removing it. The default reads the raw Android children, does not find the tab, logs view already removed from parent! and returns, so TabsHost.unmountReactSubviewAt, which drops the fragment from tabsModel, never runs. Inserts have no such check:

    https://github.com/facebook/react-native/blob/72258ddf7b0b795bbafcbeb5930620b6500c7a06/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.kt#L456-L482

    Each remove + insert leaves one stale fragment. updateNavigationMenuStructure clears the menu and adds one item per tabsModel entry, so the second change makes 7 and MenuBuilder.add throws. While a page covers the tabs the container is detached and the flush waits for onAttachedToWindow, which is why the stack starts in ScreenStack.onUpdate:

    https://github.com/software-mansion/react-native-screens/blob/42788c197e817ebfe21c0f5d36e0b0e02df4950e/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt#L532-L547

    Main now avoids both triggers in JS: the route-name tab key from #104238 (staging 9.5.13-1) and a host remount when the hidden tab changes from #104368 (staging 9.5.16-0, not on production yet). The Android removal itself is unchanged, as the patch 004 note says:

    ### [@react-navigation+bottom-tabs+7.19.2+004+stable-native-tab-identity.patch](@react-navigation+bottom-tabs+7.19.2+004+stable-native-tab-identity.patch)
    - Reason: Identifies each native tab by its route name instead of its route key. The App gives the focused tab route a new key on purpose when it pre-inserts a screen under the RHP (`markFocusedTabRouteForRemount` in `GetStateForActionHandlers.ts`, #90985), so the split navigator remounts without a push-transition flash. Keyed by route key, that new key replaced the whole `Tabs.Screen`. Android never removes the old one: `TabsHostViewManager` does not override `getChildAt`/`getChildCount`, so Fabric's `removeViewAt` finds no child at that index ("view already removed from parent!") and skips `TabsHost.unmountReactSubviewAt`, while the insert goes through. The bar then shows the tab twice, and the stale copy opens a blank page. With this patch the native tab (`key`, `screenKey`, `navStateRequest.selectedScreenKey` and the selection events) uses the route name, which stays the same, and only the tab's content is keyed by the route key, so a re-keyed route still remounts its content inside the same native tab. **Remove this patch** once react-native-screens removes Android tabs correctly and React Navigation's native tab view no longer recreates a tab when its route key changes.
    - Upstream PR/issue: not reported yet. The Android removal bug belongs to react-native-screens (`TabsHostViewManager`).
    - E/App issue: [#104230](https://github.com/Expensify/App/issues/104230), [#104233](https://github.com/Expensify/App/issues/104233)
    - PR Introducing Patch: [#104238](https://github.com/Expensify/App/pull/104238)
    - PR Updating Patch: N/A

    // The native tab host can't drop a tab it already shows: on Android the removed tab keeps its bar item and keeps
    // firing selection events. Remounting the host when the set of tabs without a bar item changes (the Insights beta
    // loading after launch or being toggled) gives it only the tabs it should show.
    const tabsWithoutBarItemKey = state.routes
    .filter((route) => descriptors[route.key]?.options.tabBarItemHidden)
    .map((route) => route.name)
    .join();
    // The tabs stay at the same place in the tree in both layouts, so crossing the breakpoint does not remount them.
    return (
    <View style={[styles.flex1, !shouldUseNarrowLayout && styles.flexRow]}>
    {!shouldUseNarrowLayout && <TabNavigatorBar state={state} />}
    <View
    key={tabsWithoutBarItemKey}
    style={styles.flex1}
    >

    I reproduced it on dev Android with that remount key removed (the JS production 9.5.13-6 onward still ships) and watched logcat:

    • Account tab, Troubleshoot > Beta overrides, insightsPage on: removeViewAt: [2542] -> [2544] @4: view already removed from parent!
    • insightsPage off: removeViewAt: [6324] -> [2544] @3: view already removed from parent!
    • Back to the tabs: Maximum number of items supported by CustomBottomNavigationView is 6 from MenuHelpers.kt:8 ← TabsContainer.kt:545 ← :212 ← onAttachedToWindow :311 ← ScreenStack.onUpdate (ScreenStack.kt:323), the APP-N5H frames.

    On unchanged main the toggles do not crash, but logcat still shows every removal skipped.

    What changes do you think we should make in order to solve the problem?

    Add react-native-screens+4.28.0+007 so TabsHostViewManager reports renderedScreens to Fabric, as the gamma stack host already does:

    https://github.com/software-mansion/react-native-screens/blob/42788c197e817ebfe21c0f5d36e0b0e02df4950e/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackHostViewManager.kt#L52-L57

    -    private val renderedScreens: ArrayList<TabsScreen> = arrayListOf()
    +    internal val renderedScreens: ArrayList<TabsScreen> = arrayListOf()
    ...
    +    override fun getChildAt(parent: TabsHost, index: Int): View? = parent.renderedScreens.getOrNull(index)
    +    override fun getChildCount(parent: TabsHost): Int = parent.renderedScreens.size

    Fabric then reaches unmountReactSubviewAt, and tabsModel matches what JS renders on every remove path. I'd keep the remount and patch 004: TabsContainer.selectedTab is a checkNotNull lookup, so removing the selected tab from a live host still needs them. I have not built the patched APK yet.

    What alternative solutions did you explore? (Optional)

    • Stop adding menu items at 6 in getOrCreateMenuItemForFragment: hides the crash but leaves the stale tab in tabsModel.
  23. nabi-ebrahimi commented on Oct 23, 2026

    @nabi-ebrahimi
    Contributor

    Proposal

    What is the root cause of that problem?

    Android skips native tab removals, so stale tabs accumulate in TabsContainer.tabsModel until rebuilding the menu exceeds its six-item limit.

    TabsHost stores its logical tab children in renderedScreens; its only direct Android child is the internal container. However, TabsHostViewManager implements additions and removals without overriding getChildAt or getChildCount. Fabric therefore inspects the wrong children, cannot find the tab’s tag, and returns before calling removeViewAt. ([TabsHost](https://github.com/software-mansion/react-native-screens/blob/2381ed8d72fe19185fc8415cb61df7b79bbf4376/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt#L32-L64), [view manager](https://github.com/software-mansion/react-native-screens/blob/2381ed8d72fe19185fc8415cb61df7b79bbf4376/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt#L33-L59), [Fabric removal check](https://github.com/react/react-native/blob/a632f9efe24bac8b3a113c78469948f55bde0f5d/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.kt#L453-L505))

    Replacement tabs still reach mountReactSubviewAt, while the old tabs never reach unmountReactSubviewAt. The insertion guard checks the insertion index, not the accumulated count. Starting with five native tabs, two replacements can therefore leave seven entries. ([Mount/unmount implementation](https://github.com/software-mansion/react-native-screens/blob/2381ed8d72fe19185fc8415cb61df7b79bbf4376/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt#L85-L104))

    updateNavigationMenuStructure already clears the menu, then adds one item per model entry. The seventh entry causes the exception. Updates wait while the container is detached and resume in onAttachedToWindow, explaining the reattachment path in the reported stack. Reattachment without accumulated stale tabs does not increase the menu count. ([Menu rebuild](https://github.com/software-mansion/react-native-screens/blob/2381ed8d72fe19185fc8415cb61df7b79bbf4376/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt#L532-L547), [attachment handling](https://github.com/software-mansion/react-native-screens/blob/2381ed8d72fe19185fc8415cb61df7b79bbf4376/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt#L291-L311))

    The App has six routes but renders five native tabs: the hidden-tab patch omits Tabs.Screen for the hidden route. Earlier builds replaced native tabs when a focused route received a new key or the Insights beta changed which tab was hidden. Current main mitigates these triggers with [stable native tab identity](

    ### [@react-navigation+bottom-tabs+7.19.2+004+stable-native-tab-identity.patch](@react-navigation+bottom-tabs+7.19.2+004+stable-native-tab-identity.patch)
    - Reason: Identifies each native tab by its route name instead of its route key. The App gives the focused tab route a new key on purpose when it pre-inserts a screen under the RHP (`markFocusedTabRouteForRemount` in `GetStateForActionHandlers.ts`, #90985), so the split navigator remounts without a push-transition flash. Keyed by route key, that new key replaced the whole `Tabs.Screen`. Android never removes the old one: `TabsHostViewManager` does not override `getChildAt`/`getChildCount`, so Fabric's `removeViewAt` finds no child at that index ("view already removed from parent!") and skips `TabsHost.unmountReactSubviewAt`, while the insert goes through. The bar then shows the tab twice, and the stale copy opens a blank page. With this patch the native tab (`key`, `screenKey`, `navStateRequest.selectedScreenKey` and the selection events) uses the route name, which stays the same, and only the tab's content is keyed by the route key, so a re-keyed route still remounts its content inside the same native tab. **Remove this patch** once react-native-screens removes Android tabs correctly and React Navigation's native tab view no longer recreates a tab when its route key changes.
    - Upstream PR/issue: not reported yet. The Android removal bug belongs to react-native-screens (`TabsHostViewManager`).
    - E/App issue: [#104230](https://github.com/Expensify/App/issues/104230), [#104233](https://github.com/Expensify/App/issues/104233)
    - PR Introducing Patch: [#104238](https://github.com/Expensify/App/pull/104238)
    - PR Updating Patch: N/A
    ) and a [host remount when the hidden tab changes](
    // The native tab host can't drop a tab it already shows: on Android the removed tab keeps its bar item and keeps
    // firing selection events. Remounting the host when the set of tabs without a bar item changes (the Insights beta
    // loading after launch or being toggled) gives it only the tabs it should show.
    const tabsWithoutBarItemKey = state.routes
    .filter((route) => descriptors[route.key]?.options.tabBarItemHidden)
    .map((route) => route.name)
    .join();
    // The tabs stay at the same place in the tree in both layouts, so crossing the breakpoint does not remount them.
    return (
    <View style={[styles.flex1, !shouldUseNarrowLayout && styles.flexRow]}>
    {!shouldUseNarrowLayout && <TabNavigatorBar state={state} />}
    <View
    key={tabsWithoutBarItemKey}
    style={styles.flex1}
    >
    {children}
    ). The native removal defect remains.

    What changes do you think we should make in order to solve the problem?

    Add a sequenced react-native-screens patch that exposes read-only accessors for renderedScreens and overrides TabsHostViewManager.getChildCount and getChildAt to use them. Follow the existing [gamma stack host implementation](https://github.com/software-mansion/react-native-screens/blob/2381ed8d72fe19185fc8415cb61df7b79bbf4376/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackHostViewManager.kt#L52-L57), including nullable lookup for an invalid index.

    Fabric can then find the intended child and execute the existing removal, keeping renderedScreens and tabsModel synchronized with React.

    Keep the current stable-identity patch and host-remount behavior. Correct child reporting alone does not make removing the selected tab safe: selectedTab requires that tab to remain in the model.

    Validate the native boundary with repeated non-selected tab replacements while the host survives, including replacements while detached followed by reattachment. Assert that removed tags disappear and the model/menu retain the expected count. Also verify Android HybridApp expense creation and Insights-beta changes with the existing mitigations retained; mocked JavaScript tab tests cannot exercise Fabric’s removal check.

    What alternative solutions did you explore? (Optional)

    Clearing the menu is already implemented. Capping menu population would leave stale tabs in the model, while rejecting further insertions would merely move the crash earlier. Correcting child reporting addresses the failed removal itself.

  24. bconnnnn commented on Oct 23, 2026

    @bconnnnn

    Proposal

    What is the root cause of that problem?

    The stack trace shows a seventh native menu entry being added despite Android's six-item limit. A duplicate during tab-container reattachment is plausible but not yet reproduced.

    What changes do you think we should make in order to solve the problem?

    Instrument tab IDs and native menu counts during attach/update; determine duplicate insertion versus seven distinct tabs, then reconcile entries correctly instead of suppressing the exception.

    What alternative solutions did you explore? (Optional)

    Avoid a UI-only workaround that masks the symptom without fixing the state or lifecycle problem.

    Validation: Test repeated attach/detach and tab switching, including Settings_Troubleshoot.

    @mananjadhav I'm available if you need a contributor for this issue. I'll wait for approval before opening a PR.

  25. removed
    Help WantedApply this label when an issue is open to proposals by contributors
    on Oct 23, 2026
  26. melvin-bot commented on Oct 23, 2026

    @melvin-bot

    ❌ There was an error making the offer to @wildan-m for the Contributor role. The BZ member will need to manually hire the contributor.

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

Metadata

Metadata

Labels

DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions