Skip to content

feat(navigation): hold tab state outside the back stack - #1379

Merged
bmc08gt merged 2 commits into
code/cashfrom
feat/tab-state-retention
Aug 31, 2026
Merged

feat(navigation): hold tab state outside the back stack#1379
bmc08gt merged 2 commits into
code/cashfrom
feat/tab-state-retention

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

A tab press clears the whole backstack, so every tab was rebuilt from nothing on every press. Nav3 gives each entry its own ViewModelStore and saveable state, and both die with the entry, so returning to the wallet meant a cold ViewModel behind its loading spinner. That is what made the scanner-to-wallet switch after a claim read as a reload rather than a tab change.

The obvious fix is to keep the tab homes on the stack, and it costs more than it looks. NavDisplay enables back on scene.previousEntries.isNotEmpty() (NavDisplay.kt:557), so an entry left on the stack for state reasons is also an entry back stops at — back would walk the tabs the user had visited before leaving the app. Swallowing the pop doesn't help either; the predictive-back preview still runs and snaps back.

So the state is held off the stack instead. rememberViewModelStoreOwner documents its store as outliving the composition that used it and being destroyed only by an explicit clearKey, and a NavEntryDecorator's onPop is where Nav3's own decorators make that call. RetainedEntryState is that pair of decorators with clearKey and SaveableStateHolder.removeState skipped for the content keys the host names.

The host names the four routes a tab press produces, so a variant of a tab route — a resumed Tips, say — still gets a fresh screen rather than being held forever. Everything else behaves as it did: a tab press still clears the stack, back still leaves the app from any tab, pushed screens and sheets are still dropped with the tab they were on, and deeplinks still arrive through navigateAll.

Releasing

Held state is dropped once the back stack holds no tab route at all — signing out — so one account's ViewModels aren't waiting for whoever signs in next. A push keeps its tab home on the stack, so this only fires on a replaceAll away from the tabs.

The ordering is the difficult part. A pop is reported only after the entry's content leaves composition, which lands it a transition later than the back stack change that caused it, so the release routinely runs before the pops it is meant to discard. RetentionLedger marks itself released rather than only emptying, and those late pops clear instead of being held for the next account.

Tests

RetentionLedgerTest covers the ledger as a pure function: retained keys survive their pop and unretained ones don't, release drops what it holds exactly once, repeated switching between two tabs holds one entry each, a pop arriving after a release is neither retained nor held for a later release, and a retained key rendering again resumes retention.

A tab press cleared the whole backstack, so every tab was rebuilt from nothing on
every press. Nav3 gives each entry its own ViewModelStore and saveable state, and
both die with the entry, so returning to the wallet meant a cold ViewModel behind
its loading spinner — which is what made the scanner-to-wallet switch after a
claim read as a reload rather than a tab change.

Tab homes now stay on the stack and the target moves to the top. Its entry is the
one already there, so the tab comes back with its ViewModels, rememberSaveable
state and list scroll position intact. Everything that is not a tab home is still
dropped: the screens pushed on the outgoing tab and any sheet over them, which
clearing always discarded and which would otherwise sit under the tab being
opened.

Back walks the visited tabs before it leaves the app, rather than leaving from
whichever tab is showing. That is the cost of the retention — an entry has to be
on the stack to survive — and it is not avoidable by refusing to pop: NavDisplay
enables predictive back on stack depth alone, so a swallowed pop would show a
seek preview and snap back.

Two readers assumed the old shape. The nav bar's selection walked the stack from
the bottom, which is now the tab visited first rather than the tab showing, and
the scanner ran its camera only when it was the only entry on the stack, which
never held again. Both now ask what is on top. MainRoot compares its launch graph
from the active tab up, so the retained tabs beneath it don't read as a mismatch
and reset the user to the launch route.

Deeplinks are unaffected — they still arrive through navigateAll, which clears. A
link is an entry point into the app rather than a move between tabs.
@bmc08gt bmc08gt self-assigned this Aug 31, 2026
@github-actions github-actions Bot added type: feature New functionality area: scanner QR/Kikcode scanning, camera labels Aug 31, 2026
Keeping the tab homes on the stack, as the previous commit did, made back walk
the tabs the user had visited before it left the app. That was the price of that
retention, and it is not worth paying: NavDisplay enables back on
`scene.previousEntries.isNotEmpty()`, so an entry left on the stack for state
reasons is also an entry back stops at, and swallowing the pop only buys a
predictive-back preview that snaps back.

The state does not have to be on the stack. `rememberViewModelStoreOwner`
documents its store as outliving the composition that used it, destroyed only by
an explicit `clearKey`, and a `NavEntryDecorator`'s `onPop` is where Nav3's own
decorators make that call. RetainedEntryState is that pair of decorators with
`clearKey` and `SaveableStateHolder.removeState` skipped for the content keys the
host names — here the four routes a tab press produces. A tab press still clears
the stack, so back leaves the app from any tab exactly as before, and the tab
still comes back to its ViewModels, rememberSaveable state and scroll position.

Held state is dropped once the back stack holds no tab route, so a signed-out
account's ViewModels are not waiting for whoever signs in next. A pop is reported
only after the entry's content leaves composition, which lands it a transition
later than the back stack change that caused it — meaning the release routinely
runs before the pops it means to discard. RetentionLedger marks itself released
rather than only emptying, so those late pops clear too.

The stack shape is unchanged, so the tab-switch helper and the three readers the
previous commit had to adjust are gone with it.
@bmc08gt bmc08gt changed the title feat(navigation): keep tabs alive across a tab switch feat(navigation): hold tab state outside the back stack Aug 31, 2026
@github-actions github-actions Bot added the area: ui Compose UI, theme, components, resources label Aug 31, 2026
@bmc08gt
bmc08gt merged commit 2847b9d into code/cash Aug 31, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: scanner QR/Kikcode scanning, camera area: ui Compose UI, theme, components, resources type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant