Description
With freezeOnBlur, a screen can freeze in the same commit as its deactivation. The suspended subtree never delivers activityState = 0 to the native side, so more than one screen ends up reporting RNSActivityStateOnTop, and a tab navigator permanently renders the wrong screen. The tab bar keeps tracking the selection; the content does not, and it never recovers.
We hit this in production behind bottom tabs: switching tabs quickly would wedge the app on one tab's content regardless of the selection.
Root cause
DelayedFreeze exists to give a screen one unfrozen render so activityState = 0 reaches the native side before the subtree suspends. But freezeState is stale across freeze cycles, and the reset is scheduled with setTimeout(0):
const [freezeState, setFreezeState] = React.useState(false);
React.useEffect(() => {
const id = setTimeout(() => setFreezeState(freeze), 0);
return () => clearTimeout(id);
}, [freeze]);
return <Freeze freeze={freeze ? freezeState : false}>{children}</Freeze>;
Sequence:
- A tab blurs; the timer sets
freezeState = true; the screen freezes. So far so good.
- The tab is refocused:
freeze = false, unfrozen immediately, and a setTimeout(0) is scheduled to reset freezeState.
- The tab blurs again before that timer fires. The effect cleanup clears the reset, and the new render is
freeze = true, freezeState = true — frozen immediately, with no grace render, in the same commit as activityState going 2 -> 0.
react-freeze suspends the subtree, so the previous committed tree (activityState = 2) is retained and the deactivation never reaches the host component. Instrumenting updateProps on RNSScreenView shows the imbalance directly: the stuck screen received 4 deliveries of 2.0 but only 3 of 0.0, while every other screen was balanced. Native then has two screens claiming OnTop, and RNSScreenNavigationContainer.updateContainer — whose comment says that should never happen — loops without breaking, so the last stale screen in subview order wins.
This happens organically when two tab presses are processed back to back (JS thread busy), which is why it shows up in heavy apps and is hard to reproduce in a toy one.
Steps to reproduce
Deterministic, one button press: https://github.com/janicduplessis/rns-stale-freeze-repro
- Tap the Echo tab (so it freezes when blurred), then the Alpha tab.
- Press Trigger bug, which refocuses the frozen tab and blurs it again in one task:
navigation.navigate('Echo');
queueMicrotask(() => navigation.navigate('Alpha'));
The second navigation renders before the setTimeout(0) reset can fire, so it reproduces every time. Result: tab bar shows Alpha selected, Echo's screen is displayed, and tapping tabs moves the selection but never the content.
Fix
Resetting the stale flag synchronously closes the hole — the grace render is then guaranteed on every freeze:
const [freezeState, setFreezeState] = React.useState(false);
if (!freeze && freezeState) {
setFreezeState(false);
}
Verified in the repro app (deterministic wedge -> gone) and in the production app where we found it (scripted rapid tab switching: stuck 2/4 runs before, 0/6 after, control re-confirmed after revert). Happy to open a PR — the file has moved to legacy/ on main, so let me know the right target.
It may also be worth hardening RNSScreenNavigationContainer.updateContainer against multiple OnTop screens (it currently calls setViewControllers once per claimant, last one wins); picking the most recently activated screen kept the container correct in our testing even with the desync present.
Snack or a link to a repository
https://github.com/janicduplessis/rns-stale-freeze-repro
Screens version
4.26.2
React Native version
0.86.2 (New Architecture)
Platforms
iOS (reproduced; Android not tested)
Device
iOS 26.5 simulator, iPhone 17 Pro
Additional context
Reproduced with @react-navigation/bottom-tabs 6.5.20 and 7.18.16. Likely related reports: #3824 (blank tab flashes with detach + animation) and react-navigation/react-navigation#12755. Either freezeOnBlur: false or detachInactiveScreens={false} masks the bug, which fits the mechanism: no suspension, or nothing to desync.
Description
With
freezeOnBlur, a screen can freeze in the same commit as its deactivation. The suspended subtree never deliversactivityState = 0to the native side, so more than one screen ends up reportingRNSActivityStateOnTop, and a tab navigator permanently renders the wrong screen. The tab bar keeps tracking the selection; the content does not, and it never recovers.We hit this in production behind bottom tabs: switching tabs quickly would wedge the app on one tab's content regardless of the selection.
Root cause
DelayedFreezeexists to give a screen one unfrozen render soactivityState = 0reaches the native side before the subtree suspends. ButfreezeStateis stale across freeze cycles, and the reset is scheduled withsetTimeout(0):Sequence:
freezeState = true; the screen freezes. So far so good.freeze = false, unfrozen immediately, and asetTimeout(0)is scheduled to resetfreezeState.freeze = true, freezeState = true— frozen immediately, with no grace render, in the same commit asactivityStategoing2 -> 0.react-freezesuspends the subtree, so the previous committed tree (activityState = 2) is retained and the deactivation never reaches the host component. InstrumentingupdatePropsonRNSScreenViewshows the imbalance directly: the stuck screen received 4 deliveries of2.0but only 3 of0.0, while every other screen was balanced. Native then has two screens claimingOnTop, andRNSScreenNavigationContainer.updateContainer— whose comment says that should never happen — loops without breaking, so the last stale screen in subview order wins.This happens organically when two tab presses are processed back to back (JS thread busy), which is why it shows up in heavy apps and is hard to reproduce in a toy one.
Steps to reproduce
Deterministic, one button press: https://github.com/janicduplessis/rns-stale-freeze-repro
The second navigation renders before the
setTimeout(0)reset can fire, so it reproduces every time. Result: tab bar shows Alpha selected, Echo's screen is displayed, and tapping tabs moves the selection but never the content.Fix
Resetting the stale flag synchronously closes the hole — the grace render is then guaranteed on every freeze:
Verified in the repro app (deterministic wedge -> gone) and in the production app where we found it (scripted rapid tab switching: stuck 2/4 runs before, 0/6 after, control re-confirmed after revert). Happy to open a PR — the file has moved to
legacy/onmain, so let me know the right target.It may also be worth hardening
RNSScreenNavigationContainer.updateContaineragainst multipleOnTopscreens (it currently callssetViewControllersonce per claimant, last one wins); picking the most recently activated screen kept the container correct in our testing even with the desync present.Snack or a link to a repository
https://github.com/janicduplessis/rns-stale-freeze-repro
Screens version
4.26.2
React Native version
0.86.2 (New Architecture)
Platforms
iOS (reproduced; Android not tested)
Device
iOS 26.5 simulator, iPhone 17 Pro
Additional context
Reproduced with @react-navigation/bottom-tabs 6.5.20 and 7.18.16. Likely related reports: #3824 (blank tab flashes with detach + animation) and react-navigation/react-navigation#12755. Either
freezeOnBlur: falseordetachInactiveScreens={false}masks the bug, which fits the mechanism: no suspension, or nothing to desync.