Skip to content

fix(android): Stage Nav3 Transactions - #6398

Open
0xadam-brown wants to merge 1 commit into
mainfrom
0xadam-brown/collection/nav3-deferred-transactions
Open

0xadam-brown wants to merge 1 commit into
mainfrom
0xadam-brown/collection/nav3-deferred-transactions

Conversation

@0xadam-brown

@0xadam-brown 0xadam-brown commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Fyi, I wrote the PR description below by hand. It's beefy, but it has some useful background if you want it. (No obligation. It's helpful for my future reference either way.)

PR Stack (Stage Nav3 Transactions)


📜 Description

Makes the multi-Activity and multi-Fragment support introduced by the #6301 PR stack play nicely with ViewPager2.

If you want the tl;dr, start at the "Solution" section below.

💡 Motivation and Context

The previous approach

The #6301 PR stack implemented multi-Activity and multi-Fragment support for our key Nav3 integration API, SentryNavEffect. It did so by introducing a new NavScopes facade and injecting it into the BackStackObserver associated with each SentryNavEffect. Each of those observers converted host back stack changes into write commands against its own NavScopes instance.

The trick of #6301 was to swizzle the destination of those write commands so that we could publish them directly to a shared underlying IScope if their NavScopes instance had an exclusive lease, or shunt them to a staging area if it didn't (for later publication if the instance eventually acquired the lease). The lease is acquired whenever i) an Activity or Fragment initializes or updates the back stack it passes to SentryNavEffect and ii) the current lease holder (if any) isn't in a resumed / visible state.

Combined, those updates allowed us separate navigation timing in the form of host back stack updates from publication of nav data, which saved us from having to drop nav data or let it become stale as the user switched between Activities or Fragments with their own nav subgraphs. Hooray.

The problem with ViewPager2

Note, however, that the approach in #6301 did not stage nav transactions, just nav data (ie, screen name + nav context). A new nav transaction could be started only if the lease was owned at the time of navigation, where "navigation" == initialization or modification of the nav back stack. Because the lease had to be owned, that meant that the Activity or Fragment hosting the SentryNavEffect requesting the lease had to be in a resumed state.

That assumption made sense for most use cases, because usually initialization of an Activity or Fragment's nav back stack coincides with the Activity or Fragment being visible and therefore being able to preempt the lease from the previous leaseholder.

But the assumption didn't pan out for ViewPager2, because ViewPager2 preloads neighboring pages. That means it initializes its nav back stack for Fragments that aren't in a resumed state and therefore can't preempt the lease, causing them to drop their initial nav transaction and any spans created by the initial destination screen. Sigh.

More details about ViewPager2

Unlike most Activity and Fragment architectures, ViewPager2 creates neighboring pages before a user navigates to them (and therefore before they become visible). When ViewPager2 creates a page, it populates the page's SentryNavEffect with an initial nav back stack. (Note that each page gets its own nav back stack, and there's no nav back stack that tracks movements between the ViewPager2 pages.)
.
Pre-population of a page's nav back stack is first chance SentryNavEffect has to try to claim the lease and start a nav transaction. If the page is currently visible, the lease will be acquired, the nav transaction will be published, and all will be well. But if the page is pre-loaded before it's visible, the initial attempt to acquire the lease will fail and the nav transaction will be dropped (at least under the approach from #6301). The user's later swiping to the pre-loaded page won't modify that page's back stack, and so no additional request will be made to start a nav transaction. Oof.

The solution

The solution this PR stack implements is to create a mechanism similar to the data staging from #6301, but for transactions. Doing so lets us separate back stack initialization observed during preloading vs attempted lease acquisition when a ViewPager2 page becomes visible.

In particular, the new StagedNavTransaction class tracks requests to start nav transactions when a SentryNavEffect is created even if it doesn't currently have the lease. If the lease is later acquired and the transaction slot is available on the shared IScope, the staged transaction is started, keeping ViewPager2 happy.

Before and after

 BEFORE #6398 
  ============ 
 
  Page A visible                  Page B preloaded offscreen 
  A holds nav lease               B observes initial destination 
          |                                   | 
          |                         Attempts nav transaction 
          |                                   | 
          |                         No lease -> request declined (page A is still visible, so page B can't preempt the lease)
          |                                   | 
          +---------- swipe to B ------------>| 
                                              | 
                                    B acquires nav lease 
                                    Publishes screen + back stack 
                                              | 
                                    Destination hasn't changed 
                                    No new transaction attempt 
                                              | 
                                    NO B NAV TRANSACTION 
 AFTER #6398 
  =========== 
 
  Page A visible                  Page B preloaded offscreen 
  A holds nav lease               B observes initial destination 
          |                                   | 
          |                         Requests nav transaction 
          |                                   | 
          |                         No lease -> request staged rather than dropped
          |                                   | 
          +---------- swipe to B ------------>| 
                                              | 
                                    B acquires nav lease 
                                    Publishes screen + back stack 
                                              | 
                                    Attempts deferred start to nav transaction
                                              | 
                                +-------------+-------------+ 
                                |                           | 
                          Scope available             Another active 
                                |                   transaction blocks it 
                                v                           | 
                       START B TRANSACTION                  v 
                                                     DISCARD REQUEST 
                                                     No later retry

Returning to a previously active page follows the same rule before and after this PR stack:

  B loses lease -> B's transaction is unbound; timers keep running 
          | 
          +---------- swipe back to B ----------+ 
                                                | 
                                    Did B navigate without 
                                    the lease while away? 
                                                | 
                           +--------------------+--------------------+ 
                           |                                         | 
                          NO                                        YES 
                           |                                         | 
                Existing B transaction?                 BEFORE: request declined 
                           |                            No new transaction on return 
                   +-------+-------+ 
                   |               |                    AFTER: latest request staged 
               Unfinished       Finished                Attempt start on return; 
                   |               |                    discard if blocked 
                   v               v 
                REBIND         NO TRANSACTION 
             existing one        STARTS 

A swipe alone creates no new navigation request. The stack lets a swipe fulfill a request recorded earlier, during preloading or navigation without the lease.

⚠️ Callouts

As shown in the final diagram above, this PR stack implements an approach that starts a nav transaction the first time the user swipes to a ViewPager2 page, but not upon swiping back between already visited pages. (Note: Previous transactions are kept in a idling state, so they can be resumed, but if they expire they won't be restarted.)**

That's contrary to what I had originally proposed here:

My plan (for now) is to start a txn on lease acquisition by replaying the page's last back stack through the observer, so that a page that acquires the lease w/o a txn will be able to start one in the usual way. It'll be timed from when the swipe settles, which for a preloaded page is when the work the user can see actually starts. Chime in if you think that's bonkers (now or when you see the PR). A lot of corner cases in this... :blob-sweat:

In the end I deviated b/c:

  1. I discovered that the current approach (ie, in this PR) matches what we do for ui.load transactions;
  2. it also matches what we do for Nav2;
  3. our goal for SentryNavEffect generally has been parity with Nav2;
  4. the current approach closes the most problematic behavioral gaps;
  5. the bulk of the work in this PR stack is refactoring that we'll want regardless of the policy we choose; and
  6. it's probably preferable to apply a single policy for all nav roots (Activity, Activity-hosted Fragment, or ViewPager2-hosted Fragment) – meaning even if we want Nav3 to diverge from Nav2 and ui.load transactions, we should consider whether we're okay diverging for all Activities and Fragments and not just in the case of ViewPager2.

If folks prefer, I'm happy to fast-follow with an across-the-board policy that starts a new transaction whenever the SentryNavEffect root changes (and not just when SentryNavEffect's back stack is initialized or updated).

** Note: this isn't as bad as it sounds. First, if the ViewPager destroys a previously visited page, then a new transaction will be started for it the next time the user swipes back to it. Second, usually each ViewPager2 page will be the root of a composable nav subgraph. So long as the user navigates inside that page, new transactions will be started. It's just when swiping the ViewPager between already created pages that no transaction is restarted, since the ViewPager2 is bringing existing Fragments back into resumed state without modifying their nav back stacks.

More details The `BackStackObserver` associated with each SentryNavEffect ensures that only nav back stack updates trigger requests to start nav transactions. Creating an Activity or Fragment (or ViewPager2 Fragment) produces a nav back stack "update" when the nav back stack is initialized; resuming a non-resumed Activity or Fragment does not. That leads to two issues:
  1. resuming existing Activities or Fragments, each with their own SentryNavEffect, does not trigger a nav change and therefore doesn't request a new nav transaction or publish new nav data to IScope;
  2. starting nav transactions or re-publishing nav data is something we have to do manually when an existing Activity or Fragment is resumed.

Both #6301 and this PR stack re-publish nav data when an existing Activity or Fragment is resumed so that IScope data doesn't reflect the stale outgoing Activity or Fragment's nav destination. Both also rebind the incoming Activity's or Fragment's transaction. If that transaction hasn't timed out, any spans created by the incoming Activity or Fragment will be parented under the resumed nav transaction.

The question the "across-the-board policy" mentioned above raises is whether we should create a new transaction instead of rebinding the incoming Activity or Fragment's existing transaction. The latter may have expired, whereas the former is guaranteed to be fresh. The downside of a new transaction is that it creates a discontinuous span context.

addresses: JAVA-785

💚 How did you test it?

See the stack PRs.

📝 Checklist

  • I added GH Issue ID & Linear ID
  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.
  • Public API changes reviewed by another Mobile SDK team member or implemented according to the develop docs spec.

🔮 Next steps

#skip-changelog

  • sentry-docs: Update Sentry Android Navigation page to document ViewPager2 support and limitations.
  • If desired, fast-follow with an across-the-board policy that starts a new transaction whenever the SentryNavEffect root changes (and not just when SentryNavEffect's back stack is initialized or updated).

@sentry

sentry Bot commented Oct 27, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
SDK Size io.sentry.tests.size 8.62.0 (1) release

⚙️ sentry-android Build Distribution Settings

@0xadam-brown 0xadam-brown changed the title fix(android): Nav3 Deferred Transactions fix(android): Stage Nav3 Transactions Oct 27, 2026
@0xadam-brown 0xadam-brown added the sanity-check PR needs a lightweight review for obvious issues label Oct 27, 2026
@0xadam-brown
0xadam-brown marked this pull request as ready for review October 27, 2026 23:20

This branch has not been deployed

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

Labels

sanity-check PR needs a lightweight review for obvious issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant