Skip to content

Restore the tab pager adapter state after populating the list of tabs and avoid navigating to input screen on fragment restoration#8180

Merged
catalinradoiu merged 13 commits intodevelopfrom
fix/cradoiu/omnibar-unresponsive-on-activity-recreation
Apr 9, 2026
Merged

Restore the tab pager adapter state after populating the list of tabs and avoid navigating to input screen on fragment restoration#8180
catalinradoiu merged 13 commits intodevelopfrom
fix/cradoiu/omnibar-unresponsive-on-activity-recreation

Conversation

@catalinradoiu
Copy link
Copy Markdown
Contributor

@catalinradoiu catalinradoiu commented Apr 1, 2026

Task/Issue URL: https://app.asana.com/1/137249556945/project/414730916066338/task/1213836390603173?focus=true

Description

Fixed 2 issues:

  1. FragmentStateAdapter GC'd restored fragments: In BrowserActivity.initializeTabs(), tabPagerAdapter.restore() was called before the adapter's tabs list was populated. The gcFragments() call inside restoreState() checks containsItem() for each restored fragment, but with tabs empty, every fragment failed this check and was removed. The adapter then created a brand-new fragment via createFragment() with null savedInstanceState.
  2. ViewModel flow re-emitted on recreation: The flowSelectedTab subscription in the ViewModel's init block re-evaluated on every new ViewModel instance. Since the tab still had no URL, the filter passed and LaunchInputScreen was emitted again, causing the loop

Fix:

  1. Populate adapter tabs before restoring state — ensures containsItem() returns true during GC, so restored fragments survive with their savedInstanceState.
  2. Extract the flow subscription to observeSelectedTab(isRestored) — the fragment calls this method, passing whether it was restored from saved state. The first emission is skipped when isRestored is true, preventing the re-launch. The flag resets after the first emission so subsequent tab switches work normally.

Steps to test this PR

Tab Restoration Behavior

  • Enable "Don't keep activities" in Developer Options
  • Enable “Search&Duck.ai” omnibar
  • Go to new tab page
  • Click on the omnibar
  • Click on the menu icon
  • The menu should be opened

UI changes

Before After

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 tabStateRestorationFix that restores TabPagerAdapter state after onTabsUpdated populates the tab list (instead of during initializeTabs), 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 BrowserTabViewModel init into observeSelectedTab(isRestored), called from BrowserTabFragment, and skipping the first emission on restore when the fix is enabled. Also ensures shown fragments’ root views are explicitly made VISIBLE in FragmentStateAdapter.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.

Copy link
Copy Markdown
Contributor Author

@catalinradoiu catalinradoiu changed the title Restore the tab pager adapter state after populating the list of tabs Restore the tab pager adapter state after populating the list of tabs and avoid navigating to input o Apr 1, 2026
@catalinradoiu catalinradoiu changed the title Restore the tab pager adapter state after populating the list of tabs and avoid navigating to input o Restore the tab pager adapter state after populating the list of tabs and avoid navigating to input screen on fragment restoration Apr 1, 2026
@LukasPaczos LukasPaczos self-assigned this Apr 1, 2026
Copy link
Copy Markdown
Contributor

@LukasPaczos LukasPaczos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Enable "Don't Keep Activities".
  2. Create a bunch of tabs (more than 3).
  3. Open Tab Manager, and select one of edge tabs.
  4. 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

Comment thread app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt Outdated
Comment thread app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt Outdated
@catalinradoiu
Copy link
Copy Markdown
Contributor Author

catalinradoiu commented Apr 7, 2026

@LukasPaczos I have further investigated this issue and found out why this happens. We have our custom FragmentStateAdapter, which uses hide()/show() to keep fragments alive instead of removing and recreating them (the default library behavior). Fragments are added without a container ID (tag-only add()), so Fragment.mContainer is null.

The issue involves three pieces of the Fragment framework interacting:

  1. hideFragment sets mHidden = true
    When onViewRecycled calls hideFragment, it runs: mFragmentManager.beginTransaction().hide(fragment).commitAllowingStateLoss();

FragmentManager.hideFragment() sets fragment.mHidden = true. During normal swiping, the view stays VISIBLE, the fragment is just hidden at the FragmentManager level.

  1. Tab switcher triggers a lifecycle transition that sets the view to GONE
    When the tab switcher activity opens the BrowserTabFragment is destroeyd (due to “Don’t keep activities being enabled). When we go back and select one of the tabs at the edge, which has been hiden before due to holder being recycled (and FragmentManager.hideFragment() called), we go through the onCreateView stage (as the fragment view is being createaed), inside FragmentStateManager.createView() which runs:
if (mFragment.mHidden) {
    mFragment.mView.setVisibility(View.GONE);
}

Since mHidden is true, the view is set to GONE. This doesn't happen during normal swiping because the fragment is just hidden and the view is not being destroyed.

  1. show() can't undo the GONE visibility
    When returning from the tab switcher, showFragment calls show(fragment).commitNow(). This sets mHidden = false, but the SpecialEffectsController, which is responsible for calling view.setVisibility(View.VISIBLE) is skipped because mFragment.mContainer is null (fragments are added with a tag only, no container ID):
// FragmentStateManager.java
if (mFragment.mHiddenChanged) {
    if (mFragment.mView != null && mFragment.mContainer != null) {  // skipped — mContainer is null
        SpecialEffectsController controller = ...
        controller.enqueueShow(this);  // never reached — this is what sets VISIBLE
    }
}

Result: mHidden = false but the view is still GONE.

The solution is to explicitly set the view to VISIBLE in showFragment, since FragmentManager cannot do it when mContainer is null:

private void showFragment(long itemId) {
    Fragment fragment = mFragments.get(itemId);
    if (fragment == null) return;

    if (fragment.isAdded() && fragment.isHidden()) {
        mFragmentManager.beginTransaction().show(fragment).commitNow();
    }
    if (fragment.getView() != null && fragment.getView().getVisibility() != View.VISIBLE) {
        fragment.getView().setVisibility(View.VISIBLE);
    }
}

This was not an issue before because gcFragments would remove the framents (as the state was not being handled correctly (the tabs in TabPagerAdapter were not initialised so containsItem(itemId) was false, therefore the fragment was removed).

I will push a commit with the fix.
CC: @0nko

Copy link
Copy Markdown
Contributor

@LukasPaczos LukasPaczos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +902 to +908
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 ->
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@LukasPaczos do you think it makes sense to feature flag this as well?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
        }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@catalinradoiu catalinradoiu marked this pull request as ready for review April 8, 2026 12:07
Comment thread app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt
}

fun observeSelectedTab(isRestored: Boolean) {
val isTabRestorationFixEnabled = androidBrowserConfig.tabStateRestorationFix().isEnabled()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be done on a worker thread.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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());
        }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@0nko
Copy link
Copy Markdown
Member

0nko commented Apr 8, 2026

@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.

Comment thread app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt
@0nko
Copy link
Copy Markdown
Member

0nko commented Apr 9, 2026

@catalinradoiu The changes look good to me. I've tested it and didn't find any issues.

Copy link
Copy Markdown
Member

@0nko 0nko left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

catalinradoiu and others added 2 commits April 9, 2026 11:32
Co-authored-by: Łukasz Paczos <lpaczos@duckduckgo.com>
…-on-activity-recreation' into fix/cradoiu/omnibar-unresponsive-on-activity-recreation
@catalinradoiu
Copy link
Copy Markdown
Contributor Author

@0nko @LukasPaczos I have pushed 2 more commits to make the feature flag enabled by default and move the selectedTabObserver?.cancel() call that was flaged in the comment above. I will merge the PR once the CI passes.

@catalinradoiu catalinradoiu merged commit cc201be into develop Apr 9, 2026
12 of 13 checks passed
@catalinradoiu catalinradoiu deleted the fix/cradoiu/omnibar-unresponsive-on-activity-recreation branch April 9, 2026 09:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants