diff --git a/CHANGELOG.md b/CHANGELOG.md index f4c6168..d76e90f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Gbéewá are recorded in this file. ## [Unreleased] +## [0.2.0] + +- Add direct consume-once access through `PendingResult.Missing` and `PendingResult.Value`. + ## [0.1.0] - Add typed result keys and root-owned pending-result state. @@ -11,5 +15,6 @@ All notable changes to Gbéewá are recorded in this file. - Allow a custom value saver for saveable result state. - Add Android and iOS targets. -[Unreleased]: https://github.com/quantipixels/gbeewa/compare/release-0.1.0...HEAD +[Unreleased]: https://github.com/quantipixels/gbeewa/compare/release-0.2.0...HEAD +[0.2.0]: https://github.com/quantipixels/gbeewa/compare/release-0.1.0...release-0.2.0 [0.1.0]: https://github.com/quantipixels/gbeewa/releases/tag/release-0.1.0 diff --git a/README.md b/README.md index 35a73f8..896c920 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,8 @@ The showcase iOS application targets iOS 17.2. This value is the host applicatio | --- | --- | | `ResultKey` | Defines a typed result lane. Key equality defines lane identity. | | `ResultStore` | Publishes and retains the latest pending value for each equal key. | -| `ResultConsumer` | Provides consume-only access to a store. | +| `ResultConsumer` | Provides callback and direct consume-only access to a store. | +| `PendingResult` | Distinguishes a removed value from a missing pending result. | | `LocalResultConsumer` | Provides a consumer to Compose descendants. | | `ResultEffect` | Consumes one pending result in a non-suspending callback. | | `rememberResultAsState` | Converts delivered results into read-only Compose state. | @@ -295,6 +296,28 @@ The route can pass ordinary values and callbacks to its screen content. The scre The handler is not a suspend function. Keep it quick. Update state directly, or launch longer work in a caller-owned scope that has the correct lifecycle and error policy. +Use direct consumption when an event handler, effect, or non-Compose function needs the removed +value as an expression: + +```kotlin +import com.quantipixels.gbeewa.PendingResult +import com.quantipixels.gbeewa.ResultConsumer + +fun openSelectedCountry( + resultConsumer: ResultConsumer, + openCountry: (String) -> Unit, +) { + when (val result = resultConsumer.consume(SelectedCountryKey)) { + PendingResult.Missing -> Unit + is PendingResult.Value -> openCountry(result.value) + } +} +``` + +Direct consumption removes the pending result before it returns. Do not call it during composition +because removal changes observable Compose state. Use `ResultEffect`, `rememberResultAsState`, or +`rememberSaveableResultAsState` for composition-owned delivery. + Use `rememberResultAsState` when the result directly updates local Compose state. The `AsState` suffix distinguishes the returned `State` from the root-owned `ResultStore`. Extra remember keys reset the initial value when their context changes: ```kotlin @@ -388,11 +411,14 @@ resultStore.put(OptionalNoteKey, null) ``` Presence is stored separately from the value. A pending `null` is delivered once. It is not treated as no result. +Direct consumption returns `PendingResult.Value(null)`. It returns `PendingResult.Missing` only when +the key has no pending result. ## Delivery contract - The store keeps only the latest pending value for each equal key. - Consumption removes the value before the handler runs. +- Direct consumption returns `PendingResult.Value` after removal, or `PendingResult.Missing` when no value exists. - One result is consumed at most once, including when multiple effects compete for one key. - A handler failure does not restore the result. - A handler must not publish another value for its own key. That value can remain pending until the effect leaves and re-enters composition. diff --git a/gbeewa/api/gbeewa.api b/gbeewa/api/gbeewa.api index bbfc956..dd5ef16 100644 --- a/gbeewa/api/gbeewa.api +++ b/gbeewa/api/gbeewa.api @@ -1,3 +1,26 @@ +public abstract interface class com/quantipixels/gbeewa/PendingResult { +} + +public final class com/quantipixels/gbeewa/PendingResult$Missing : com/quantipixels/gbeewa/PendingResult { + public static final field $stable I + public static final field INSTANCE Lcom/quantipixels/gbeewa/PendingResult$Missing; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/quantipixels/gbeewa/PendingResult$Value : com/quantipixels/gbeewa/PendingResult { + public static final field $stable I + public fun (Ljava/lang/Object;)V + public final fun component1 ()Ljava/lang/Object; + public final fun copy (Ljava/lang/Object;)Lcom/quantipixels/gbeewa/PendingResult$Value; + public static synthetic fun copy$default (Lcom/quantipixels/gbeewa/PendingResult$Value;Ljava/lang/Object;ILjava/lang/Object;)Lcom/quantipixels/gbeewa/PendingResult$Value; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/Object; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class com/quantipixels/gbeewa/RememberResultAsStateKt { public static final fun rememberResultAsState (Lcom/quantipixels/gbeewa/ResultKey;[Ljava/lang/Object;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)Landroidx/compose/runtime/State; public static final fun rememberSaveableResultAsState (Lcom/quantipixels/gbeewa/ResultKey;[Ljava/lang/Object;Landroidx/compose/runtime/saveable/Saver;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)Landroidx/compose/runtime/State; @@ -7,6 +30,7 @@ public final class com/quantipixels/gbeewa/RememberResultAsStateKt { public final class com/quantipixels/gbeewa/ResultConsumer { public static final field $stable I public fun (Lcom/quantipixels/gbeewa/ResultStore;)V + public final fun consume (Lcom/quantipixels/gbeewa/ResultKey;)Lcom/quantipixels/gbeewa/PendingResult; public final fun consume (Lcom/quantipixels/gbeewa/ResultKey;Lkotlin/jvm/functions/Function1;)V public final fun contains (Lcom/quantipixels/gbeewa/ResultKey;)Z } diff --git a/gbeewa/api/gbeewa.klib.api b/gbeewa/api/gbeewa.klib.api index c6a96da..a1ab0c6 100644 --- a/gbeewa/api/gbeewa.klib.api +++ b/gbeewa/api/gbeewa.klib.api @@ -8,9 +8,31 @@ // Library unique name: abstract interface <#A: kotlin/Any?> com.quantipixels.gbeewa/ResultKey // com.quantipixels.gbeewa/ResultKey|null[0] +sealed interface <#A: out kotlin/Any?> com.quantipixels.gbeewa/PendingResult { // com.quantipixels.gbeewa/PendingResult|null[0] + final class <#A1: out kotlin/Any?> Value : com.quantipixels.gbeewa/PendingResult<#A1> { // com.quantipixels.gbeewa/PendingResult.Value|null[0] + constructor (#A1) // com.quantipixels.gbeewa/PendingResult.Value.|(1:0){}[0] + + final val value // com.quantipixels.gbeewa/PendingResult.Value.value|{}value[0] + final fun (): #A1 // com.quantipixels.gbeewa/PendingResult.Value.value.|(){}[0] + + final fun component1(): #A1 // com.quantipixels.gbeewa/PendingResult.Value.component1|component1(){}[0] + final fun copy(#A1 = ...): com.quantipixels.gbeewa/PendingResult.Value<#A1> // com.quantipixels.gbeewa/PendingResult.Value.copy|copy(1:0){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.quantipixels.gbeewa/PendingResult.Value.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.quantipixels.gbeewa/PendingResult.Value.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.quantipixels.gbeewa/PendingResult.Value.toString|toString(){}[0] + } + + final object Missing : com.quantipixels.gbeewa/PendingResult { // com.quantipixels.gbeewa/PendingResult.Missing|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.quantipixels.gbeewa/PendingResult.Missing.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.quantipixels.gbeewa/PendingResult.Missing.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.quantipixels.gbeewa/PendingResult.Missing.toString|toString(){}[0] + } +} + final class com.quantipixels.gbeewa/ResultConsumer { // com.quantipixels.gbeewa/ResultConsumer|null[0] constructor (com.quantipixels.gbeewa/ResultStore) // com.quantipixels.gbeewa/ResultConsumer.|(com.quantipixels.gbeewa.ResultStore){}[0] + final fun <#A1: kotlin/Any?> consume(com.quantipixels.gbeewa/ResultKey<#A1>): com.quantipixels.gbeewa/PendingResult<#A1> // com.quantipixels.gbeewa/ResultConsumer.consume|consume(com.quantipixels.gbeewa.ResultKey<0:0>){0§}[0] final fun <#A1: kotlin/Any?> consume(com.quantipixels.gbeewa/ResultKey<#A1>, kotlin/Function1<#A1, kotlin/Unit>) // com.quantipixels.gbeewa/ResultConsumer.consume|consume(com.quantipixels.gbeewa.ResultKey<0:0>;kotlin.Function1<0:0,kotlin.Unit>){0§}[0] final fun contains(com.quantipixels.gbeewa/ResultKey<*>): kotlin/Boolean // com.quantipixels.gbeewa/ResultConsumer.contains|contains(com.quantipixels.gbeewa.ResultKey<*>){}[0] } @@ -24,6 +46,8 @@ final class com.quantipixels.gbeewa/ResultStore { // com.quantipixels.gbeewa/Res final val com.quantipixels.gbeewa/LocalResultConsumer // com.quantipixels.gbeewa/LocalResultConsumer|{}LocalResultConsumer[0] final fun (): androidx.compose.runtime/ProvidableCompositionLocal // com.quantipixels.gbeewa/LocalResultConsumer.|(){}[0] +final val com.quantipixels.gbeewa/com_quantipixels_gbeewa_PendingResult_Missing$stableprop // com.quantipixels.gbeewa/com_quantipixels_gbeewa_PendingResult_Missing$stableprop|#static{}com_quantipixels_gbeewa_PendingResult_Missing$stableprop[0] +final val com.quantipixels.gbeewa/com_quantipixels_gbeewa_PendingResult_Value$stableprop // com.quantipixels.gbeewa/com_quantipixels_gbeewa_PendingResult_Value$stableprop|#static{}com_quantipixels_gbeewa_PendingResult_Value$stableprop[0] final val com.quantipixels.gbeewa/com_quantipixels_gbeewa_ResultConsumer$stableprop // com.quantipixels.gbeewa/com_quantipixels_gbeewa_ResultConsumer$stableprop|#static{}com_quantipixels_gbeewa_ResultConsumer$stableprop[0] final val com.quantipixels.gbeewa/com_quantipixels_gbeewa_ResultStore$stableprop // com.quantipixels.gbeewa/com_quantipixels_gbeewa_ResultStore$stableprop|#static{}com_quantipixels_gbeewa_ResultStore$stableprop[0] @@ -31,5 +55,7 @@ final fun <#A: kotlin/Any?> com.quantipixels.gbeewa/ResultEffect(com.quantipixel final fun <#A: kotlin/Any?> com.quantipixels.gbeewa/rememberResultAsState(com.quantipixels.gbeewa/ResultKey<#A>, kotlin/Array..., kotlin/Function0<#A>, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.runtime/State<#A> // com.quantipixels.gbeewa/rememberResultAsState|rememberResultAsState(com.quantipixels.gbeewa.ResultKey<0:0>;kotlin.Array...;kotlin.Function0<0:0>;androidx.compose.runtime.Composer?;kotlin.Int){0§}[0] final fun <#A: kotlin/Any?> com.quantipixels.gbeewa/rememberSaveableResultAsState(com.quantipixels.gbeewa/ResultKey<#A>, kotlin/Array..., androidx.compose.runtime.saveable/Saver<#A, out kotlin/Any>, kotlin/Function0<#A>, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.runtime/State<#A> // com.quantipixels.gbeewa/rememberSaveableResultAsState|rememberSaveableResultAsState(com.quantipixels.gbeewa.ResultKey<0:0>;kotlin.Array...;androidx.compose.runtime.saveable.Saver<0:0,out|kotlin.Any>;kotlin.Function0<0:0>;androidx.compose.runtime.Composer?;kotlin.Int){0§}[0] final fun <#A: kotlin/Any?> com.quantipixels.gbeewa/rememberSaveableResultAsState(com.quantipixels.gbeewa/ResultKey<#A>, kotlin/Array..., kotlin/Function0<#A>, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.runtime/State<#A> // com.quantipixels.gbeewa/rememberSaveableResultAsState|rememberSaveableResultAsState(com.quantipixels.gbeewa.ResultKey<0:0>;kotlin.Array...;kotlin.Function0<0:0>;androidx.compose.runtime.Composer?;kotlin.Int){0§}[0] +final fun com.quantipixels.gbeewa/com_quantipixels_gbeewa_PendingResult_Missing$stableprop_getter(): kotlin/Int // com.quantipixels.gbeewa/com_quantipixels_gbeewa_PendingResult_Missing$stableprop_getter|com_quantipixels_gbeewa_PendingResult_Missing$stableprop_getter(){}[0] +final fun com.quantipixels.gbeewa/com_quantipixels_gbeewa_PendingResult_Value$stableprop_getter(): kotlin/Int // com.quantipixels.gbeewa/com_quantipixels_gbeewa_PendingResult_Value$stableprop_getter|com_quantipixels_gbeewa_PendingResult_Value$stableprop_getter(){}[0] final fun com.quantipixels.gbeewa/com_quantipixels_gbeewa_ResultConsumer$stableprop_getter(): kotlin/Int // com.quantipixels.gbeewa/com_quantipixels_gbeewa_ResultConsumer$stableprop_getter|com_quantipixels_gbeewa_ResultConsumer$stableprop_getter(){}[0] final fun com.quantipixels.gbeewa/com_quantipixels_gbeewa_ResultStore$stableprop_getter(): kotlin/Int // com.quantipixels.gbeewa/com_quantipixels_gbeewa_ResultStore$stableprop_getter|com_quantipixels_gbeewa_ResultStore$stableprop_getter(){}[0] diff --git a/gbeewa/src/androidUnitTest/kotlin/com/quantipixels/gbeewa/ResultTypeContractAndroidTest.kt b/gbeewa/src/androidUnitTest/kotlin/com/quantipixels/gbeewa/ResultTypeContractAndroidTest.kt index f1bdedb..0756fc8 100644 --- a/gbeewa/src/androidUnitTest/kotlin/com/quantipixels/gbeewa/ResultTypeContractAndroidTest.kt +++ b/gbeewa/src/androidUnitTest/kotlin/com/quantipixels/gbeewa/ResultTypeContractAndroidTest.kt @@ -22,4 +22,22 @@ class ResultTypeContractAndroidTest { assertFalse(StringKey in consumer) } + + @Test + fun `direct typed contract bypass fails on value access after removal`() { + val store = ResultStore() + val consumer = ResultConsumer(store) + @Suppress("UNCHECKED_CAST") + val bypassedKey = StringKey as ResultKey + store.put(bypassedKey, 7) + + val result = consumer.consume(StringKey) + + assertFailsWith { + @Suppress("UNCHECKED_CAST") + val value = (result as PendingResult.Value).value + value.length + } + assertFalse(StringKey in consumer) + } } diff --git a/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/PendingResult.kt b/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/PendingResult.kt new file mode 100644 index 0000000..53f3f61 --- /dev/null +++ b/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/PendingResult.kt @@ -0,0 +1,19 @@ +package com.quantipixels.gbeewa + +/** + * Describes whether a [ResultConsumer] removed a pending result. + * + * A nullable result is represented by [Value] with a `null` [Value.value]. It is distinct from + * [Missing], which means that the key had no pending result. + * + * @param T the payload type declared by the consumed result key. + */ +public sealed interface PendingResult { + /** No pending result existed for the requested key. */ + public data object Missing : PendingResult + + /** A pending [value] was removed for the requested key. */ + public data class Value public constructor( + public val value: T, + ) : PendingResult +} diff --git a/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultConsumer.kt b/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultConsumer.kt index fea8d8a..83b7aef 100644 --- a/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultConsumer.kt +++ b/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultConsumer.kt @@ -18,6 +18,20 @@ public class ResultConsumer public constructor( /** Returns `true` when [key] has a pending result. */ public operator fun contains(key: ResultKey<*>): Boolean = key in store + /** + * Removes and returns the pending result for [key]. + * + * This function returns [PendingResult.Missing] when the key has no pending result. A present + * nullable result returns [PendingResult.Value] whose value is `null`. + * + * Direct consumption is intended for event handlers, effects, and non-Compose code. Do not + * call it during composition because removal changes observable Compose state. + * + * @param key the typed result lane to consume. + * @return the removed value or [PendingResult.Missing]. + */ + public fun consume(key: ResultKey): PendingResult = store.remove(key) + /** * Removes and delivers the pending result for [key]. * @@ -30,6 +44,10 @@ public class ResultConsumer public constructor( * @param key the typed result lane to consume. * @param onResult the non-suspending callback that receives a present result. */ - public fun consume(key: ResultKey, onResult: (T) -> Unit): Unit = - store.remove(key, onResult) + public fun consume(key: ResultKey, onResult: (T) -> Unit) { + when (val result = consume(key)) { + PendingResult.Missing -> Unit + is PendingResult.Value -> onResult(result.value) + } + } } diff --git a/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultStore.kt b/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultStore.kt index 85fae9e..5ff53c4 100644 --- a/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultStore.kt +++ b/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultStore.kt @@ -38,11 +38,11 @@ public class ResultStore public constructor() { */ public operator fun contains(key: ResultKey<*>): Boolean = key in pendingResults - internal fun remove(key: ResultKey, onResult: (T) -> Unit) { - if (key !in pendingResults) return + internal fun remove(key: ResultKey): PendingResult { + if (key !in pendingResults) return PendingResult.Missing @Suppress("UNCHECKED_CAST") val result = pendingResults.remove(key) as T - onResult(result) + return PendingResult.Value(result) } } diff --git a/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultStoreTest.kt b/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultStoreTest.kt index fa24a4e..e057973 100644 --- a/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultStoreTest.kt +++ b/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultStoreTest.kt @@ -16,6 +16,62 @@ class ResultStoreTest { private object NullableKey : ResultKey private data class RequestKey(val requestId: String) : ResultKey + @Test + fun `direct consumption returns a pending result once`() { + val store = ResultStore() + val consumer = ResultConsumer(store) + + assertEquals(PendingResult.Missing, consumer.consume(CountryKey)) + + store.put(CountryKey, "NG") + + assertEquals(PendingResult.Value("NG"), consumer.consume(CountryKey)) + assertFalse(CountryKey in consumer) + assertEquals(PendingResult.Missing, consumer.consume(CountryKey)) + } + + @Test + fun `direct consumption distinguishes a nullable value from a missing result`() { + val store = ResultStore() + val consumer = ResultConsumer(store) + + store.put(NullableKey, null) + + assertEquals(PendingResult.Value(null), consumer.consume(NullableKey)) + assertEquals(PendingResult.Missing, consumer.consume(NullableKey)) + } + + @Test + fun `direct consumption returns the latest result for the same key`() { + val store = ResultStore() + val consumer = ResultConsumer(store) + + store.put(IndexKey, 1) + store.put(IndexKey, 2) + + assertEquals(PendingResult.Value(2), consumer.consume(IndexKey)) + } + + @Test + fun `callback and direct consumers compete for one pending result`() { + val store = ResultStore() + val callbackConsumer = ResultConsumer(store) + val directConsumer = ResultConsumer(store) + val delivered = mutableListOf() + + store.put(CountryKey, "callback") + callbackConsumer.consume(CountryKey, delivered::add) + + assertEquals(PendingResult.Missing, directConsumer.consume(CountryKey)) + assertEquals(listOf("callback"), delivered) + + store.put(CountryKey, "direct") + + assertEquals(PendingResult.Value("direct"), directConsumer.consume(CountryKey)) + callbackConsumer.consume(CountryKey, delivered::add) + assertEquals(listOf("callback"), delivered) + } + @Test fun `consumer delivers a pending result once`() { val state = ResultStore()