Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,20 @@ suspend fun <T> Reactive<T>.await(): T {
} ?: awaitOnce()
}

/**
* Reads the current value, waiting for one if there isn't one yet.
*
* A ready state is taken as-is, with no subscription: readiness means some other listener - or the
* source's own nature, as with a `Signal` - is keeping that value current. Only [ReactiveState.notActive]
* and notReady require listening, and the subscription is released as soon as a value arrives.
*/
suspend fun <T> Reactive<T>.awaitOnce(): T {
val state = state
@Suppress("DEPRECATION")
return if (state.ready) state.get()
else suspendCancellableCoroutine {
// If it's not ready, we need to wait until it is then never bother with this again.
// Not ready, or nothing is maintaining a value: either way we have to listen, which is
// also what activates a lazy source so it calculates one.
var remover: (() -> Unit)? = null
var alreadyRun = false
var done = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,31 @@ abstract class DependencyTracker {

protected val dependencyCount: Int get() = dependencies.size

/**
* Looks up a dependency already tracked for this run, marking it used.
*
* Fast path: if dependencies are read in the same order every run (the steady-state case),
* the dependency due to be read next always sits at [dependencies]`[usedDependencies.size]` -
* i.e. the slot immediately following the ones already marked used this run. Checking that one
* slot avoids the O(n) [dependencies].find fallback below, keeping a rerun over n dependencies
* O(n) instead of O(n^2).
*
* A dependency read more than once in the same run is only added to [usedDependencies] once,
* so the fast-path alignment above holds even when a call site re-reads an earlier dependency.
*/
@Suppress("UNCHECKED_CAST")
fun <T : Any> existingDependency(listenable: T): T? {
usedDependencies.add(listenable)
if (dependencies.size > usedDependencies.size) {
val maybe = dependencies[usedDependencies.size].first
if (maybe == listenable) return maybe as T
val index = usedDependencies.size
if (index < dependencies.size) {
val maybe = dependencies[index].first
if (maybe == listenable) {
usedDependencies.add(listenable)
return maybe as T
}
}
return dependencies.find { it.first == listenable }?.first as? T
val found = dependencies.find { it.first == listenable }?.first as? T
if (listenable !in usedDependencies) usedDependencies.add(listenable)
return found
}

fun registerDependency(any: Any, remove: () -> Unit) {
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,6 @@ class ReactiveContextSuspending<T>(
*
* The calculation may complete synchronously (if already on correct dispatcher and no suspension points)
* or asynchronously. If [useLastWhileLoading] is false, the state is set to notReady during async execution.
*
* If the calculation has no dependencies after completion, the context is automatically
* cancelled to release resources (since it will never rerun).
*/
fun startCalculation() {
active = true
Expand Down Expand Up @@ -194,33 +191,6 @@ class ReactiveContextSuspending<T>(
}
}

/**
* Runs the calculation once without activating the context or tracking dependencies.
*
* This is useful for getting an initial value or running the calculation in a test
* environment without setting up the full reactive machinery.
*
* The result is still reported to [reportTo], but no dependency tracking occurs and
* the calculation will not rerun when dependencies change.
*/
fun runOnceWhileDead() {
lastLoopJob = run {
var done = false
val job = scope.launchWithStart {
val result = reactiveState { this@ReactiveContextSuspending.action() }
if (!useLastWhileLoading || result.ready) reportTo.state = result
done = true
}

// Check if calculation completed synchronously
if (done) null
else {
if (!useLastWhileLoading) reportTo.state = ReactiveState.notReady
job
}
}
}

/**
* Called by the dependency tracker when a dependency changes.
* Triggers a recalculation with [startCalculation].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import kotlin.time.Duration
* is manually set it will stop the calculation and instead behave like a [Signal].
*
* Note:
* - `mutableRemember` is lazy: if it has no listeners, it will not calculate a value.
* - `mutableRemember` is lazy: if it has no listeners, it will not calculate a value. Reading its state while it has none, and has not been set, reports [ReactiveState.Companion.notActive].
* - Listeners are only notified if the calculated or set value changes.
*
* Example:
Expand Down Expand Up @@ -46,7 +46,7 @@ fun <T> mutableRemember(
* When overridden by direct assignment, automatic calculation is paused until `reset()` is called.
*
* Note:
* - `MutableRemember` is lazy: if it has no listeners, it will not calculate a value.
* - `MutableRemember` is lazy: if it has no listeners, it will not calculate a value. Reading its state while it has none, and has not been set, reports [ReactiveState.Companion.notActive].
* - Listeners are only notified if the calculated or set value changes.
* - The `reset()` method restores automatic calculation and updates the value from dependencies.
*
Expand All @@ -67,19 +67,15 @@ class MutableRemember<T>(
var overridden: Boolean = false
private set

private val remember = Remember(coroutineContext, useLastWhileLoading, deactivationDelay, initialValue)
private val remember = Remember(coroutineContext, useLastWhileLoading, deactivationDelay, action = initialValue)
private var forget: (()->Unit)? = null

private fun updateOnce() {
val currentRememberedState = remember.state
if(!overridden && (!useLastWhileLoading || currentRememberedState.ready)) state = currentRememberedState
}

private fun startListening() {
forget = remember.addListener {
if (!overridden) state = remember.state
}
updateOnce()
val currentRememberedState = remember.state
if (!overridden && (!useLastWhileLoading || currentRememberedState.ready)) state = currentRememberedState
}
private fun stopListening() {
forget?.invoke()
Expand All @@ -88,7 +84,7 @@ class MutableRemember<T>(

override var state: ReactiveState<T>
get() {
if (!overridden && forget == null) updateOnce()
if (!overridden && forget == null) return ReactiveState.notActive
return super.state
}
set(value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import kotlin.time.Duration
* is manually set it will stop the calculation and instead behave like a [Signal].
*
* Note:
* - `mutableRememberSuspending` is lazy: if it has no listeners, it will not calculate a value.
* - `mutableRememberSuspending` is lazy: if it has no listeners, it will not calculate a value. Reading its state while it has none, and has not been set, reports [ReactiveState.Companion.notActive].
* - Listeners are only notified if the calculated or set value changes.
*
* Example:
Expand Down Expand Up @@ -47,7 +47,7 @@ fun <T> mutableRememberSuspending(
* When overridden by direct assignment, automatic calculation is paused until `reset()` is called.
*
* Note:
* - lazy: if this has no listeners, it will not calculate a value.
* - lazy: if this has no listeners, it will not calculate a value. Reading its state while it has none, and has not been set, reports [ReactiveState.Companion.notActive].
* - Listeners are only notified if the calculated or set value changes. I.e., if '1' is calculated, and then '1' is set, it will not notify listeners.
* - The `reset()` method restores automatic calculation and updates the value from dependencies.
*
Expand Down Expand Up @@ -90,7 +90,7 @@ class MutableRememberSuspending<T>(

override var state: ReactiveState<T>
get() {
if (!overridden && forget == null) updateOnce()
if (!overridden && forget == null) return ReactiveState.notActive
return super.state
}
set(value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,34 @@ import kotlin.jvm.JvmInline
/**
* Represents the state of a reactive value, including loading, success, and error conditions.
*
* - A [ReactiveState] can hold a ready value, a loading state, or an error state.
* - A [ReactiveState] can hold a ready value, a loading state, an error state, or [notActive].
* - Use [ready] to check if the value is available, [success] to check if it is available and not an error, and [exception] to retrieve any error.
* - Listeners of [Reactive] are only notified when the [ReactiveState] changes.
* - [ReactiveState] provides methods for safely handling, mapping, and retrieving the underlying value.
*
* ### notReady versus notActive
*
* Both mean "no value", for different reasons, and both make [ready] false:
*
* - [Companion.notReady]: something is maintaining this value, and it does not have one right now -
* it is loading, or one of its dependencies is not ready.
* - [Companion.notActive]: *nothing* is maintaining this value. Lazy reactives like `remember` only
* calculate while they have listeners, so with none they cannot vouch for any value. Sources whose
* value is accurate whether or not anyone is listening - `Signal`, `Constant` - never report it.
*
* The distinction is what makes it safe to read a value without subscribing: anything other than
* [notActive] is current, no matter who is (or isn't) listening. On [notActive] you must subscribe -
* see `awaitOnce` - or accept that there is no value to be had.
*/
@JvmInline
@OptIn(InternalReactiveApi::class)
value class ReactiveState<out T>(val raw: T) {
inline val ready: Boolean get() = raw !is InternalReactiveNotReady
inline val ready: Boolean get() = raw != InternalReactiveNotReady && raw != InternalReactiveNotActive
inline val success: Boolean get() = ready && raw !is InternalReactiveThrownException

/** True when nothing is maintaining this value; see the [ReactiveState] docs. */
inline val notActive: Boolean get() = raw is InternalReactiveNotActive

inline fun <R> onSuccess(action: (T)->R): R? = handle(
success = { action(it) },
exception = { null },
Expand All @@ -28,7 +46,8 @@ value class ReactiveState<out T>(val raw: T) {
fun get(): T = handle(
success = { it },
exception = { throw it },
notReady = { throw NotReadyException() }
notReady = { throw NotReadyException() },
notActive = { throw NotActiveException() }
)

fun getOrNull(): T? = handle(
Expand All @@ -40,14 +59,21 @@ value class ReactiveState<out T>(val raw: T) {
companion object Companion {
@Suppress("UNCHECKED_CAST")
val notReady: ReactiveState<Nothing> = ReactiveState<Any?>(InternalReactiveNotReady) as ReactiveState<Nothing>

/** No value, because nothing is maintaining one; see the [ReactiveState] docs. */
@Suppress("UNCHECKED_CAST")
val notActive: ReactiveState<Nothing> = ReactiveState<Any?>(InternalReactiveNotActive) as ReactiveState<Nothing>

@Suppress("UNCHECKED_CAST")
fun <T> exception(exception: Exception) = (if(exception is CancellationException) notReady else ReactiveState<Any?>(InternalReactiveThrownException(exception))) as ReactiveState<T>
@Suppress("UNCHECKED_CAST")
fun <T> wrap(value: T) = ReactiveState<Any?>(InternalReactiveWrapper(value)) as ReactiveState<T>
}
@Suppress("UNCHECKED_CAST")
inline fun <B> map(mapper: (T)->B): ReactiveState<B> {
if(raw is InternalReactiveNotReady || raw is InternalReactiveThrownException) return this as ReactiveState<B>
// notActive propagates like the other valueless states: a value derived from a source
// nobody is maintaining is equally unmaintained.
if(raw is InternalReactiveNotReady || raw is InternalReactiveNotActive || raw is InternalReactiveThrownException) return this as ReactiveState<B>
if(raw is InternalReactiveWrapper<*>) try {
return ReactiveState(mapper(raw.other as T))
} catch(e: Exception) {
Expand All @@ -59,24 +85,43 @@ value class ReactiveState<out T>(val raw: T) {
exception(e)
}
}
@Suppress("UNCHECKED_CAST")
/**
* Handles [Companion.notActive] the same way as [notReady], because "nobody is maintaining a
* value" and "there is no value yet" are the same thing to code that only wants to display or
* wait for one. Use the four-argument overload to do something better, such as subscribing.
*/
inline fun <R> handle(
success: (T)->R,
exception: (Exception)->R,
notReady: ()->R
): R = handle(success, exception, notReady, notReady)

@Suppress("UNCHECKED_CAST")
inline fun <R> handle(
success: (T)->R,
exception: (Exception)->R,
notReady: ()->R,
notActive: ()->R
): R {
return when(raw) {
InternalReactiveNotReady -> notReady()
InternalReactiveNotActive -> notActive()
is InternalReactiveThrownException -> exception(raw.exception)
is InternalReactiveWrapper<*> -> success(raw.other as T)
else -> success(raw)
}
}

fun asResult(): Result<T> = handle(success = { Result.success(it) }, exception = { Result.failure(it) }, notReady = { Result.failure(NotReadyException()) })
fun asResult(): Result<T> = handle(
success = { Result.success(it) },
exception = { Result.failure(it) },
notReady = { Result.failure(NotReadyException()) },
notActive = { Result.failure(NotActiveException()) }
)

override fun toString(): String = when(raw) {
is InternalReactiveNotReady -> "NotReady"
is InternalReactiveNotActive -> "NotActive"
is InternalReactiveThrownException -> "ThrownException(${raw.exception})"
is InternalReactiveWrapper<*> -> "ReadyW($raw)"
else -> "Ready($raw)"
Expand All @@ -88,15 +133,27 @@ data class InternalReactiveWrapper<T>(val other: T)
data class InternalReactiveThrownException(val exception: Exception)
@InternalReactiveApi
object InternalReactiveNotReady
@InternalReactiveApi
object InternalReactiveNotActive

class NotReadyException(message: String? = null) : IllegalStateException(message)
open class NotReadyException(message: String? = null) : IllegalStateException(message)

/**
* Thrown when reading a value from a reactive that nothing is listening to, and which therefore
* has no value to give. Subscribe to it first, or use `awaitOnce`, which subscribes for as long
* as it takes to obtain a value.
*/
class NotActiveException(message: String = "Nothing is listening to this reactive value, so it has no value to report. Subscribe to it, or use awaitOnce.") : NotReadyException(message)

inline fun <T> reactiveState(action: () -> T): ReactiveState<T> {
@OptIn(InternalReactiveApi::class)
return try {
ReactiveState(action())
} catch (_: CancellationException) {
ReactiveState.notReady
} catch (e: CancellationException) {
// A cancellation means the coroutine running `action` was torn down mid-calculation - it
// must propagate so the caller's suspension point actually stops, rather than being
// reinterpreted as "not ready" and letting the calculation resume past its cancellation.
throw e
} catch (_: ReactiveLoading) {
ReactiveState.notReady
} catch (e: Exception) {
Expand Down
Loading
Loading