Restore the tab pager adapter state after populating the list of tabs and avoid navigating to input screen on fragment restoration#8180
Conversation
…sers being stuck on the input screen if the browser activity was destroyed by the system
This stack of pull requests is managed by Graphite. Learn more about stacking. |
LukasPaczos
left a comment
There was a problem hiding this comment.
Generally, the change looks good to me and seem to get the job done well! Good find and I think we can continue in this direction. Let's make sure to feature flag it though.
One issue I was able to reproduce:
- Enable "Don't Keep Activities".
- Create a bunch of tabs (more than 3).
- Open Tab Manager, and select one of edge tabs.
- I can reproduce nearly every time that if start swiping tabs, when I go beyond what would be typically cached, the tabs are broken (completely blank).
Screen_recording_20260401_160242.mp4
I'm not certain it's related to changes from this PR but I can't reproduce on main (with Input Screen disable of course, impossible otherwise due to the original issue). cc @0nko in case you have any ideas
|
@LukasPaczos I have further investigated this issue and found out why this happens. We have our custom The issue involves three pieces of the Fragment framework interacting:
Since
Result: The solution is to explicitly set the view to This was not an issue before because I will push a commit with the fix. |
LukasPaczos
left a comment
There was a problem hiding this comment.
Works as expected! Adding one suggestion below and let's also include the feature flag.
Also tagging @0nko to double-check the fragment management changes.
| fun observeSelectedTab(isRestored: Boolean) { | ||
| isTabRestored = isRestored | ||
| selectedTabObserver?.cancel() | ||
| // auto-launch input screen for new, empty tabs (New Tab Page) | ||
| selectedTabObserver = tabRepository.flowSelectedTab | ||
| .distinctUntilChangedBy { selectedTab -> selectedTab?.tabId } // only observe when the tab changes and ignore further updates | ||
| .filter { selectedTab -> |
There was a problem hiding this comment.
| fun observeSelectedTab(isRestored: Boolean) { | |
| isTabRestored = isRestored | |
| selectedTabObserver?.cancel() | |
| // auto-launch input screen for new, empty tabs (New Tab Page) | |
| selectedTabObserver = tabRepository.flowSelectedTab | |
| .distinctUntilChangedBy { selectedTab -> selectedTab?.tabId } // only observe when the tab changes and ignore further updates | |
| .filter { selectedTab -> | |
| fun observeSelectedTab(isRestored: Boolean) { | |
| selectedTabObserver?.cancel() | |
| // auto-launch input screen for new, empty tabs (New Tab Page) | |
| selectedTabObserver = tabRepository.flowSelectedTab | |
| .distinctUntilChangedBy { selectedTab -> selectedTab?.tabId } // only observe when the tab changes and ignore further updates | |
| .let { | |
| if (isRestored) { | |
| // Skip the first emission on restore to avoid a loop | |
| // where closing InputScreenActivity would return to a restored NTP, | |
| // which would restart this flow and reopen InputScreenActivity again. | |
| // This happens when memory pressure destroys BrowserActivity while | |
| // InputScreenActivity is in the foreground. | |
| it.drop(1) | |
| } else { | |
| it | |
| } | |
| } | |
| .filter { selectedTab -> |
Wouldn't this do the same job without having to add a class field? I'm just worried that the field will be accessible across the class while being only usable within this specific function body, so it becomes noise for everything else.
There was a problem hiding this comment.
Done, looks much better then using the local field 👍
| if (fragment.isAdded() && fragment.isHidden()) { | ||
| mFragmentManager.beginTransaction().show(fragment).commitNow(); | ||
| } | ||
| if (fragment.getView() != null && fragment.getView().getVisibility() != View.VISIBLE) { |
There was a problem hiding this comment.
@LukasPaczos do you think it makes sense to feature flag this as well?
There was a problem hiding this comment.
This looks relatively safe to me.
I'd only change it to work on a local ref to avoid any race conditions that could lead to NPE. This all in theory runs on the main thread, but some defensive programming shouldn't hurt.
View fragmentView = fragment.getView();
if (fragmentView != null && fragmentView.getVisibility() != View.VISIBLE) {
fragmentView.setVisibility(View.VISIBLE);
}| } | ||
|
|
||
| fun observeSelectedTab(isRestored: Boolean) { | ||
| val isTabRestorationFixEnabled = androidBrowserConfig.tabStateRestorationFix().isEnabled() |
There was a problem hiding this comment.
This should be done on a worker thread.
There was a problem hiding this comment.
I think I will need some kind of synchronization mechanism in this case. I can move that to the coroutine but I just figured out that I need to do the same for the BrowserActivity. However I will need to get the value of the FF first before initialising the tabs (if using a coroutine), otherwise we could end up restoring the state after the fragments are initialised in the adapter, which will crash the app:
public final void restoreState(@NonNull Parcelable savedState) {
if (!mFragments.isEmpty()) {
throw new IllegalStateException(
"Expected the adapter to be 'fresh' while restoring state.");
}
Bundle bundle = (Bundle) savedState;
if (bundle.getClassLoader() == null) {
/* TODO(b/133752041): pass the class loader from {@link ViewPager2.SavedState } */
bundle.setClassLoader(getClass().getClassLoader());
}
There was a problem hiding this comment.
I had to do a change inside BrowserActivity, since initializeTabs() was calling androidBrowserConfigFeature.tabStateRestorationFix().isEnabled() directly on the main thread.
Additionally, configureFlowCollectors() was called separately from initializeTabs(). The flow collectors invoke tabManager.onTabsChanged(), which relies on the onTabsUpdated callback — but that callback is only registered inside initializeTabs() via tabManager.registerCallbacks(). If the flow emitted before registerCallbacks() was called, it would crash on an uninitialized callback.
The toggle check is now performed off the main thread (withContext(dispatcherProvider.io())). The result must be resolved before proceeding because both onTabsUpdated (which receives it as a parameter) and the tab state restoration logic in initializeTabs depend on this flag.
@0nko can you please take another look as well here?
|
@catalinradoiu Really great investigation! 👏 Must not have been easy to pinpoint the culprit behind the behavior.. I've looked over the changes and don't see any problems, aside from the BugBot comment, which I think is legitimate. |
… to the io dispatcher and fix operator ordering issue
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 6dfb2f5. Configure here.
|
@catalinradoiu The changes look good to me. I've tested it and didn't find any issues. |
Co-authored-by: Łukasz Paczos <lpaczos@duckduckgo.com>
…-on-activity-recreation' into fix/cradoiu/omnibar-unresponsive-on-activity-recreation
|
@0nko @LukasPaczos I have pushed 2 more commits to make the feature flag enabled by default and move the |


Task/Issue URL: https://app.asana.com/1/137249556945/project/414730916066338/task/1213836390603173?focus=true
Description
Fixed 2 issues:
Fix:
Steps to test this PR
Tab Restoration Behavior
UI changes
Note
Medium Risk
Touches tab restoration and lifecycle-driven flow collection in core browsing UI; ordering/remote-config gating changes could affect tab switching or state restore on low-memory/rotation scenarios.
Overview
Fixes swiping-tabs state restoration by adding a remote-configed
tabStateRestorationFixthat restoresTabPagerAdapterstate afteronTabsUpdatedpopulates the tab list (instead of duringinitializeTabs), avoiding restored fragments being GC’d; state is removed from the bundle after applying.Prevents an Input Screen relaunch loop on fragment/viewmodel recreation by moving the selected-tab flow logic out of
BrowserTabViewModelinit intoobserveSelectedTab(isRestored), called fromBrowserTabFragment, and skipping the first emission on restore when the fix is enabled. Also ensures shown fragments’ root views are explicitly madeVISIBLEinFragmentStateAdapter.showFragment, and updates/adds unit tests for the new behavior.Reviewed by Cursor Bugbot for commit 25f60d2. Bugbot is set up for automated code reviews on this repo. Configure here.