diff --git a/.claude/skills/molecule-viewmodel/SKILL.md b/.claude/skills/molecule-viewmodel/SKILL.md index 95c22db..b00989f 100644 --- a/.claude/skills/molecule-viewmodel/SKILL.md +++ b/.claude/skills/molecule-viewmodel/SKILL.md @@ -1,24 +1,29 @@ --- name: molecule-viewmodel -description: Instructions for writing and testing molecule-viewmodel presenters. +description: Write and test presenters built with molecule-viewmodel. --- # molecule-viewmodel -A ViewModel base class for Molecule. Screen logic lives in a `@Composable` presenter, state is -exposed as a `StateFlow`, and tests call the presenter directly. - -Dependencies: +`MoleculeViewModel` runs a composable presenter and exposes its models as a `StateFlow`. ```kotlin implementation("io.github.raheelnaz:molecule-viewmodel:0.4.0") testImplementation("io.github.raheelnaz:molecule-viewmodel-test:0.4.0") ``` -Requires minSdk 23, Kotlin 2.3 or newer, and the Compose compiler plugin. Tests also need -`kotlinx-coroutines-test` and `testOptions { unitTests.isReturnDefaultValues = true }`. +The library requires minSdk 23, Kotlin 2.3 or newer, and the Compose compiler plugin. JVM tests +also need `kotlinx-coroutines-test` and the following Android setting: + +```kotlin +android { + testOptions { + unitTests.isReturnDefaultValues = true + } +} +``` -## Writing a ViewModel +## Presenter ```kotlin class CounterViewModel : MoleculeViewModel() { @@ -39,50 +44,53 @@ class CounterViewModel : MoleculeViewModel /* navigate, snackbar, ... */ }, + presenter = viewModel, + onEffect = ::handleEffect, ) { state, onEvent -> CounterScreen(state, onEvent) } ``` -`UiFactory` collects state with the lifecycle and effects while at least STARTED. Effects stay -buffered while the screen is stopped. One caught mid-handoff by a lifecycle cancellation goes -back in the buffer while there is room, behind anything buffered meanwhile. A handler that -started counts as delivered, cancellation inside it does not retry the effect. Use -`effectsMinActiveState = Lifecycle.State.RESUMED` to wait for navigation transitions, states -below CREATED throw. Collect `effects` from one place; concurrent collectors split the -channel. +`UiFactory` collects models with the lifecycle and collects effects while the lifecycle is at least +`STARTED`. Set `effectsMinActiveState` to `RESUMED` when effects must wait for navigation +transitions. Values below `CREATED` are rejected. + +Effects have one consumer. Do not collect the effect flow from multiple places. + +Hilt is optional. Put `@HiltViewModel` on the concrete class. Use an assisted factory for runtime +screen arguments. -## Testing +## Tests ```kotlin @Test @@ -95,13 +103,13 @@ fun increment() = runTest { } ``` -- Drive events with `sendEvent`, never `vm.onEvent`. `onEvent` feeds the production channel, - which the harness does not read. After 50 buffered sends it throws. -- `sendEvent` is synchronous, so the next line can assert its result. Work behind a `delay` or - another dispatcher is the exception; wait for that work with `awaitState()`. -- `awaitState()` returns the next distinct model, matching the production `StateFlow`. -- Unasserted states and effects fail the test. Use `skipStates(n)` for states you do not need. -- `expectNoStateChanges()` and `expectNoEffects()` are valid immediately after `sendEvent`. -- Tests run on the JVM with plain `runTest`. No Robolectric, no dispatcher setup, no main - looper. -- Construct a fresh ViewModel for each `test { }` block. +- Create a new ViewModel for each `test` block. +- Send events with `sendEvent`, not `viewModel.onEvent`. The harness owns a separate event stream. +- `sendEvent` finishes immediate presenter work before returning. Delayed or re-dispatched work is + still asynchronous. +- `awaitState` returns the next distinct model. +- `awaitEffect` returns the next effect. +- `expectNoStateChanges` and `expectNoEffects` inspect what is available now; they do not wait. +- `skipStates` skips distinct models. +- `awaitFailure` returns the exception that ended the presenter. +- The test fails if a model or effect remains unconsumed when the block returns. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f768d8..5360814 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Change Log +## Unreleased + +- Deliver events queued before presenter startup to every active event collector. +- Stop the event pump when the presenter fails, and clean up a recomposer left by a failed first + composition. +- Preserve the original startup failure as the cause of later `state` access errors. +- Add `awaitFailure` to the test harness and cover the remaining harness contracts. +- Include ViewModel and payload types in queue overflow messages without logging payload values. + ## [0.4.0] - 2026-08-03 - Put an effect back in the buffer when a lifecycle cancellation catches it mid-handoff. diff --git a/README.md b/README.md index 283f7c5..7a41382 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,16 @@ [![build](https://github.com/Raheelnaz/molecule-viewmodel/actions/workflows/build.yaml/badge.svg)](https://github.com/Raheelnaz/molecule-viewmodel/actions/workflows/build.yaml) [![Maven Central](https://img.shields.io/maven-central/v/io.github.raheelnaz/molecule-viewmodel)](https://central.sonatype.com/artifact/io.github.raheelnaz/molecule-viewmodel) -Write a ViewModel's logic as a `@Composable` function. +Use a [Molecule](https://github.com/cashapp/molecule) presenter as an Android `ViewModel`. -The Android ViewModel plumbing around [Molecule](https://github.com/cashapp/molecule): events -in, one `StateFlow` of models out, one-shot effects on the side, and a JVM test harness. +`MoleculeViewModel` runs a composable presenter in `viewModelScope`, exposes its models as a +`StateFlow`, and provides channels for UI events and one-time effects. The test artifact runs the +same presenter directly on the JVM. + +Inside the presenter, use `remember`, `collectAsState`, and Compose effects. Outside it, the screen +sees an ordinary ViewModel. + +## Quick start ```kotlin class CounterViewModel : MoleculeViewModel() { @@ -27,206 +33,229 @@ class CounterViewModel : MoleculeViewModel + when (effect) { + is CounterEffect.OpenShareSheet -> shareSheet.open(effect.count) + } + }, +) { state, onEvent -> + CounterScreen(state, onEvent) +} +``` + +Test the presenter without starting Android or replacing `Dispatchers.Main`: + ```kotlin @Test fun increment() = runTest { CounterViewModel().test { assertThat(awaitState()).isEqualTo(CounterState(0)) + sendEvent(CounterEvent.Increment) assertThat(awaitState()).isEqualTo(CounterState(1)) + sendEvent(CounterEvent.Share) assertThat(awaitEffect()).isEqualTo(CounterEffect.OpenShareSheet(1)) } } ``` -Tests run on the JVM. No Robolectric, no dispatcher setup. +## Installation -## Why +```kotlin +implementation("io.github.raheelnaz:molecule-viewmodel:0.4.0") +testImplementation("io.github.raheelnaz:molecule-viewmodel-test:0.4.0") +``` -[Molecule](https://github.com/cashapp/molecule) runs Compose without UI, so presenter logic can -use `remember`, snapshot state, and `LaunchedEffect` in place of `combine`, `stateIn`, and -friends. It does not provide Android ViewModel integration or an event/effect API. I kept -rebuilding that glue, so this library packages it: +The library requires minSdk 23 and Kotlin 2.3 or newer. Apply the Compose compiler plugin to the +module that subclasses `MoleculeViewModel`. -- The molecule starts lazily in `viewModelScope` with `RecompositionMode.Immediate`, which - makes the first model available synchronously, the moment `state` is read. -- Events wait in a buffered channel until the presenter starts, then broadcast to every - collector. Overflow throws. I would rather crash than lose a click. -- Effects are single-consumer and queue while the UI is stopped. An effect that cancellation - catches before its handler runs goes back in the buffer while there is room. A handler that - started counts as delivered. -- The test artifact runs presenters with the production recomposition mode. +Unit tests also need `kotlinx-coroutines-test`. Compose calls `android.util.Log` on some JVM test +paths, so enable default Android return values: -The molecule uses `Dispatchers.Main`, not `viewModelScope`'s `Main.immediate`. Deferring snapshot -notifications until after the current write avoids the invalidation bug in -[cashapp/molecule#465](https://github.com/cashapp/molecule/issues/465). No Compose UI frame clock -is involved. +```kotlin +android { + testOptions { + unitTests.isReturnDefaultValues = true + } +} +``` -Molecule is [Jake Wharton](https://jakewharton.com/)'s work. This library is the ViewModel -wiring around it. +## Models, events, and effects -## Status +| Type | Direction | Purpose | +| --- | --- | --- | +| Model | Presenter to UI | Everything the screen needs to render | +| Event | UI to presenter | User input such as a tap or retry | +| Effect | Presenter to UI | One-time work such as navigation or a snackbar | -Pre-1.0, so the API can still change between minor releases. `UiFactory` will probably get a -better name before then. The changelog lists every break. `rememberSaveable` inside a presenter -falls back to `remember` because the presenter has no saveable registry, so state that must -survive process death belongs in a `SavedStateHandle`. A screen with no events or effects can -pass `Nothing` for both types and still write its state with `remember` instead of `combine` -and `stateIn`. +### Models -## Usage +The molecule starts when `state` is first read. `RecompositionMode.Immediate` produces the first +model during that read, so `state.value` is available as soon as the getter returns. Later models +are conflated by equality, like any other `StateFlow`. -`UiFactory` collects state with the lifecycle. It collects effects while the screen is at least -STARTED, which leaves them buffered while the screen is stopped. Use -`effectsMinActiveState = Lifecycle.State.RESUMED` if navigation effects must wait until a -transition finishes. Collect `effects` from one place. Concurrent collectors compete for them. +### Events + +Events sent before the presenter starts wait in the input queue. Once the presenter is running, +events are broadcast to every active collector and are not replayed to collectors added later. + +Register event collectors unconditionally: ```kotlin -composable("counter") { - val vm: CounterViewModel = hiltViewModel() - UiFactory( - presenter = vm, - onEffect = { effect -> - when (effect) { - is CounterEffect.OpenShareSheet -> shareSheet.open(effect.count) - } - }, - ) { state, onEvent -> - CounterScreen(state, onEvent) - } -} +CollectEvents(events) { event -> handleEvent(event) } ``` -With Navigation 3, include the ViewModelStore decorator so each back-stack entry gets its own -ViewModel: +Use `CollectEventsOf` when a handler only accepts one event type: ```kotlin -NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - entryProvider = entryProvider { - entry { - val vm: CounterViewModel = hiltViewModel() - UiFactory( - presenter = vm, - onEffect = { effect -> - when (effect) { - is CounterEffect.OpenShareSheet -> shareSheet.open(effect.count) - } - }, - ) { state, onEvent -> - CounterScreen(state, onEvent) - } - } - }, -) +CollectEventsOf(events) { + count++ +} ``` -Hilt is optional. `MoleculeViewModel` is a plain `ViewModel`. For a screen that takes an -argument, assisted injection works the usual way: +Both input and effect queues have a capacity of 50. Sending to a full queue throws with the +ViewModel and payload types in the message. Sending after the ViewModel is cleared does nothing. -```kotlin -@HiltViewModel(assistedFactory = ProductViewModel.Factory::class) -class ProductViewModel @AssistedInject constructor( - @Assisted private val productId: String, - private val repository: ProductRepository, -) : MoleculeViewModel() { +### Effects - @AssistedFactory - interface Factory { - fun create(productId: String): ProductViewModel - } +Effects are delivered to one collector. `UiFactory` collects them while the UI lifecycle is at +least `STARTED`, so effects remain queued while the screen is stopped. - @Composable - override fun present(events: Flow): ProductState = TODO() -} -``` +An effect is considered delivered when `onEffect` starts. If lifecycle cancellation happens after +the channel receives an effect but before `onEffect` starts, the effect returns to the queue when +there is room. -```kotlin -val vm = hiltViewModel( - creationCallback = { factory -> factory.create(productId) }, -) -``` +Collect effects from one place. Concurrent collectors divide the stream between them. ## Writing presenters -Use `CollectEvents` for event handling: +Presenter logic uses the same `remember`, `collectAsState`, and Compose effect APIs as UI code. A +few rules keep that state predictable: + +- Change snapshot state from an event handler or Compose effect. Writing it unconditionally in + the composition body causes an endless recomposition loop. +- Call `emitEffect` from an event handler or Compose effect, not from the composition body. +- Keep `CollectEvents` and `CollectEventsOf` out of conditionals. Events are lost while a collector + is absent. +- Use `Nothing` when a screen has no events or effects. + +If a broad catch handles failures inside a presenter coroutine, check for real cancellation first: ```kotlin -CollectEvents(events) { event -> ... } +try { + repository.refresh() +} catch (failure: Throwable) { + currentCoroutineContext().ensureActive() + error = failure.toUiError() +} ``` -The collector lives as long as the presenter composition. Do not put it behind an `if` or use a -keyed `LaunchedEffect` as an event collector. Events sent while that collector is absent are -lost. Multiple `CollectEvents` calls each receive every event. +`ensureActive()` rethrows when the presenter coroutine was cancelled. It does not rethrow a +`TimeoutCancellationException` caught outside its `withTimeout` block, because the surrounding +event collector is still active. -Change state from an event handler or effect: +An uncaught `CancellationException` stops only that event collector. Any other uncaught exception +stops the presenter and reaches the uncaught exception handler. -```kotlin -var count by remember { mutableIntStateOf(0) } -count++ // recomposes forever -CollectEvents(events) { count++ } // fine -``` +## UI lifecycle + +`UiFactory` collects models with `collectAsStateWithLifecycle`. Effects are collected with +`repeatOnLifecycle` and default to `Lifecycle.State.STARTED`. + +Set `effectsMinActiveState = Lifecycle.State.RESUMED` when an effect must wait until a navigation +transition finishes. + +`UiFactory` controls collection in the UI. It does not pause the presenter itself; the molecule +runs until the ViewModel is cleared. ## Testing -Everything happens inside `test { }`: +`test` calls the composable presenter directly. Use a new ViewModel for each test. ```kotlin -vm.test { - sendEvent(event) // deliver an event and everything it triggers - awaitState() // the next distinct model - awaitEffect() // the next effect - expectNoStateChanges() // assert the model didn't change - expectNoEffects() // assert nothing fired - skipStates(2) // jump past states you don't care about +viewModel.test { + awaitState() // Wait for the next distinct model. + sendEvent(event) // Send an event and finish immediate presenter work. + awaitEffect() // Wait for the next effect. + expectNoStateChanges() // Fail if a model is ready now. + expectNoEffects() // Fail if an effect is ready now. + skipStates(2) // Skip two distinct models. + awaitFailure() // Wait for a terminal presenter failure. } ``` -`sendEvent` is synchronous. When it returns, the presenter has handled the event. Work behind -a `delay` or another dispatcher finishes on its own schedule. `awaitState` returns only -distinct models, matching the production `StateFlow`. +`sendEvent` is synchronous for work that stays on the current dispatcher. Work behind a `delay` or +another dispatcher still finishes asynchronously. -Anything the presenter emitted that the test didn't assert fails the test. +The harness fails when the block returns with an unconsumed model or effect. Drive it with +`sendEvent`; calling `viewModel.onEvent` writes to the production queue, which the harness does not +read. -Drive the presenter with `sendEvent`. Calling `vm.onEvent` in a test feeds the production -channel, which the harness doesn't read. +## Hilt -## Coding agents +Hilt is optional. Add `@HiltViewModel` to the concrete class. A screen that takes an argument +uses an assisted factory: -`.claude/skills/molecule-viewmodel/SKILL.md` is a skill for this library. Copy it into your -project: +```kotlin +@HiltViewModel(assistedFactory = ProductViewModel.Factory::class) +class ProductViewModel @AssistedInject constructor( + @Assisted private val productId: String, + private val repository: ProductRepository, +) : MoleculeViewModel() { -``` -mkdir -p .claude/skills/molecule-viewmodel -curl -L -o .claude/skills/molecule-viewmodel/SKILL.md \ - https://raw.githubusercontent.com/Raheelnaz/molecule-viewmodel/main/.claude/skills/molecule-viewmodel/SKILL.md -``` + @AssistedFactory + interface Factory { + fun create(productId: String): ProductViewModel + } -## Download + @Composable + override fun present(events: Flow): ProductState = TODO() +} +``` ```kotlin -implementation("io.github.raheelnaz:molecule-viewmodel:0.4.0") -testImplementation("io.github.raheelnaz:molecule-viewmodel-test:0.4.0") +val viewModel = hiltViewModel( + creationCallback = { factory -> factory.create(productId) }, +) ``` -Requires minSdk 23 and Kotlin 2.3 or newer. The module that subclasses `MoleculeViewModel` needs -the Compose compiler plugin. Unit tests need `kotlinx-coroutines-test`. Compose also calls -`android.util.Log` on some JVM test paths, so enable default Android return values: +## Navigation 3 + +Include the ViewModelStore decorator so each back-stack entry owns its ViewModel: ```kotlin -android { - testOptions { - unitTests.isReturnDefaultValues = true - } -} +entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), +) ``` +The library does not define a navigation API. Navigation can stay in the UI or be handled as an +effect, depending on the application. + +## Implementation notes + +The molecule runs on `Dispatchers.Main`, not `Main.immediate`, to avoid the invalidation problem +tracked in [cashapp/molecule#465](https://github.com/cashapp/molecule/issues/465). It does not use a +Compose UI frame clock. + +`rememberSaveable` falls back to `remember` because the presenter has no saveable state registry. +Use `SavedStateHandle` for state that must survive process death. + +The project is pre-1.0. Minor releases may change the public API; see [CHANGELOG.md](CHANGELOG.md) +before upgrading. + +Molecule is maintained by [Cash App](https://github.com/cashapp/molecule). This project provides +the Android ViewModel and testing integration around it. + ## License MIT License diff --git a/molecule-viewmodel-test/src/main/java/io/github/raheelnaz/molecule/test/MoleculeTestHarness.kt b/molecule-viewmodel-test/src/main/java/io/github/raheelnaz/molecule/test/MoleculeTestHarness.kt index 7ed63ba..92df5b4 100644 --- a/molecule-viewmodel-test/src/main/java/io/github/raheelnaz/molecule/test/MoleculeTestHarness.kt +++ b/molecule-viewmodel-test/src/main/java/io/github/raheelnaz/molecule/test/MoleculeTestHarness.kt @@ -17,26 +17,25 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.shareIn /** - * Runs [MoleculeViewModel.present] with a test event stream. [MoleculeTestScope.sendEvent] runs - * immediate work synchronously. Models are distinct, and unasserted models or effects fail the - * test. + * Runs [MoleculeViewModel.present] with a test event stream. * - * Use [MoleculeTestScope.sendEvent], not [MoleculeViewModel.onEvent]. + * [MoleculeTestScope.sendEvent] finishes immediate presenter work before returning. Models are + * distinct, and the test fails if a model or effect is left unconsumed. Send events through the + * test scope rather than [MoleculeViewModel.onEvent]. */ public suspend fun MoleculeViewModel.test( validate: suspend MoleculeTestScope.() -> Unit, ): Unit = turbineScope { val events = Channel(capacity = Channel.UNLIMITED) - // Match production's broadcast behavior. Unconfined keeps sendEvent synchronous, and the - // child job gives finally something to cancel when the test ends. + // Broadcast events like production while keeping sendEvent synchronous. val eventsJob = Job(currentCoroutineContext().job) val eventsScope = CoroutineScope(currentCoroutineContext() + eventsJob + Dispatchers.Unconfined) val eventsFlow = events.receiveAsFlow().shareIn(eventsScope, SharingStarted.Lazily) val effectsTurbine = effects.testIn(this) val stateTurbine = moleculeFlow(RecompositionMode.Immediate) { present(eventsFlow) } - // StateFlow does not emit a value equal to its current value. + // Match StateFlow's equality-based conflation. .distinctUntilChanged() .testIn(this) @@ -57,23 +56,26 @@ public class MoleculeTestScope internal private val effectsTurbine: ReceiveTurbine, private val events: Channel, ) { - /** The next distinct model. */ + /** Waits for the next distinct model. */ public suspend fun awaitState(): Model = stateTurbine.awaitItem() /** Skips the next [count] distinct models. */ public suspend fun skipStates(count: Int): Unit = stateTurbine.skipItems(count) - /** Checks for an immediate model change after [sendEvent]. */ + /** Fails if a model is ready now. This does not wait for future work. */ public fun expectNoStateChanges(): Unit = stateTurbine.expectNoEvents() + /** Sends [event] and finishes presenter work that does not suspend or change dispatchers. */ public fun sendEvent(event: Event) { events.trySend(event).getOrThrow() } + /** Waits for the next effect. */ public suspend fun awaitEffect(): Effect = effectsTurbine.awaitItem() + /** Fails if an effect is ready now. This does not wait for future work. */ public fun expectNoEffects(): Unit = effectsTurbine.expectNoEvents() - /** The failure that ended the presenter. */ + /** Waits for the failure that ended the presenter. */ public suspend fun awaitFailure(): Throwable = stateTurbine.awaitError() } diff --git a/molecule-viewmodel-test/src/test/java/io/github/raheelnaz/molecule/test/PresenterFailureTest.kt b/molecule-viewmodel-test/src/test/java/io/github/raheelnaz/molecule/test/PresenterFailureTest.kt index 3d90e56..d0d3dde 100644 --- a/molecule-viewmodel-test/src/test/java/io/github/raheelnaz/molecule/test/PresenterFailureTest.kt +++ b/molecule-viewmodel-test/src/test/java/io/github/raheelnaz/molecule/test/PresenterFailureTest.kt @@ -24,8 +24,6 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout import org.junit.Test -// thrower dies on event 2. writer drives the model, so the model says whether the rest of the -// presenter survived. private class TwoHandlerViewModel(private val boom: () -> Nothing) : MoleculeViewModel() { val thrower = mutableListOf() @@ -121,7 +119,6 @@ class PresenterFailureTest { sendEvent(2) scope.advanceUntilIdle() - // withTimeout throws TimeoutCancellationException, so the first timeout ends it. expectNoStateChanges() } } diff --git a/molecule-viewmodel-test/src/test/java/io/github/raheelnaz/molecule/test/ProductionContractTest.kt b/molecule-viewmodel-test/src/test/java/io/github/raheelnaz/molecule/test/ProductionContractTest.kt index 10b0962..98ae3e5 100644 --- a/molecule-viewmodel-test/src/test/java/io/github/raheelnaz/molecule/test/ProductionContractTest.kt +++ b/molecule-viewmodel-test/src/test/java/io/github/raheelnaz/molecule/test/ProductionContractTest.kt @@ -83,8 +83,7 @@ private class EffectProdViewModel : MoleculeViewModel() { } } -// Android's main looper: Dispatchers.Main posts, Main.immediate runs inline. Test dispatchers -// collapse the two and hide anything that depends on the difference. +// Standard test dispatchers do not model Android's Main/Main.immediate distinction. private class LooperMainDispatcher : MainCoroutineDispatcher() { private val queue = ArrayDeque() @@ -324,8 +323,7 @@ class ProductionContractTest { @Test fun `an effect caught mid-handoff by cancellation returns to the buffer`() { - // Deferred dispatch like production Main: the send resumes the suspended collector, and - // the cancellation wins the race before that resumption runs. + // Delay collector resumption so cancellation wins the handoff race. val main = StandardTestDispatcher() Dispatchers.setMain(main) try { @@ -440,8 +438,7 @@ class ProductionContractTest { @Test fun `events sent before startup reach every collector`() { - // Deferred dispatch like production Main: both collectors subscribe during the initial - // composition before the event pump runs. + // Defer Main so both collectors subscribe before the event pump runs. val main = StandardTestDispatcher() Dispatchers.setMain(main) try { @@ -569,7 +566,6 @@ class ProductionContractTest { vm.onEvent(1) advanceUntilIdle() - // The pump died with the presenter, so the queue fills instead of draining. repeat(50) { vm.onEvent(it) } assertFailure { vm.onEvent(99) } .isInstanceOf(IllegalStateException::class) @@ -577,7 +573,6 @@ class ProductionContractTest { } } - // The crash still surfaces: nothing catches it, so runTest reports it as uncaught. val root = generateSequence(outcome.exceptionOrNull()) { it.cause }.lastOrNull() assertThat(root) .isNotNull() @@ -587,8 +582,7 @@ class ProductionContractTest { @Test fun `the first composition runs on the thread that first reads state`() { - // launchMolecule calls setContent inline, so whoever reads state first composes it, even - // off main like this reader thread. Recomposition afterwards is on Dispatchers.Main. + // The initial composition runs in the state getter's call frame. val vm = ThreadRecordingViewModel().tracked() val reader = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "reader") } try { diff --git a/molecule-viewmodel/src/main/java/io/github/raheelnaz/molecule/LaunchedEffectNotNull.kt b/molecule-viewmodel/src/main/java/io/github/raheelnaz/molecule/LaunchedEffectNotNull.kt index 3a452ce..44242b3 100644 --- a/molecule-viewmodel/src/main/java/io/github/raheelnaz/molecule/LaunchedEffectNotNull.kt +++ b/molecule-viewmodel/src/main/java/io/github/raheelnaz/molecule/LaunchedEffectNotNull.kt @@ -13,7 +13,7 @@ public fun LaunchedEffectNotNull( LaunchedEffect(a) { if (a != null) block(a) } } -/** Runs [block] when [a] and [b] are both non-null. */ +/** Runs [block] when [a] and [b] are non-null. Changing either value cancels the old block. */ @Composable public fun LaunchedEffectNotNull( a: A?, @@ -23,7 +23,7 @@ public fun LaunchedEffectNotNull( LaunchedEffect(a, b) { if (a != null && b != null) block(a, b) } } -/** Runs [block] when [a], [b], and [c] are all non-null. */ +/** Runs [block] when all values are non-null. Changing any value cancels the old block. */ @Composable public fun LaunchedEffectNotNull( a: A?, diff --git a/molecule-viewmodel/src/main/java/io/github/raheelnaz/molecule/MoleculePresenter.kt b/molecule-viewmodel/src/main/java/io/github/raheelnaz/molecule/MoleculePresenter.kt index adb0644..7ca19da 100644 --- a/molecule-viewmodel/src/main/java/io/github/raheelnaz/molecule/MoleculePresenter.kt +++ b/molecule-viewmodel/src/main/java/io/github/raheelnaz/molecule/MoleculePresenter.kt @@ -3,14 +3,15 @@ package io.github.raheelnaz.molecule import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow -/** State, effects, and events exposed to UI code. */ +/** The model, events, and effects used by a screen. */ public interface MoleculePresenter { + /** The latest model to render. */ public val state: StateFlow - /** One-off effects. Concurrent collectors split this stream. */ + /** One-off work for the UI. Each effect is delivered to one collector. */ public val effects: Flow - /** Safe to call from any thread. */ + /** Sends an event to the presenter. Safe to call from any thread. */ public fun onEvent(event: Event) } diff --git a/molecule-viewmodel/src/main/java/io/github/raheelnaz/molecule/MoleculeViewModel.kt b/molecule-viewmodel/src/main/java/io/github/raheelnaz/molecule/MoleculeViewModel.kt index 4c980c5..08a3512 100644 --- a/molecule-viewmodel/src/main/java/io/github/raheelnaz/molecule/MoleculeViewModel.kt +++ b/molecule-viewmodel/src/main/java/io/github/raheelnaz/molecule/MoleculeViewModel.kt @@ -21,14 +21,14 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.job import kotlinx.coroutines.launch -/** A ViewModel backed by a Molecule presenter. Implement [present] to produce the screen model. */ +/** A ViewModel whose state is produced by a Molecule presenter. */ public abstract class MoleculeViewModel : ViewModel(), MoleculePresenter { private val eventChannel = Channel(capacity = 50) private val effectChannel = redeliveringChannel(capacity = 50) - // 64 is the buffer shareIn was using before the pump became explicit. + // Keep the same downstream capacity as shareIn's default buffer. private val events = MutableSharedFlow(extraBufferCapacity = 64) final override val effects: Flow = effectChannel.receiveAsFlow() @@ -36,10 +36,10 @@ public abstract class MoleculeViewModel private var startAttempted = false private var startFailure: Throwable? = null - // Immediate produces the first model synchronously and does not wait for display frames. + // Immediate makes state.value available on the first read. final override val state: StateFlow by lazy { check(viewModelScope.isActive) { "state was first read after the ViewModel was cleared" } - // lazy reruns an initializer that threw, which would compose a second presenter. + // Kotlin retries a lazy initializer after it throws. startFailure?.let { throw IllegalStateException("the presenter already failed to start", it) } @@ -49,8 +49,8 @@ public abstract class MoleculeViewModel startPresenter() } - // One job owns the presenter runtime. A throw in the first composition cancels the - // Recomposer it left behind (molecule#761); a crash later takes the event pump down too. + // A presenter failure must stop the event pump too. The cancel on a failed start also + // covers a Recomposer leak fixed upstream in cashapp/molecule#761. private fun startPresenter(): StateFlow { val presenterJob = Job(viewModelScope.coroutineContext.job) val presenterScope = CoroutineScope(viewModelScope.coroutineContext + presenterJob) @@ -61,8 +61,7 @@ public abstract class MoleculeViewModel ) { present(events) } - // Main queues the pump behind the collectors the composition just posted. - // Main.immediate would run it inline and beat all but the first collector. + // Main queues the pump until every initial event collector has subscribed. presenterScope.launch(Dispatchers.Main) { for (event in eventChannel) events.emit(event) } @@ -74,14 +73,15 @@ public abstract class MoleculeViewModel } } - /** Produces a model from snapshot state and [events]. */ + /** Returns the current model for [events] and remembered presenter state. */ @Composable public abstract fun present(events: Flow): Model /** - * Collects [events] for the lifetime of the presenter. Call this unconditionally. Events sent - * before the presenter starts are kept; events sent while no collector is subscribed are - * dropped. + * Collects [events] while the presenter is running. + * + * Call this unconditionally. The event stream does not replay items to a collector added after + * startup. */ @Composable protected fun CollectEvents( @@ -92,7 +92,7 @@ public abstract class MoleculeViewModel LaunchedEffect(events) { events.collect { current(it) } } } - /** Collects events of type [T] for the lifetime of the presenter. */ + /** Collects events of type [T]. This follows the same rules as [CollectEvents]. */ @Composable protected inline fun CollectEventsOf( events: Flow, @@ -103,19 +103,22 @@ public abstract class MoleculeViewModel } /** - * Throws when the 50 slot event queue fills. A started presenter buffers 64 more past it. - * A no-op once the ViewModel is cleared. + * Adds [event] to the input queue. Throws if its 50 slots are full. Events sent after the + * ViewModel is cleared are ignored. */ final override fun onEvent(event: Event) { eventChannel.trySendOrThrow(event, "Event", this) } - /** Throws after 50 unconsumed effects. A no-op once the ViewModel is cleared. */ + /** + * Adds [effect] to the effect queue. Throws if its 50 slots are full. Effects emitted after the + * ViewModel is cleared are ignored. + */ protected fun emitEffect(effect: Effect) { effectChannel.trySendOrThrow(effect, "Effect", this) } - /** Use [addCloseable] for subclass cleanup. */ + /** Register subclass cleanup with [addCloseable]. */ final override fun onCleared() { super.onCleared() eventChannel.close() @@ -126,16 +129,15 @@ public abstract class MoleculeViewModel private fun Channel.trySendOrThrow(value: T, streamName: String, owner: Any) { val result = trySend(value) if (result.isClosed) return - // Do not include the payload in the error; events and effects may contain user data. + // Payload values may contain user data, so only include their types. check(result.isSuccess) { "$streamName buffer overflow in ${owner.typeName} (latest: ${value.typeName})" } } -// simpleName is null for anonymous classes. private val Any.typeName: String get() = this::class.simpleName ?: this::class.java.name -// A cancelled receive puts the effect back instead of dropping it. +// Return an effect to the queue if its collector is cancelled before handling it. private fun redeliveringChannel(capacity: Int): Channel { lateinit var channel: Channel channel = Channel(capacity) { channel.trySend(it) } diff --git a/molecule-viewmodel/src/main/java/io/github/raheelnaz/molecule/UiFactory.kt b/molecule-viewmodel/src/main/java/io/github/raheelnaz/molecule/UiFactory.kt index b724b44..c54d400 100644 --- a/molecule-viewmodel/src/main/java/io/github/raheelnaz/molecule/UiFactory.kt +++ b/molecule-viewmodel/src/main/java/io/github/raheelnaz/molecule/UiFactory.kt @@ -10,8 +10,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle /** - * Collects [presenter] state with the lifecycle and handles effects while the lifecycle is at - * least [effectsMinActiveState]. + * Renders [content] from [presenter] and handles effects while the lifecycle is at least + * [effectsMinActiveState]. */ @Composable public fun UiFactory( @@ -20,7 +20,6 @@ public fun UiFactory( effectsMinActiveState: Lifecycle.State = Lifecycle.State.STARTED, content: @Composable (state: Model, onEvent: (Event) -> Unit) -> Unit, ) { - // repeatOnLifecycle rejects INITIALIZED at runtime, fail at the call site instead. require(effectsMinActiveState.isAtLeast(Lifecycle.State.CREATED)) { "effectsMinActiveState must be CREATED, STARTED, or RESUMED" }