Skip to content

freezeOnBlur can freeze a screen in the same commit as its deactivation, leaving tab navigators permanently on the wrong screen #4518

Description

@janicduplessis

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:

  1. A tab blurs; the timer sets freezeState = true; the screen freezes. So far so good.
  2. The tab is refocused: freeze = false, unfrozen immediately, and a setTimeout(0) is scheduled to reset freezeState.
  3. 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

  1. Tap the Echo tab (so it freezes when blurred), then the Alpha tab.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    platform:androidIssue related to Android part of the libraryplatform:iosIssue related to iOS part of the libraryrepro-providedA reproduction with a snack or repo is provided

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions