From b3a4e13b8550492413754f97ad07144b3bdedc84 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 00:50:32 +0000 Subject: [PATCH] refactor: scope account ViewModelStore with Lifecycle 2.11 rememberViewModelStoreOwner The per-account ViewModelStore was managed by a hand-rolled registry (StoreOwnerRegistry + ScopedViewModelStoreOwner + a RememberObserver) that tracked configuration changes manually. Its own TODO admitted it could not clear a store detached around a configuration change, so AccountViewModels (and their child ViewModels, feed states and relay subscriptions) leaked and stayed active after switching accounts. Replace the whole registry with androidx.lifecycle 2.11's rememberViewModelStoreOwner (already on the classpath at 2.11.0). The owner is keyed by the account public key via key(): while an account stays logged in the owner survives recompositions and configuration changes (it is parented to the Activity's LocalViewModelStoreOwner); when the account changes the previous owner leaves the composition and its ViewModelStore is cleared immediately. Deletes ~95 lines of lifecycle plumbing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0154te1AiD1Ykz1HCa8ao2Vo --- .../amethyst/ui/screen/AccountState.kt | 117 ++++-------------- 1 file changed, 21 insertions(+), 96 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt index 0b764e9e89..27f15114a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt @@ -20,112 +20,37 @@ */ package com.vitorpamplona.amethyst.ui.screen -import androidx.activity.ComponentActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.RememberObserver -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelStore -import androidx.lifecycle.ViewModelStoreOwner +import androidx.compose.runtime.key import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.ui.components.getActivity +import androidx.lifecycle.viewmodel.compose.rememberViewModelStoreOwner /** - * Creates a new scope for the given ViewModel type. + * Provides a [androidx.lifecycle.ViewModelStoreOwner] scoped to the currently logged-in account so + * that every ViewModel created under it (the [com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel] + * and all of its children) lives and dies with that account. + * + * The owner is keyed by the account's public key via [key]. While the same account stays logged in, + * the call site is stable, so the owner — and therefore the ViewModels — survives recompositions and + * configuration changes (it is parented to the Activity's [LocalViewModelStoreOwner]). When the user + * switches accounts the public key changes, the previous owner leaves the composition, and Lifecycle + * 2.11's [rememberViewModelStoreOwner] clears its [androidx.lifecycle.ViewModelStore] immediately — + * tearing down the old account's ViewModels instead of leaking them. + * + * This replaces a hand-rolled per-account ViewModelStore registry that could not clear stores around + * configuration changes (see git history). */ @Composable fun SetAccountCentricViewModelStore( state: AccountState.LoggedIn, content: @Composable () -> Unit, ) { - val activity = LocalContext.current.getActivity() - val vmStore: StoreOwnerRegistry = viewModel(viewModelStoreOwner = activity) - vmStore.checkAttached(activity) - - val owner = vmStore.getOwner(state) - - val observer = remember { CompositionObserver(vmStore, state) } - - CompositionLocalProvider( - LocalViewModelStoreOwner provides owner, - content = content, - ) -} - -/** - * This class is responsible for notifying the [StoreOwnerRegistry] when a composable is detached so - * that the viewmodel can be cleared. - */ -class CompositionObserver( - private val vmStore: StoreOwnerRegistry, - private val key: Any, -) : RememberObserver { - override fun onRemembered() { - // No action needed when remembered — registration happens at construction time. - } - - override fun onForgotten() = vmStore.composableDetached(key) - - override fun onAbandoned() = vmStore.composableDetached(key) -} - -/** - * Registry for [ViewModelStoreOwner]s that are scoped to a particular composition. - * This ViewModel is registered with the Activity's lifecycle and will clear the viewmodels. - */ -class StoreOwnerRegistry : ViewModel() { - private var isActivityRegistered: Boolean = false - private var isChangingConfigurations: Boolean = false - private val map = mutableMapOf() - - override fun onCleared() { - map.values.forEach { it.viewModelStore.clear() } - super.onCleared() - } - - fun getOwner(key: Any): ViewModelStoreOwner = map[key] ?: ScopedViewModelStoreOwner().also { map[key] = it } - - fun composableDetached(key: Any) { - // TODO: This prevents the viewmodel from being cleared when the Composable is detached due - // to a configuration change. We need to make sure that the viewmodel is cleared when the - // Composition is recreated without the Composable. E.g. by observing the Composition - if (isChangingConfigurations) return - map.remove(key)?.also { owner -> owner.viewModelStore.clear() } - } - - fun checkAttached(activity: ComponentActivity) { - if (!isActivityRegistered) { - isActivityRegistered = true - activity.lifecycle.addObserver( - object : DefaultLifecycleObserver { - override fun onStart(owner: LifecycleOwner) { - isChangingConfigurations = false - } - - override fun onStop(owner: LifecycleOwner) { - if (activity.isChangingConfigurations) { - isChangingConfigurations = true - } - } - - override fun onDestroy(owner: LifecycleOwner) { - isActivityRegistered = false - owner.lifecycle.removeObserver(this) - } - }, - ) - } + key(state.account.signer.pubKey) { + val owner = rememberViewModelStoreOwner() + CompositionLocalProvider( + LocalViewModelStoreOwner provides owner, + content = content, + ) } } - -/** - * Simple ViewModelStoreOwner that can be used to create a new scope. - */ -class ScopedViewModelStoreOwner : ViewModelStoreOwner { - override val viewModelStore: ViewModelStore = ViewModelStore() -}