Skip to content

fix(android, tabs): tolerate null screenKey during NativeTabs initial mount#4044

Open
blackshrub wants to merge 2 commits into
software-mansion:mainfrom
blackshrub:fix/tabs-android-initial-mount-screenkey-race
Open

fix(android, tabs): tolerate null screenKey during NativeTabs initial mount#4044
blackshrub wants to merge 2 commits into
software-mansion:mainfrom
blackshrub:fix/tabs-android-initial-mount-screenkey-race

Conversation

@blackshrub
Copy link
Copy Markdown

Description

TabsContainer on Fabric Android can crash on cold-start with:

IllegalStateException: [RNScreens] screenKey MUST NOT be null

This happens when onAttachedToWindow fires and invalidates 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 throws on the first unbound fragment.

Crash stack:

TabsContainer.onAttachedToWindow
  → TabsContainer.flushPendingUpdates
  → TabsContainer.performContainerUpdate
  → TabsContainer.updateBottomNavigationViewAppearance
  → TabsAppearanceCoordinator.updateTabAppearance      (line 21)
  → TabsContainer.selectedTab                          (line 88)
  → TabsContainer.getFragmentForScreenKey              (line 600)
  → TabsScreenFragment.requireScreenKey                (line 13)
  → TabsScreen.requireScreenKey                        (line 45 → throw)

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 in tabsModel, but its screenKey field hasn't been set yet.

Changes

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.

Test plan

Minimal reproduction (deterministic on Android Fabric, RN 0.83 + new arch + Reanimated 4 + expo-router 55.0.14):

// app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';

export default function Layout() {
  return (
    <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>
  );
}

// app/home.tsx & app/profile.tsx → any simple `<View>` returning component

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 their screenKey, 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

  • Included code example that can be used to test this change.
  • For visual changes, included screenshots / GIFs / recordings documenting the change.
  • Updated relevant documentation. (N/A — internal race fix, no public API change.)
  • Listed all changes done in this PR in the changelog draft. (commit message describes the three call-site changes)
  • Ensured that CI passes. (local Kotlin compile + existing test suite pass; logic-only changes)

@blackshrub
Copy link
Copy Markdown
Author

Pushed a follow-up commit (0f5eead) — the initial guard set covered updateBottomNavigationViewAppearance but field testing on Pixel 9 (Android 16, RN 0.83.6, expo-router 55.0.14, Fabric, Hermes V1) surfaced a second crash path that bypasses the entry 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 in the same onAttachedToWindow window where fragment screenKeys still haven't propagated from JS. That path hits updateTabAppearance → selectedTab without crossing updateBottomNavigationViewAppearance, so guarding only that method leaves the second crash unmitigated.

Moved the guard to the selectedTab getter itself — fall back to the first bound fragment when navState.selectedScreenKey doesn't resolve, throw only when no usable fragment exists. The two helpers (getSelectedTabsScreenFragmentId, getFragmentForScreenKey) and the updateBottomNavigationViewAppearance early-return are kept as belt-and-suspenders.

Same minimal <NativeTabs> repro from the original PR description now starts cleanly on Pixel 9 cold-start.

… 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.
@blackshrub blackshrub force-pushed the fix/tabs-android-initial-mount-screenkey-race branch from 0f5eead to dce8bfe Compare May 16, 2026 12:49
…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.
@blackshrub blackshrub force-pushed the fix/tabs-android-initial-mount-screenkey-race branch from dce8bfe to dd390f5 Compare May 16, 2026 12:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant