Skip to content

Incorrect behavior of OnetimeWhileSubscribed with buffered Flow operators: upstream flow may be canceled before it has a chance to emit a single value #4

Description

@cbeyls

Library Version: 0.1.0

Describe the Bug:

The documentation of OnetimeWhileSubscribed says:

OnetimeWhileSubscribed is a SharingStarted strategy that ensures the upstream flow emits only once while a subscriber is active

This is incorrect. OnetimeWhileSubscribed only ensures that the coroutine collecting the upstream Flow is launched at most once, which is different.

Launching a coroutine once doesn't guarantee that the Flow it collects will have the opportunity to emit at least one value. If the coroutine is stopped before the Flow could emit at least one value (because of a slow HTTP request for example), then no value will ever be emitted because OnetimeWhileSubscribed will not start the coroutine a second time.

To go into technical details, even if the hasCollected flag is set after the suspending function emit(SharingCommand.START) returns, it doesn't mean that at that point a value has reached the StateFlow/SharedFlow cache downstream, because some Flow operators like buffer() or mapLatest() are buffered: in case of buffering, when emit() returns the emitted value is in a downstream buffer but has not yet been processed further down the chain.

In summary, the current implementation doesn't work reliably and should not be used.

Expected Behavior:

Instead of using a custom SharingStarted strategy like OnetimeWhileSubcribed acting upstream, the onetimeStateIn() implementation should collect the upstream Flow directly and only set the hasCollected flag after emitting the value directly to the downstream SharedFlow/StateFlow. This is the only reliable way to detect that the upstream Flow emitted at least one value and that this value has reached the SharedFlow/StateFlow cache.

Something like this:

fun <T> Flow<T>.onetimeStateIn(
    scope: CoroutineScope,
    started: SharingStarted,
    initialValue: T,
): StateFlow<T> {
    var hasCollected = false
    return flow {
        if (!hasCollected) {
            collect {
                emit(it)
                hasCollected = true
            }
        }
    }.stateIn(
        scope = scope,
        started = started,
        initialValue = initialValue
    )
}

Or better, set the flag after the upstream Flow completes (similarly to what the LiveData coroutine builder does):

fun <T> Flow<T>.onetimeStateIn(
    scope: CoroutineScope,
    started: SharingStarted,
    initialValue: T,
): StateFlow<T> {
    var hasCollected = false
    return flow {
        if (!hasCollected) {
            collect(this)
            hasCollected = true
        }
    }.stateIn(
        scope = scope,
        started = started,
        initialValue = initialValue,
    )
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions