Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@ 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.
- Add consume-once Compose effects plus in-memory and saveable result-to-state helpers.
- 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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,8 @@ The showcase iOS application targets iOS 17.2. This value is the host applicatio
| --- | --- |
| `ResultKey<T>` | 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<T>` | 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. |
Expand Down Expand Up @@ -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<T>` from the root-owned `ResultStore`. Extra remember keys reset the initial value when their context changes:

```kotlin
Expand Down Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions gbeewa/api/gbeewa.api
Original file line number Diff line number Diff line change
@@ -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 <init> (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;
Expand All @@ -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 <init> (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
}
Expand Down
26 changes: 26 additions & 0 deletions gbeewa/api/gbeewa.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,31 @@
// Library unique name: <com.quantipixels:gbeewa>
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 <init>(#A1) // com.quantipixels.gbeewa/PendingResult.Value.<init>|<init>(1:0){}[0]

final val value // com.quantipixels.gbeewa/PendingResult.Value.value|{}value[0]
final fun <get-value>(): #A1 // com.quantipixels.gbeewa/PendingResult.Value.value.<get-value>|<get-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<kotlin/Nothing> { // 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 <init>(com.quantipixels.gbeewa/ResultStore) // com.quantipixels.gbeewa/ResultConsumer.<init>|<init>(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§<kotlin.Any?>}[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§<kotlin.Any?>}[0]
final fun contains(com.quantipixels.gbeewa/ResultKey<*>): kotlin/Boolean // com.quantipixels.gbeewa/ResultConsumer.contains|contains(com.quantipixels.gbeewa.ResultKey<*>){}[0]
}
Expand All @@ -24,12 +46,16 @@ final class com.quantipixels.gbeewa/ResultStore { // com.quantipixels.gbeewa/Res

final val com.quantipixels.gbeewa/LocalResultConsumer // com.quantipixels.gbeewa/LocalResultConsumer|{}LocalResultConsumer[0]
final fun <get-LocalResultConsumer>(): androidx.compose.runtime/ProvidableCompositionLocal<com.quantipixels.gbeewa/ResultConsumer> // com.quantipixels.gbeewa/LocalResultConsumer.<get-LocalResultConsumer>|<get-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]

final fun <#A: kotlin/Any?> com.quantipixels.gbeewa/ResultEffect(com.quantipixels.gbeewa/ResultKey<#A>, kotlin/Function1<#A, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int) // com.quantipixels.gbeewa/ResultEffect|ResultEffect(com.quantipixels.gbeewa.ResultKey<0:0>;kotlin.Function1<0:0,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int){0§<kotlin.Any?>}[0]
final fun <#A: kotlin/Any?> com.quantipixels.gbeewa/rememberResultAsState(com.quantipixels.gbeewa/ResultKey<#A>, kotlin/Array<out kotlin/Any?>..., 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<out|kotlin.Any?>...;kotlin.Function0<0:0>;androidx.compose.runtime.Composer?;kotlin.Int){0§<kotlin.Any?>}[0]
final fun <#A: kotlin/Any?> com.quantipixels.gbeewa/rememberSaveableResultAsState(com.quantipixels.gbeewa/ResultKey<#A>, kotlin/Array<out kotlin/Any?>..., 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<out|kotlin.Any?>...;androidx.compose.runtime.saveable.Saver<0:0,out|kotlin.Any>;kotlin.Function0<0:0>;androidx.compose.runtime.Composer?;kotlin.Int){0§<kotlin.Any?>}[0]
final fun <#A: kotlin/Any?> com.quantipixels.gbeewa/rememberSaveableResultAsState(com.quantipixels.gbeewa/ResultKey<#A>, kotlin/Array<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<out|kotlin.Any?>...;kotlin.Function0<0:0>;androidx.compose.runtime.Composer?;kotlin.Int){0§<kotlin.Any?>}[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]
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>
store.put(bypassedKey, 7)

val result = consumer.consume(StringKey)

assertFailsWith<ClassCastException> {
@Suppress("UNCHECKED_CAST")
val value = (result as PendingResult.Value<String>).value
value.length
}
assertFalse(StringKey in consumer)
}
}
Original file line number Diff line number Diff line change
@@ -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<out T> {
/** No pending result existed for the requested key. */
public data object Missing : PendingResult<Nothing>

/** A pending [value] was removed for the requested key. */
public data class Value<out T> public constructor(
public val value: T,
) : PendingResult<T>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T> consume(key: ResultKey<T>): PendingResult<T> = store.remove(key)

/**
* Removes and delivers the pending result for [key].
*
Expand All @@ -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 <T> consume(key: ResultKey<T>, onResult: (T) -> Unit): Unit =
store.remove(key, onResult)
public fun <T> consume(key: ResultKey<T>, onResult: (T) -> Unit) {
when (val result = consume(key)) {
PendingResult.Missing -> Unit
is PendingResult.Value -> onResult(result.value)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ public class ResultStore public constructor() {
*/
public operator fun contains(key: ResultKey<*>): Boolean = key in pendingResults

internal fun <T> remove(key: ResultKey<T>, onResult: (T) -> Unit) {
if (key !in pendingResults) return
internal fun <T> remove(key: ResultKey<T>): PendingResult<T> {
if (key !in pendingResults) return PendingResult.Missing

@Suppress("UNCHECKED_CAST")
val result = pendingResults.remove(key) as T
onResult(result)
return PendingResult.Value(result)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,62 @@ class ResultStoreTest {
private object NullableKey : ResultKey<String?>
private data class RequestKey(val requestId: String) : ResultKey<String>

@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<String>()

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()
Expand Down