ADR-0023: Retained ViewModel scope ownership
- Status: Accepted
- Date: 2026-08-29
Context
Before this decision, ViewCompose resolved Activity-, Fragment-, navigation-entry-, and navigation-
graph-scoped ViewModels but had no general child-scope facility for Pager pages, tabs, lazy items,
overlays, or application containers. Navigation compensated with a specialized
NavEntryOwnerStore, while viewModel() also remembered the resolved instance in composition even
though ViewModelStore already owned that identity. The standalone savedStateHandle() helper
added a second holder model instead of using ViewModel construction and CreationExtras.
AndroidX Lifecycle 2.11 adds
ViewModelStoreProvider,
whose child stores survive configuration changes through a parent store and whose reference tokens
defer terminal clearing during exit animation or another temporary consumer. Its low-level contract
does not know whether a ViewCompose candidate committed or rolled back, whether a render absence is
temporary, or which navigation event is terminal. Copying its Compose adapter would therefore be
insufficient for ViewCompose's prepared-composition, delayed-session, and retained-stack model.
Decision
One store and one scoped-provider core
- Upgrade the executable AndroidX Lifecycle baseline to 2.11.
ViewModelStoreProvideris the sole child-store allocation and reference-counting primitive. ViewCompose does not implement a parallel child-store map. ViewModelStoreis the sole cache of ViewModel instances.viewModel()performs a provider lookup on every executed composition call and never remembers a resolved ViewModel instance.- The stable capability identity for the new public family is
viewmodel.scoped-owners. Its capability record is added with the first declarations because governance records describe the current compiled inventory and cannot be pre-created. - The module-owned
ViewModelScopeProviderwraps the AndroidX provider with ViewCompose commit, rollback, no-resurrection, and terminal-disposal state.ViewModelStoreOwnerLeaseis the core reference-owning handle used by navigation and custom retained containers. Closing a lease ends one use; it does not by itself declare the logical scope permanently removed.
The wrapper namespaces provider and child identities before passing them to AndroidX. Private
provider and child metadata ViewModels live in AndroidX-owned marker and child stores, so commit,
terminal, and no-resurrection state survives recreation of the facade without introducing a second
child-store map. Metadata keeps only weak references to active lifecycle and saved-state owners and
releases those references when the last lease closes. AndroidX remains the sole allocator and
reference counter for child stores.
5. rememberViewModelScopeProvider is the composition adapter. It binds provider lifetime to a
retained parent ViewModelStoreOwner, a parent LifecycleOwner, and a caller-supplied stable
provider key. rememberViewModelStoreOwner is the child adapter. Existing
ProvideViewModelStoreOwner remains the only local-publication API.
6. The core is shared, while scenario adapters remain separate:
ordinary DSL content remembers an owner and publishes it; navigation acquires leases and drives
entry/graph lifecycle plus terminal clear; a custom retained container acquires and closes leases
directly. Pager, tabs, overlays, and lazy content do not receive parallel provider APIs.
Identity, reference, and removal protocol
- Provider and child keys are non-null stable values supplied by the caller or owning container. Call position, collection position, incrementing counters, and referentially unstable objects are not durable identities. ViewCompose deliberately exposes no automatic-position overload for a retained provider.
- Equal provider keys in the same parent store share provider state. Equal child keys share one owner only inside that provider. Equal child keys under different provider keys remain isolated.
- Preparing a composition binding acquires a reference before application code can use the owner. Commit makes the binding durable. Abort releases the candidate reference and clears a scope that was created only by the failed candidate; it must not clear a previously committed scope or consume its restored state.
- Temporary render absence closes references but does not request removal. The owning container
calls
clear(key)exactly once when the logical destination, item, page, tab, or overlay is permanently removed. Active leases defer the underlying clear; after removal is requested, a new lease for that identity fails until the final old lease closes. Reusing the key afterward creates a fresh scope rather than resurrecting the removed store. - Normal provider-subtree removal while its parent lifecycle is not
DESTROYEDrequests provider-wide terminal cleanup, including removal before the parent reachesCREATED. Disposal while the parent isDESTROYEDdoes not request that cleanup: configuration recreation must recover shared provider state, while a finishing parent clears its own store. Clearing the parent store remains the final safety boundary. - Provider creation, lease operations, ViewModel lookup, and clear operations are Android-main- thread confined. They perform bounded in-memory map, provider, and reference-count operations; they do no I/O, blocking, scheduling, or global discovery.
Factory, extras, saved state, and lifecycle
- Child owners inherit the parent Factory and initial
CreationExtrasunless explicitly overridden when the provider is first created. Scoped default arguments take precedence over default arguments already present in the extras, following AndroidX. Later recomposition does not mutate an existing provider's creation policy. - Saved-state support is enabled only when the child owner delegates to a valid
SavedStateRegistryOwner. Standard combined Activity, Fragment, navigation, and Preview owners resolve naturally; a custom boundary with separate owners must pass the saved-state owner explicitly. - A provider requires a parent
LifecycleOwner, normally the same object as the parent store owner. A custom split boundary passes it explicitly. Missing, already-invalid, or inconsistent boundaries fail directly instead of falling back to an Activity, static registry, or root store. - Navigation continues to own route arguments, entry and graph
LifecycleRegistrytransitions, saved-state registry namespaces, transition retention, stack retention, and terminal pop. It obtains destination and graph stores fromViewModelScopeProviderand deletes its independent store-allocation policy after equivalent tests pass.
ViewModel creation and state interoperability
- Only
nullselects AndroidX's class-derived ViewModel key. Every non-null string, including empty and whitespace-only strings, is an explicit key and is passed unchanged. - The existing reified and
KClassfactory/extras overloads remain. Two initializer overloads are added: a reified form and aKClassform whoseCreationExtras.() -> VMinitializer receives the resolved owner's default extras. All overloads delegate to one store-only internal resolver. savedStateHandle()andSavedStateHandleHolderViewModelhave been removed without aliases. Business state obtains a handle in a ViewModel constructor or initializer throughCreationExtras.createSavedStateHandle().- No ViewCompose snapshot-state adapter for
SavedStateHandleis added. UI-only state usesrememberSaveable; ViewModel business state usesSavedStateHandle.getMutableStateFlow()and is observed through the existing state-collection integration. This preserves one writable owner and one restoration path instead of creating API symmetry with two sources of truth. - The released
viewmodel.saved-statecapability record remains only as the alpha01 historical identity required by immutable deletion-impact records. Current generated Reference entries are derived from compiled declarations and expose neither removed symbol; the record is not a compatibility API or an alternate ownership path.
Frozen public surface
The implementation phases may add overload annotations required by Kotlin/JVM, but they do not change these consumer-visible roles:
ViewModelScopeProvider.acquireOwner(key, savedStateRegistryOwner)returns aViewModelStoreOwnerLease;clear(key)andclearAll()provide the terminal signals.ViewModelStoreOwnerLeaseimplementsAutoCloseableand exposes its read-onlyViewModelStoreOwnerasowner.rememberViewModelScopeProvider(key, parentOwner, lifecycleOwner, defaultArgs, defaultCreationExtras, defaultFactory)returns oneViewModelScopeProvider.parentOwnerdefaults toLocalViewModelStoreOwner.current;lifecycleOwnerdefaults to the parent cast; Factory and extras default to the parent contracts.rememberViewModelStoreOwner(key, provider, savedStateRegistryOwner)returns aViewModelStoreOwner. Its saved-state owner defaults to the current local owner when that owner implementsSavedStateRegistryOwner.
acquireOwner exists for navigation and retained-container engines. Ordinary DSL content uses the
two remember functions and ProvideViewModelStoreOwner; it does not manually retain a lease.
clear and clearAll are terminal signals, not visibility callbacks.
Alternatives considered
Expose only AndroidX ViewModelStoreProvider
Rejected because a raw provider cannot distinguish a new owner created by a failed ViewCompose candidate from an already committed owner, cannot reject resurrection after terminal removal, and does not bind provider cleanup to ViewCompose's parent-lifecycle rule. It remains the internal storage primitive rather than the complete public contract.
Give navigation, Pager, lazy items, and overlays separate owner stores
Rejected because the policies differ only in lifecycle inputs and terminal events. Separate stores would reproduce the existing navigation specialization, multiply restoration bugs, and prevent one reference/removal test matrix from protecting every container.
Clear a child whenever its content leaves composition
Rejected because exit animation, retained navigation stacks, Pager offscreen limits, lazy reuse, and delayed rendering all make visibility shorter than logical ownership. Reference release and terminal removal must remain distinct events.
Retain providers in a process-global registry
Rejected because it outlives Activity and Fragment owners, cannot model process restoration, leaks application keys, and bypasses AndroidX's configuration-retained parent store.
Keep blank-key and standalone-handle compatibility
Rejected because the artifact is Alpha and both paths preserve defective ownership. Empty or blank keys are valid AndroidX explicit identities; a public handle-only holder duplicates the ViewModel constructor/factory model and reserves an application-visible store key.
Consequences
- ViewCompose gains the material Lifecycle 2.11 scoped-owner capability without adding Compose as a dependency or copying its position-derived persistent identity.
- A provider and one lightweight lease are additional bounded objects around AndroidX state. The wrapper complexity is accepted because it protects prepared-composition rollback, terminal clear, delayed references, and no-resurrection behavior that a raw adapter cannot express.
- Navigation migrated in one hard cut after parity tests proved entry, graph, multi-stack, restoration, transition, and cleanup behavior. It retains identity/lifecycle coordination but no longer allocates an independent ViewModelStore.
- Applications using blank keys or the standalone SavedStateHandle helper receive a compile-time or behavior break with explicit migration guidance; no deprecated compatibility window is provided.
Validation and rollout
- Phase 1 proves store-only lookup, null/non-null keys, Factory/extras precedence, initializer
failure,
onCleared, and lookup after clear. - Phase 2 proves provider sharing/isolation, commit/abort, multiple leases, temporary absence, terminal clear, no resurrection, configuration recreation, provider disposal, saved-state defaults, Pager/lazy/overlay reorder, and lifecycle-boundary diagnostics through 20 focused scoped-owner contracts. The owning module passes all 44 tests after combining this evidence with Phase 1 resolution coverage.
- Phase 3 passed 151/151 Navigation Android tests and 21/21 aggregate-host cases. Navigation now
leases entry and graph stores from the shared provider, keeps them across configuration
recreation through a saved host-scope identity, and clears them at permanent removal. Activity
hosts discover the installed ViewTree owner; the explicit Fragment owner wins over its
shorter-lived View owner; nested explicit providers retain precedence.
renderIntoremains owner-free. Conclusion: improved for ownership and retention relative to the 148-test navigation baseline. Device process-kill, memory, and performance evidence remains inconclusive. - Phase 4 passed both constructor/default-Factory and initializer process-style restoration
contracts and all 45/45 owning-module tests. The holder APIs and reserved key are absent;
rememberSaveableowns UI-only state, while one business ViewModel owns each mutableSavedStateHandleflow. Conclusion: improved. JVM restoration does not replace a device process-kill journey, which remains inconclusive for Phase 5. - Phase 5 passed all 52/52 owning-module tests after adding seven negative and deletion guards;
the clean affected-layer run passed 276/276 tests, and repository
qaQuick qaPreviewcompleted all 2,270 tasks. On a Xiaomi MI 6 running Android 9/API 28, two Debug process-death journeys changed PID and preserved the normalized Activity-root and multi-stack navigation state exactly. Conclusion: improved. One device does not establish release-mode, memory, performance, or platform-matrix behavior; those dimensions remain inconclusive. - Each phase lands Q3 KDoc, compiled samples, capability-impact records, module and migration documentation, immutable release intent, and focused tests with interpreted evidence.