Skip to content

Proposal: pending dispatch policy on state transitions #164

@hkusu

Description

@hkusu

Summary

Add an optional policy to control what happens to queued dispatch calls when the state class changes.

Motivation

Different apps need different behavior:

  • Keep queued actions (current behavior)
  • Drop stale queued actions on state-class transition

Making either behavior unconditional is problematic, so this should be configurable.

Proposed API

// tart-core/src/commonMain/kotlin/io/yumemi/tart/core/PendingDispatchPolicy.kt
enum class PendingDispatchPolicy {
    KEEP_ALL,
    DROP_ON_STATE_CLASS_TRANSITION,
}

// tart-core/src/commonMain/kotlin/io/yumemi/tart/core/StoreBuilder.kt
@TartStoreDsl
class StoreBuilder<S : State, A : Action, E : Event> {
    fun pendingDispatchPolicy(policy: PendingDispatchPolicy)
}

Semantics

  • KEEP_ALL:

    • Preserve current behavior
    • Do not cancel queued dispatch jobs
  • DROP_ON_STATE_CLASS_TRANSITION:

    • Only when state::class != nextState::class, cancel queued dispatch jobs
    • Keep the currently running dispatch alive
    • Limit cancellation target to dispatch scope children only (do not affect collectors or state-enter scopes)

Example

val store = Store<AppState, AppAction, AppEvent>(AppState.Initial) {
    pendingDispatchPolicy(PendingDispatchPolicy.DROP_ON_STATE_CLASS_TRANSITION)

    state<AppState.Initial> {
        action<AppAction.Start> { nextState(AppState.Loading) }
    }

    state<AppState.Loading> {
        action<AppAction.Done> { nextState(AppState.Main) }
    }
}

Test Plan

  • Default (KEEP_ALL) keeps queued dispatches
  • DROP_ON_STATE_CLASS_TRANSITION drops queued dispatches on class transition
  • Same-state-class updates do not drop queued dispatches
  • Running dispatch is not cancelled mid-transition

StoreImpl Changes (Concrete Example)

internal abstract class StoreImpl<S : State, A : Action, E : Event> : Store<S, A, E> {
    protected abstract val pendingDispatchPolicy: PendingDispatchPolicy

    private val dispatchScope by lazy {
        CoroutineScope(coroutineScope.coroutineContext + SupervisorJob(coroutineScope.coroutineContext[Job]))
    }

    final override fun dispatch(action: A) {
        dispatchScope.launch {
            mutex.withLock {
                initializeIfNeeded()
                onActionDispatched(currentState, action)
            }
        }
    }

    private suspend fun onActionDispatched(state: S, action: A) {
        val nextState = processActionDispatch(state, action)
        if (state::class != nextState::class) {
            maybeClearPendingDispatchJobs(state, nextState)
            processStateExit(state)
        }
        if (state != nextState) processStateChange(state, nextState)
        if (state::class != nextState::class) onStateEntered(nextState)
    }

    private suspend fun onStateChanged(state: S, nextState: S) {
        if (state::class != nextState::class) {
            maybeClearPendingDispatchJobs(state, nextState)
            processStateExit(state)
        }
        if (state != nextState) processStateChange(state, nextState)
        if (state::class != nextState::class) onStateEntered(nextState)
    }

    private suspend fun onStateEntered(state: S, inErrorHandling: Boolean = false) {
        val nextState = processStateEnter(state)
        if (state::class != nextState::class) {
            maybeClearPendingDispatchJobs(state, nextState)
            processStateExit(state)
        }
        if (state != nextState) processStateChange(state, nextState)
        if (state::class != nextState::class) onStateEntered(nextState, inErrorHandling)
    }

    private suspend fun onErrorOccurred(state: S, throwable: Throwable) {
        val nextState = processError(state, throwable)
        if (state::class != nextState::class) {
            maybeClearPendingDispatchJobs(state, nextState)
            processStateExit(state)
        }
        if (state != nextState) processStateChange(state, nextState)
        if (state::class != nextState::class) onStateEntered(nextState, inErrorHandling = true)
    }

    private suspend fun maybeClearPendingDispatchJobs(state: S, nextState: S) {
        val shouldClear = when (pendingDispatchPolicy) {
            PendingDispatchPolicy.KEEP_ALL -> false
            PendingDispatchPolicy.DROP_ON_STATE_CLASS_TRANSITION -> state::class != nextState::class
        }
        if (shouldClear) clearPendingDispatchJobs()
    }

    private suspend fun clearPendingDispatchJobs() {
        val currentJob = currentCoroutineContext()[Job]
        val dispatchScopeJob = dispatchScope.coroutineContext[Job] ?: return
        dispatchScopeJob.children
            .filter { it != currentJob && it.isActive }
            .forEach { it.cancel() }
    }
}

Notes:

  • maybeClearPendingDispatchJobs(...) should be called at every state-class transition entry point (onActionDispatched, onStateChanged, onStateEntered, onErrorOccurred) so behavior is consistent regardless of transition source.
  • clearPendingDispatchJobs() must only target children of dispatchScope.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions