fix(android, tabs): tolerate null screenKey during NativeTabs initial mount#4044
Conversation
|
Pushed a follow-up commit (
Moved the guard to the Same minimal |
… mount
On Fabric Android, TabsContainer.onAttachedToWindow can fire and
invalidate the navigation menu appearance before setScreenKey has
propagated for every child TabsScreen. The downstream selectedTab
getter calls getFragmentForScreenKey, which iterates tabsModel with
TabsScreenFragment.requireScreenKey and crashes on the first unbound
fragment:
IllegalStateException: [RNScreens] screenKey MUST NOT be null
at TabsScreen.getRequireScreenKey:45
Repro (deterministic on Android Fabric, RN 0.83 + new arch + Reanimated 4):
// app/_layout.tsx (expo-router/unstable-native-tabs)
<NativeTabs>
<NativeTabs.Trigger name="home">
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="profile">
<NativeTabs.Trigger.Icon sf="person.fill" md="person" />
<NativeTabs.Trigger.Label>Profile</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
Cold-start crashes 100% of the time with the stack:
TabsContainer.onAttachedToWindow
-> TabsContainer.flushPendingUpdates
-> TabsContainer.performContainerUpdate
-> TabsContainer.updateBottomNavigationViewAppearance
-> TabsAppearanceCoordinator.updateTabAppearance
-> TabsContainer.selectedTab
-> TabsContainer.getFragmentForScreenKey
-> TabsScreenFragment.requireScreenKey
-> TabsScreen.requireScreenKey (line 45 -> throw)
Three surgical changes to TabsContainer.kt:
1. updateBottomNavigationViewAppearance — early-return if any
fragment's screenKey is still null/blank. The next invalidation
cycle re-enters this method once all fragments have settled, so
deferring is safe.
2. getSelectedTabsScreenFragmentId — use the nullable
tabsScreen.screenKey accessor instead of requireScreenKey;
unbound fragments compare to false and are skipped without
throwing.
3. getFragmentForScreenKey — same nullable-accessor pattern; this
is the lookup in the original crash stack.
Semantics are preserved: fragments without a bound screenKey are not
matchable until they bind, and the next render cycle re-applies the
appearance update once binding completes.
0f5eead to
dce8bfe
Compare
…htUiMode race
The original patch guarded `updateBottomNavigationViewAppearance` and made
the lookup helpers null-safe, but field testing surfaced a SECOND crash
path that bypasses those guards:
IllegalStateException: [RNScreens] No selected tab present
at TabsContainer.getSelectedTab(TabsContainer.kt:88)
at TabsAppearanceCoordinator.updateTabAppearance(TabsAppearanceCoordinator.kt:21)
at TabsContainer.applyDayNightUiMode(TabsContainer.kt:591)
at TabsContainer.onAttachedToWindow$lambda$11(TabsContainer.kt:271)
at ColorSchemeCoordinator.applyResolvedColorScheme(ColorSchemeCoordinator.kt:108)
at ColorSchemeCoordinator.setup$react_native_screens_release(ColorSchemeCoordinator.kt:73)
at TabsContainer.onAttachedToWindow(TabsContainer.kt:270)
ColorSchemeCoordinator.setup fires the day/night callback as part of
the same onAttachedToWindow window where fragment screenKeys haven't
flushed. The callback reaches updateTabAppearance → selectedTab on a
code path that did not previously touch updateBottomNavigationViewAppearance,
so guarding only that method left this crash unmitigated.
Guard at the source instead: make the `selectedTab` getter fall back
to the first bound fragment when navState.selectedScreenKey does not
resolve. Only throw when no usable fragment exists at all — that is the
genuine programmer error the original `checkNotNull` was meant to
catch.
Semantics:
- Happy path (fragments bound) — unchanged: getFragmentForScreenKey
returns the matching fragment.
- Initial-mount race (selectedScreenKey set, no fragment bound yet) —
fallback to first bound fragment; one frame of marginally-wrong
appearance is far cheaper than a cold-start IllegalStateException
that kills the activity.
- Genuine empty state (tabsModel has zero entries) — checkNotNull
still throws with the same diagnostic message.
Verified on Pixel 9 (Android 16) cold-start with the same minimal
NativeTabs reproduction as the original patch: previously crashed
100% of the time with 'No selected tab present', now starts cleanly.
dce8bfe to
dd390f5
Compare
Description
TabsContaineron Fabric Android can crash on cold-start with:This happens when
onAttachedToWindowfires and invalidates the navigation menu appearance beforesetScreenKeyhas propagated for every childTabsScreen. The downstreamselectedTabgetter callsgetFragmentForScreenKey, which iteratestabsModelwithTabsScreenFragment.requireScreenKeyand throws on the first unbound fragment.Crash stack:
The unbound fragment is not a malformed config — it's a timing window. Fabric applies the child views to the tabs container before the prop-setting commit phase that calls
setScreenKey. The fragment is intabsModel, but itsscreenKeyfield hasn't been set yet.Changes
Three surgical changes to
TabsContainer.kt:updateBottomNavigationViewAppearance— early-return if any fragment'sscreenKeyis still null/blank. The next invalidation cycle re-enters this method once all fragments have settled, so deferring is safe.getSelectedTabsScreenFragmentId— use the nullabletabsScreen.screenKeyaccessor instead ofrequireScreenKey. Unbound fragments compare to false and are skipped without throwing.getFragmentForScreenKey— same nullable-accessor pattern; this is the lookup in the original crash stack.Semantics are preserved: fragments without a bound
screenKeyare not matchable until they bind, and the next render cycle re-applies the appearance update once binding completes.Test plan
Minimal reproduction (deterministic on Android Fabric, RN 0.83 + new arch + Reanimated 4 + expo-router 55.0.14):
On
react-native-screens@4.25.0, cold-start crashes 100% of the time with the stack above. With this patch, the appearance update is deferred one render cycle, all fragments bind theirscreenKey, and the tabs container settles normally.Tested on Pixel 7 (Android 14), expo-router 55.0.14, React Native 0.83.6, react-native-reanimated 4.2.1, new architecture (Fabric) enabled, Hermes V1.
Checklist