software-mansion / software-mansion/react-native-screens

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

Open
#4,518 4 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

platform:android platform:ios repro-provided
Dominant language
TypeScript
Stars
3.7k
Forks
714
Avg merge
2d 23h
Merged PRs (30d)
71

Description

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.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start at the DelayedFreeze implementation, noting that the relevant file has moved to legacy/ on main, and reproduce the issue with the linked rns-stale-freeze-repro app. Trace the freezeState reset across rapid tab navigation and verify that repeated freezeOnBlur transitions deliver activityState = 0 before suspension; done means rapid switching no longer wedges the displayed screen.

Written by the indexing model from the issue text.

Assessment

Tech stack
react-native, typescript
Domain
mobile
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.