Skip to content

[Android] Tabs (gamma): fatal "[RNScreens] No selected tab present" — remounting Tabs.Host leaks a detached TabsContainer with empty navState that keeps receiving prop updates #4258

Description

@gustavopiucco

Description

On Android, apps using the gamma native tabs (Tabs.Host / Tabs.Screen, e.g. via expo-router's NativeTabs) crash with a fatal java.lang.IllegalStateException: [RNScreens] No selected tab present whenever a standardAppearance value changes at runtime (for example an in-app light/dark theme switch that re-tints the tab bar).

The root cause is a combination of two problems in gamma/tabs:

1. Remounting Tabs.Host leaks the previous TabsContainer. When the host is remounted (expo-router does this with a React key whenever the set of visible tabs changes — e.g. an auth flow that hides/shows triggers — and apps may also remount with their own key), the old TabsContainer stays alive detached (isAttachedToWindow == false), with an empty navState, and its old-generation TabsScreen views keep receiving Fabric prop updates. Any setStandardAppearance update that reaches one of those stale screens runs TabsContainer.onAppearanceChanged, which dereferences the throwing getter:

internal val selectedTab: TabsScreenFragment
    get() = checkNotNull(getFragmentForScreenKey(navState.selectedScreenKey)) { "[RNScreens] No selected tab present" }

navState.selectedScreenKey is '' on the leaked container, so this always throws.

2. navState can also stay empty on a live container. The initial programmatic selection is applied via bottomNavigationView.setSelectedItemIdWithActionOrigin(...). When the target item is the already-selected first menu item, Material's NavigationBarView treats it as a reselection and never fires onItemSelected, so progressNavigationState never runs and navState remains EMPTY until the user taps a different tab. During that window, any appearance/label value change hits the same throwing getter (onAppearanceChanged at TabsContainer.kt:381 and onMenuItemAttributesChange at TabsContainer.kt:391).

I verified the two-container leak directly by instrumenting onAppearanceChanged on-device. A single theme switch produced callbacks on two TabsContainer instances — a stale detached one with empty navState (crashes) and the live one (works):

RNSDBG: [3] onAppearanceChanged screen=welcome-krRHUmyT... navKey='' model=[welcome-krRHUmyT..., diagnostic-9EdKlXt7..., settings-BpytiDknxe...] attached=false
RNSDBG: [7] onAppearanceChanged screen=welcome-ihnbM_gI... navKey='settings-sg0wKh0mO...' model=[welcome-ihnbM_gI..., diagnostic-eBUPBW..., settings-sg0wKh0mO...] attached=true

Note the stale container [3] holds the previous generation of route keys, is detached, has navKey='' — and still receives the appearance update.

Stack trace:

java.lang.IllegalStateException: [RNScreens] No selected tab present
    at com.swmansion.rnscreens.gamma.tabs.container.TabsContainer.getSelectedTab$react_native_screens_debug(TabsContainer.kt:88)
    at com.swmansion.rnscreens.gamma.tabs.container.TabsContainer.onAppearanceChanged(TabsContainer.kt:381)
    at com.swmansion.rnscreens.gamma.tabs.screen.TabsScreen$special$$inlined$observable$2.afterChange(Delegates.kt:36)
    at kotlin.properties.ObservableProperty.setValue(ObservableProperty.kt)
    at com.swmansion.rnscreens.gamma.tabs.screen.TabsScreen.setAppearance$react_native_screens_debug(TabsScreen.kt:53)
    at com.swmansion.rnscreens.gamma.tabs.screen.TabsScreenViewManager.setStandardAppearance(TabsScreenViewManager.kt:170)
    at com.swmansion.rnscreens.gamma.tabs.screen.TabsScreenViewManager.setStandardAppearance(TabsScreenViewManager.kt:24)
    at com.facebook.react.viewmanagers.RNSTabsScreenAndroidManagerDelegate.setProperty(...)
    at com.facebook.react.uimanager.ViewManager.updateProperties(...)
    at com.facebook.react.fabric.mounting.SurfaceMountingManager.updateProps(...)
    at com.facebook.react.fabric.mounting.mountitems.IntBufferBatchMountItem.execute(...)
    at com.facebook.react.fabric.mounting.MountItemDispatcher.dispatchMountItems(...)
    ...
    at android.os.Looper.loop(Looper.java:397)

Expected behavior: changing tab appearance at runtime must never crash. Prop updates delivered to a stale/detached container (or to a container whose navigation state hasn't progressed yet) should be ignored — not throw.

Proposed fix (verified on-device — crash gone, live container still re-themes correctly): make the two delegate callbacks null-safe instead of using the throwing getter:

override fun onAppearanceChanged(tabsScreen: TabsScreen) {
    if (getFragmentForScreenKey(navState.selectedScreenKey)?.tabsScreen === tabsScreen) {
        invalidationFlags.isNavigationMenuAppearanceInvalidated = true
        post { this.flushPendingUpdates() }
    }
}

override fun onMenuItemAttributesChange(tabsScreen: TabsScreen) {
    getMenuItemForTabsScreen(tabsScreen)?.let { menuItem ->
        val appearance = getFragmentForScreenKey(navState.selectedScreenKey)?.tabsScreen?.appearance
        appearanceCoordinator.updateMenuItemAppearance(themedContext, menuItem, tabsScreen, appearance)
        a11yCoordinator.setA11yPropertiesToTabItem(menuItem, tabsScreen)
    }
}

The deeper fixes would be (a) tearing down / unregistering the old TabsContainer's screen delegates when the host is unmounted so leaked containers stop receiving updates, and (b) progressing navState for the initial selection even when the target menu item is already selected (Material swallows same-item onItemSelected).

Still reproducible reading main / 4.26.0-nightly-20260702 — the relevant code in TabsContainer.kt is unchanged. Reproduces as a host exception in debug; fatal crash in release.

Steps to reproduce

Deterministic in our app:

  1. Render Tabs.Host with several Tabs.Screen children and a standardAppearance derived from an app-level (JS) theme.
  2. Remount the host (change the React key of the host component — this is what expo-router's NativeTabs does automatically whenever the visible tab set changes, e.g. after login when hidden triggers flip).
  3. After the remount, change any standardAppearance value (e.g. switch the app theme so tabBarBackgroundColor / item state colors change).
  4. Fatal IllegalStateException: [RNScreens] No selected tab present.

Variant without remount (problem 2 in the description): mount the host with the initially-selected tab being the first tab, don't tap any other tab, then push a standardAppearance value change → same crash, because navState was never progressed (the initial same-item selection is swallowed by Material's reselection guard).

I can provide a minimal repro repository on request; the essential ingredients are just: gamma Tabs.Host + host remount via React key + a subsequent standardAppearance prop change.

Snack or a link to a repository

https://github.com/gustavopiucco/rnscreens-tabs-repro

Screens version

4.25.2

React Native version

0.86.0

Platforms

Android

JavaScript runtime

Hermes

Workflow

Expo managed workflow

Architecture

Fabric (New Architecture)

Build type

Debug mode

Device

None

Device model

No response

Acknowledgements

Yes

Metadata

Metadata

Assignees

No one assigned

    Labels

    platform:androidIssue related to Android part of the libraryrepro-providedA reproduction with a snack or repo is provided

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions