Skip to content

fix(ScreenContainer): prevent blank-screen flash during rapid tab switching with animation#3874

Open
steve228uk wants to merge 3 commits intosoftware-mansion:mainfrom
steve228uk:main
Open

fix(ScreenContainer): prevent blank-screen flash during rapid tab switching with animation#3874
steve228uk wants to merge 3 commits intosoftware-mansion:mainfrom
steve228uk:main

Conversation

@steve228uk
Copy link
Copy Markdown

@steve228uk steve228uk commented Apr 11, 2026

Description

When detachInactiveScreens is enabled and a tab navigator uses the animation prop, rapidly switching between tabs can produce a brief blank screen on iOS (and potentially Android).

The root cause is a timing race in how activityState is updated. React-navigation drives activityState via an Animated.Value interpolation, so each screen's value updates independently, frame by frame. During rapid tab switching there is a transient window where all screens simultaneously report activityState == 0: the leaving screen's animated value has already dropped below the 1.0 threshold while the arriving screen's value has not yet risen above it. updateContainer / onUpdate was detaching the leaving screen immediately upon seeing activityState == 0, before any other screen became visible — leaving the container empty for one or more frames.

The fix guards detachment of in-tree inactive screens: a screen is only detached when at least one other screen is still active or transitioning, guaranteeing something remains visible until the arriving screen takes over. Screens removed from the React tree entirely (orphaned) are still detached immediately.

Closes #3824

Related upstream report: react-navigation/react-navigation#12755

Changes

  • ios/RNSScreenContainer.mm — added a hasVisibleScreen pre-check in updateContainer before the inactive-screen detach loop
  • android/…/ScreenContainer.kt — same guard added in onUpdate for consistency

Test plan

Steps to reproduce (before this fix):

  1. Create a bottom tab navigator with at least 2 tabs and animation set (e.g. animation="shift" or animation="fade")
  2. Ensure detachInactiveScreens is enabled (it is by default)
  3. Run on a device/simulator
  4. Rapidly tap between tabs — switch before each transition finishes
  5. Observe a blank white screen flash between tabs

After this fix: switching rapidly between tabs no longer produces a blank screen. The leaving screen stays mounted until the arriving screen begins its transition.

Workaround (before this fix): set detachInactiveScreens={false} on the navigator.

No new test files were added. This can be manually verified using the reproduction in react-navigation/react-navigation#12755 or with a minimal snack using @react-navigation/bottom-tabs + animation prop.

Patch

diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.kt
index 7055a0d..210147a 100644
--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.kt
+++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.kt
@@ -386,8 +386,17 @@ open class ScreenContainer(
                         "fragment manager is null when performing update in ScreenContainer"
                     }.fragments,
                 )
+
+            // When activityState is driven by Animated.Value interpolations (as react-navigation
+            // does for tab animations), each screen's value updates independently, frame by frame.
+            // Rapid tab switching can create a transient window where ALL screens are inactive
+            // simultaneously — the leaving screen crossed the threshold while the arriving one
+            // has not yet risen above it. Only detach when at least one screen remains visible.
+            val hasVisibleScreen = screenWrappers.any { getActivityState(it) !== ActivityState.INACTIVE }
+
             for (fragmentWrapper in screenWrappers) {
-                if (getActivityState(fragmentWrapper) === ActivityState.INACTIVE &&
+                if (hasVisibleScreen &&
+                    getActivityState(fragmentWrapper) === ActivityState.INACTIVE &&
                     fragmentWrapper.fragment.isAdded
                 ) {
                     detachScreen(it, fragmentWrapper.fragment)
diff --git a/node_modules/react-native-screens/ios/RNSScreenContainer.mm b/node_modules/react-native-screens/ios/RNSScreenContainer.mm
index ae0fd54..6043105 100644
--- a/node_modules/react-native-screens/ios/RNSScreenContainer.mm
+++ b/node_modules/react-native-screens/ios/RNSScreenContainer.mm
@@ -165,8 +165,23 @@ - (void)updateContainer
   BOOL screenRemoved = NO;
   // remove screens that are no longer active
   NSMutableSet *orphaned = [NSMutableSet setWithSet:_activeScreens];
+
+  // When activityState is driven by Animated.Value interpolations (as react-navigation does
+  // for tab animations), each screen's value updates independently, frame by frame. Rapid tab
+  // switching can create a transient window where ALL screens are inactive simultaneously —
+  // the leaving screen has already crossed the threshold while the arriving screen has not yet
+  // risen above it. Only detach when at least one screen remains visible to avoid a blank flash.
+  BOOL hasVisibleScreen = NO;
+  for (RNSScreenView *screen in _reactSubviews) {
+    if (screen.activityState != RNSActivityStateInactive) {
+      hasVisibleScreen = YES;
+      break;
+    }
+  }
+
   for (RNSScreenView *screen in _reactSubviews) {
-    if (screen.activityState == RNSActivityStateInactive && [_activeScreens containsObject:screen]) {
+    if (hasVisibleScreen && screen.activityState == RNSActivityStateInactive &&
+        [_activeScreens containsObject:screen]) {
       screenRemoved = YES;
       [self detachScreen:screen];
     }

Checklist

  • Included code example that can be used to test this change.
  • For visual changes, included screenshots / GIFs / recordings documenting the change.
  • For API changes, updated relevant public types.
  • Ensured that CI passes

claude and others added 3 commits April 11, 2026 08:53
…nactive

When `activityState` is driven by an `Animated.Value` interpolation
(as react-navigation does for tab animations), each screen's state is
updated frame-by-frame and independently. During rapid tab switching
there is a brief window where ALL screens cross the inactive threshold
simultaneously: the leaving screen's animated value has already dropped
below 1.0 while the arriving screen's has not yet risen above 1.0.

Previously, the leaving screen was detached immediately upon reaching
`activityState == 0`, even if no other screen was active or
transitioning. This left the container with no visible screen for one
or more frames, producing a blank-screen flash — the bug reported in
react-navigation/react-navigation#12755.

The fix: only detach an in-tree inactive screen when at least one other
screen is still active or transitioning, guaranteeing something remains
visible until the arriving screen takes over. Screens that have been
removed from the React tree entirely (orphaned) are always detached
immediately since they can never become active again.

Applied to both iOS (`RNSScreenContainer.mm`) and Android
(`ScreenContainer.kt`) for consistency.

https://claude.ai/code/session_01HSsJnvYwm3gHfeH27CXRsW
…, trim comments

Rename the guard variable to better reflect its intent (something must
remain visible before we detach), and shorten the explanatory comments
to drop the self-evident orphan-handling sentence.

https://claude.ai/code/session_01HSsJnvYwm3gHfeH27CXRsW
…ing-KsQga

fix(ScreenContainer): defer detachment when all screens transiently inactive
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.

(iOS & Android) - Bottom tab blank page glitch (rn81) (animation+detachInactiveScreen)

2 participants