fix(chat): a tablet's room list can pick a second room, not just the first
The home screen's detail pane answered with the first room it was ever given. Tap another and the list highlighted nothing, the transcript did not change, and the app bar kept the first room's title -- so from the expanded breakpoint up, which is a tablet in landscape and every desktop window, the split was a one-shot: the first conversation you opened was the only one you could read without leaving the screen. **`key(chatRoomId)` does not rebuild a view model, and the comment claiming it did was the bug.** `viewModel()` resolves against `LocalViewModelStoreOwner.current` under a key derived from the *class* -- `ViewModelProvider.get(modelClass)`, no key, straight to the default `DEFAULT_KEY:canonicalName`. A composition key replaces a group: `remember`s are forgotten and `LaunchedEffect`s restart, which is real but is not where a view model lives. The store belongs to `HomeRoute`'s `NavBackStackEntry` and does not change while the reader is on the home screen, so the second tap re-ran the composition, asked for a `ChatRoomMessagingViewModel`, and got back the one holding room one -- its `chatRoomId` a constructor val, its factory never invoked a second time. `initiateChatRoomDetail()` then re-read room one and the pane redrew what it already had. **This is a list-detail-only failure, which is why nothing caught it.** Every other screen in the app reaches the chat transcript by `navController.navigate(...)`, and a pushed route is its own `NavBackStackEntry` and so its own store -- one subject per owner, the class key never collides. The pane is the one place one screen is composed for a succession of subjects without the entry changing. Below expanded the home screen still pushes, so a phone never had this; the four existing cases in `ChatPaneLayoutJvmTest` assert the widths and the empty state, which were right. **`ViewModelStoreScopedTo` gives the pane a store of its own rather than keying the two `viewModel()` calls by room id.** Both put the right room on screen. Keying would also leave every room visited holding a live view model in the home entry's store until the reader left the screen, each with its relay subscriptions and database collectors still running -- a leak that grows with how much a reader browses, which on this layout is the point of the layout. A scope that clears on the way out is the same correctness and none of that. **It is hand-rolled because the stock API is not here.** `rememberViewModelStoreOwner` lands in `androidx.lifecycle:lifecycle-viewmodel-compose` 2.11.0 and does exactly this, including surviving an Android configuration change by consulting the parent's lifecycle; the build resolves `org.jetbrains.androidx.lifecycle`, and the class is in neither the 2.9.6 nor the 2.11.0 artifact in the cache. A bare `ViewModelStoreOwner` is the whole contract a scope needs here -- nothing in this app puts a `SavedStateHandle` in a view model, so there is no saved state to delegate, and it deliberately does not implement `HasDefaultViewModelProviderFactory` because every call inside passes its own factory. The cost of hand-rolling is that a rotation rebuilds the pane's view models instead of keeping them; the selection itself is `rememberSaveable` and survives, so what that costs is a re-read of one room. **Both wrappers stay, and they are not redundant.** The store scope alone would leave the composer's draft text in place as the reader moved to the next room -- which on this screen is the failure the composer is built around three separate signals to prevent, one person's words going to another -- and would not restart the effect that loads a room, so the pane would sit on `Loading`. `key` alone was the bug. One resets the composition, the other the view models. **Tests: a fifth case in `ChatPaneLayoutJvmTest`, checked against the bug reinstated.** Two rooms told apart by their subject, which both panes draw as a title, so two nodes saying it means "this is the room on the right" and one means "this room is merely listed" -- an assertion that needs no new test tag and no reach into the view model. Tap room one, wait for two; tap room two, wait for two of *it* and one of room one. With `ViewModelStoreScopedTo` reduced to `content()` the second wait fails with `ComposeTimeoutException` while the other four cases still pass, so the case pins the view model scope rather than the layout. The waits are generous, because what they wait for is a database read on another dispatcher and the cost of being wrong about a thread hop is a test that fails on a loaded machine. **Not done.** Nobody has looked at this running on a tablet; the test proves the pane follows the selection, which is not the same thing. The list pane also still draws no selected state -- M3's list-detail expects the selected row to carry one, and its absence is its own reason the pane read as unresponsive. Left out deliberately: it is a separate change to a different file, and this one is the part that was broken. 974 jvm tests, 454 common tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
26d305bc14
commit
b95d723edd
3 files changed
+141
-12
No files matched your search
@@ -80,6 +80,7 @@ import mantra.composeapp.generated.resources.messages_need_the_secret_key_this_p
|
||||
import mantra.composeapp.generated.resources.sign_in_with_the_nsec
|
||||
import press.mantra.compose.ui.composable.widgets.LocalCanSign
|
||||
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
|
||||
import press.mantra.compose.ui.composable.widgets.ViewModelStoreScopedTo
|
||||
import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab
|
||||
import press.mantra.compose.ui.theme.ConformancePreviews
|
||||
|
||||
@@ -311,10 +312,17 @@ fun HomeScreen(
|
||||
message = stringResource(Res.string.pick_a_conversation_to_read_it_here)
|
||||
)
|
||||
} else {
|
||||
// Keyed on the room, so switching rooms rebuilds the
|
||||
// screen's view models rather than feeding a new id to
|
||||
// ones already subscribed to another room's relay.
|
||||
// Two things, and the pane needs both. `key` rebuilds
|
||||
// the composition -- the composer's draft text does not
|
||||
// follow you into the next room, and the effect that
|
||||
// loads a room runs again. `ViewModelStoreScopedTo`
|
||||
// rebuilds the view models, which `key` cannot: they
|
||||
// hang off the store, and on this screen the store is
|
||||
// `HomeRoute`'s and never changes, so without it the
|
||||
// pane kept answering with the first room it was given
|
||||
// however many were tapped after it.
|
||||
key(chatRoomId) {
|
||||
ViewModelStoreScopedTo(chatRoomId) {
|
||||
ChatRoomMessagingScreen(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
@@ -333,6 +341,7 @@ fun HomeScreen(
|
||||
onNavigateBack = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package press.mantra.compose.ui.composable.widgets
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
|
||||
|
||||
/**
|
||||
* Gives [content] a `ViewModelStore` of its own, cleared whenever [scopeKey] changes.
|
||||
*
|
||||
* **`key(...)` does not do this, which is the whole reason this exists.** A composition
|
||||
* key replaces a group -- `remember`s are forgotten, `LaunchedEffect`s restart -- but
|
||||
* `viewModel()` is not composition state. It resolves against
|
||||
* `LocalViewModelStoreOwner.current` under a key derived from the *class*, so two
|
||||
* compositions of one screen under one owner get the same instance no matter how they are
|
||||
* keyed, and the factory is never invoked for the second. Being inside `key(someId)` buys
|
||||
* nothing: the store is the navigation entry's, and it outlives every group in it.
|
||||
*
|
||||
* That is only a problem where one screen is composed for a succession of subjects without
|
||||
* the navigation entry changing, which in this app means a list-detail pane: the home
|
||||
* screen stays on `HomeRoute` while the detail pane is asked for one chat room after
|
||||
* another. A pushed route has no such problem, because each `NavBackStackEntry` is already
|
||||
* a store of its own -- which is why this only wraps the pane.
|
||||
*
|
||||
* The store is cleared on the way out rather than left to the parent, so the room just
|
||||
* left does not keep its relay subscriptions and database collectors running. That is the
|
||||
* other half of the job: keying the `viewModel()` calls by room id instead would fix what
|
||||
* is on screen and quietly accumulate a live view model per room visited.
|
||||
*
|
||||
* Nothing in this app puts a `SavedStateHandle` in a view model, so a bare
|
||||
* `ViewModelStoreOwner` is the whole contract a scope needs here; it deliberately does not
|
||||
* implement `HasDefaultViewModelProviderFactory`, because every call inside passes its own
|
||||
* factory.
|
||||
*/
|
||||
@Composable
|
||||
fun ViewModelStoreScopedTo(scopeKey: Any?, content: @Composable () -> Unit) {
|
||||
val owner = remember(scopeKey) {
|
||||
object : ViewModelStoreOwner {
|
||||
override val viewModelStore = ViewModelStore()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(owner) {
|
||||
onDispose { owner.viewModelStore.clear() }
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalViewModelStoreOwner provides owner, content = content)
|
||||
}
|
||||
Reference in new issue
Block a user