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:
- Render
Tabs.Host with several Tabs.Screen children and a standardAppearance derived from an app-level (JS) theme.
- 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).
- After the remount, change any
standardAppearance value (e.g. switch the app theme so tabBarBackgroundColor / item state colors change).
- 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
Description
On Android, apps using the gamma native tabs (
Tabs.Host/Tabs.Screen, e.g. via expo-router'sNativeTabs) crash with a fataljava.lang.IllegalStateException: [RNScreens] No selected tab presentwhenever astandardAppearancevalue 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.Hostleaks the previousTabsContainer. When the host is remounted (expo-router does this with a Reactkeywhenever the set of visible tabs changes — e.g. an auth flow that hides/shows triggers — and apps may also remount with their ownkey), the oldTabsContainerstays alive detached (isAttachedToWindow == false), with an emptynavState, and its old-generationTabsScreenviews keep receiving Fabric prop updates. AnysetStandardAppearanceupdate that reaches one of those stale screens runsTabsContainer.onAppearanceChanged, which dereferences the throwing getter:navState.selectedScreenKeyis''on the leaked container, so this always throws.2.
navStatecan also stay empty on a live container. The initial programmatic selection is applied viabottomNavigationView.setSelectedItemIdWithActionOrigin(...). When the target item is the already-selected first menu item, Material'sNavigationBarViewtreats it as a reselection and never firesonItemSelected, soprogressNavigationStatenever runs andnavStateremainsEMPTYuntil the user taps a different tab. During that window, any appearance/label value change hits the same throwing getter (onAppearanceChangedatTabsContainer.kt:381andonMenuItemAttributesChangeatTabsContainer.kt:391).I verified the two-container leak directly by instrumenting
onAppearanceChangedon-device. A single theme switch produced callbacks on twoTabsContainerinstances — a stale detached one with empty navState (crashes) and the live one (works):Note the stale container
[3]holds the previous generation of route keys, is detached, hasnavKey=''— and still receives the appearance update.Stack trace:
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:
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) progressingnavStatefor the initial selection even when the target menu item is already selected (Material swallows same-itemonItemSelected).Still reproducible reading
main/4.26.0-nightly-20260702— the relevant code inTabsContainer.ktis unchanged. Reproduces as a host exception in debug; fatal crash in release.Steps to reproduce
Deterministic in our app:
Tabs.Hostwith severalTabs.Screenchildren and astandardAppearancederived from an app-level (JS) theme.keyof the host component — this is what expo-router'sNativeTabsdoes automatically whenever the visible tab set changes, e.g. after login when hidden triggers flip).standardAppearancevalue (e.g. switch the app theme sotabBarBackgroundColor/ item state colors change).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
standardAppearancevalue change → same crash, becausenavStatewas 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 Reactkey+ a subsequentstandardAppearanceprop 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