From 8c3c401e4219dfe29fecb6e6090d1b7852c47773 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 08:53:05 +0000 Subject: [PATCH 1/2] fix(ScreenContainer): defer detachment when all screens transiently inactive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../swmansion/rnscreens/ScreenContainer.kt | 16 +++++++++++++- ios/RNSScreenContainer.mm | 22 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.kt index 0fcccb3850..2b15a6c470 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.kt @@ -385,8 +385,22 @@ open class ScreenContainer( "fragment manager is null when performing update in ScreenContainer" }.fragments, ) + + // When activityState is driven by an Animated.Value interpolation (as + // react-navigation does for tab animations), each screen's state updates + // frame-by-frame independently. During rapid tab switching there can be a + // transient window where ALL screens report INACTIVE because the leaving + // screen's value has already crossed the threshold while the arriving + // screen's value has not yet risen above it. Detaching the leaving screen + // in that window leaves no visible screen, producing a blank-screen flash. + // We therefore only detach an in-tree screen when at least one other screen + // is already active or transitioning. Orphaned screens (removed from the + // React tree entirely) are always detached immediately. + val hasActiveScreen = screenWrappers.any { getActivityState(it) !== ActivityState.INACTIVE } + for (fragmentWrapper in screenWrappers) { - if (getActivityState(fragmentWrapper) === ActivityState.INACTIVE && + if (hasActiveScreen && + getActivityState(fragmentWrapper) === ActivityState.INACTIVE && fragmentWrapper.fragment.isAdded ) { detachScreen(it, fragmentWrapper.fragment) diff --git a/ios/RNSScreenContainer.mm b/ios/RNSScreenContainer.mm index 86633d937c..6d6cfc80cd 100644 --- a/ios/RNSScreenContainer.mm +++ b/ios/RNSScreenContainer.mm @@ -160,8 +160,28 @@ - (void)updateContainer BOOL screenRemoved = NO; // remove screens that are no longer active NSMutableSet *orphaned = [NSMutableSet setWithSet:_activeScreens]; + + // When activityState is driven by an Animated.Value interpolation (as react-navigation + // does for tab animations), each screen's state updates frame-by-frame independently. + // During rapid tab switching there is a transient window where ALL screens report + // activityState == 0 because the leaving screen's value has already crossed the + // threshold while the arriving screen's value has not yet risen above it. + // Detaching the leaving screen in that window leaves no visible screen, producing a + // blank-screen flash. We therefore only detach an in-tree screen when at least one + // other screen is already active or transitioning, guaranteeing a screen stays visible. + // Orphaned screens (removed from the React tree entirely) are always detached + // immediately since they can never become active again. + BOOL hasActiveScreen = NO; + for (RNSScreenView *screen in _reactSubviews) { + if (screen.activityState != RNSActivityStateInactive) { + hasActiveScreen = YES; + break; + } + } + for (RNSScreenView *screen in _reactSubviews) { - if (screen.activityState == RNSActivityStateInactive && [_activeScreens containsObject:screen]) { + if (hasActiveScreen && screen.activityState == RNSActivityStateInactive && + [_activeScreens containsObject:screen]) { screenRemoved = YES; [self detachScreen:screen]; } From 48d3e4e22201cc216ea978b7ecc4f1a052617e74 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 08:56:37 +0000 Subject: [PATCH 2/2] refactor(ScreenContainer): rename hasActiveScreen -> hasVisibleScreen, 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 --- .../swmansion/rnscreens/ScreenContainer.kt | 19 +++++++---------- ios/RNSScreenContainer.mm | 21 +++++++------------ 2 files changed, 15 insertions(+), 25 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.kt index 2b15a6c470..c0a6978119 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.kt @@ -386,20 +386,15 @@ open class ScreenContainer( }.fragments, ) - // When activityState is driven by an Animated.Value interpolation (as - // react-navigation does for tab animations), each screen's state updates - // frame-by-frame independently. During rapid tab switching there can be a - // transient window where ALL screens report INACTIVE because the leaving - // screen's value has already crossed the threshold while the arriving - // screen's value has not yet risen above it. Detaching the leaving screen - // in that window leaves no visible screen, producing a blank-screen flash. - // We therefore only detach an in-tree screen when at least one other screen - // is already active or transitioning. Orphaned screens (removed from the - // React tree entirely) are always detached immediately. - val hasActiveScreen = screenWrappers.any { getActivityState(it) !== ActivityState.INACTIVE } + // 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 (hasActiveScreen && + if (hasVisibleScreen && getActivityState(fragmentWrapper) === ActivityState.INACTIVE && fragmentWrapper.fragment.isAdded ) { diff --git a/ios/RNSScreenContainer.mm b/ios/RNSScreenContainer.mm index 6d6cfc80cd..3d51fdeac3 100644 --- a/ios/RNSScreenContainer.mm +++ b/ios/RNSScreenContainer.mm @@ -161,26 +161,21 @@ - (void)updateContainer // remove screens that are no longer active NSMutableSet *orphaned = [NSMutableSet setWithSet:_activeScreens]; - // When activityState is driven by an Animated.Value interpolation (as react-navigation - // does for tab animations), each screen's state updates frame-by-frame independently. - // During rapid tab switching there is a transient window where ALL screens report - // activityState == 0 because the leaving screen's value has already crossed the - // threshold while the arriving screen's value has not yet risen above it. - // Detaching the leaving screen in that window leaves no visible screen, producing a - // blank-screen flash. We therefore only detach an in-tree screen when at least one - // other screen is already active or transitioning, guaranteeing a screen stays visible. - // Orphaned screens (removed from the React tree entirely) are always detached - // immediately since they can never become active again. - BOOL hasActiveScreen = NO; + // 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) { - hasActiveScreen = YES; + hasVisibleScreen = YES; break; } } for (RNSScreenView *screen in _reactSubviews) { - if (hasActiveScreen && screen.activityState == RNSActivityStateInactive && + if (hasVisibleScreen && screen.activityState == RNSActivityStateInactive && [_activeScreens containsObject:screen]) { screenRemoved = YES; [self detachScreen:screen];