Skip to content

Commit 678d29b

Browse files
authored
Merge pull request #172 from yumemi-inc/codex/issue-166-pending-actions
Add pending action cancellation API
2 parents d83fef6 + d6dcbca commit 678d29b

File tree

4 files changed

+238
-21
lines changed

4 files changed

+238
-21
lines changed

README.md

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ By combining Tart with Kotlin `sealed class`/`sealed interface`, you can model e
4545
- [Multiple states and transitions](#multiple-states-and-transitions)
4646
- [Error handling](#error-handling)
4747
- [Asynchronous Work](#asynchronous-work)
48+
- [Cancel Pending Actions](#cancel-pending-actions)
49+
- [Alternative DSL Forms](#alternative-dsl-forms)
4850
- [Specifying coroutineContext](#specifying-coroutinecontext)
4951
- [Specifying CoroutineDispatchers](#specifying-coroutinedispatchers)
5052
- [State Persistence](#state-persistence)
@@ -117,20 +119,6 @@ val store: Store<CounterState, CounterAction, Nothing> = Store(CounterState(coun
117119
}
118120
```
119121

120-
`anyState{}` lets you register handlers for all *States*, and `anyAction{}` can be used there as a fallback for all *Actions*.
121-
122-
For conditional or complex updates, use `nextStateBy{}` to compute and return the new state.
123-
124-
```kt
125-
nextStateBy {
126-
// ...
127-
128-
val newCount = ...
129-
130-
state.copy(count = newCount)
131-
}
132-
```
133-
134122
The *Store* setup is complete.
135123
Keep the store instance in a ViewModel (or similar).
136124

@@ -360,8 +348,6 @@ val store: Store<CounterState, CounterAction, CounterEvent> = Store {
360348
}
361349
```
362350
363-
`error<Exception>{}` can also be written as `anyError{}`.
364-
365351
Errors can be caught not only in the `enter{}` block but also in the `action{}` and `exit{}` blocks.
366352
In other words, your business logic errors can be handled in the `error{}` block.
367353
@@ -418,12 +404,46 @@ state<MyState.Active> {
418404
}
419405
```
420406
421-
If you prefer shorter forms, use `enterAsync{}` / `actionAsync{}` (or `anyActionAsync{}` when using `anyAction{}`).
422-
423407
This pattern lets your *Store* react to external data changes automatically, such as database updates, user preference changes, or network events.
424408
Coroutines started by `launch{}` are automatically cancelled when the *State* changes to a different *State*, making it easy to manage resources and subscriptions.
425409
In `action{}`, `launch{}` is tied to the *State* active at action start.
426410
411+
### Cancel Pending Actions
412+
413+
If you need to discard already queued `dispatch()` calls at a specific point, call `cancelPendingActions()` inside `enter{}`, `action{}`, `exit{}`, `error{}`, or inside `transaction{}` from a launched coroutine.
414+
415+
```kt
416+
state<MyState.Active> {
417+
action<MyAction.Finish> {
418+
cancelPendingActions()
419+
nextState(MyState.Done)
420+
}
421+
}
422+
```
423+
424+
### Alternative DSL Forms
425+
426+
Some DSL APIs are just alternative forms of existing APIs:
427+
428+
- `anyState{}` is the global form of `state<...>{}` and applies to all *States*.
429+
- `anyAction{}` is the global form of `action<...>{}` and applies to all *Actions*.
430+
- `anyError{}` is an alias of `error<Exception>{}`.
431+
- `enterAsync{}` is shorthand for `enter { launch { ... } }`.
432+
- `actionAsync{}` is shorthand for `action<...> { launch { ... } }`.
433+
- `anyActionAsync{}` is shorthand for `anyAction { launch { ... } }`.
434+
435+
Instead of `nextState(...)`, use `nextStateBy{}` when you want to compute and return the next state inside a block.
436+
437+
```kt
438+
nextStateBy {
439+
// ...
440+
441+
val newCount = ...
442+
443+
state.copy(count = newCount)
444+
}
445+
```
446+
427447
### Specifying coroutineContext
428448
429449
The Store operates using Coroutines, and the default CoroutineContext is `EmptyCoroutineContext + Dispatchers.Default`.

tart-core/src/commonMain/kotlin/io/yumemi/tart/core/StoreImpl.kt

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,15 +86,31 @@ internal abstract class StoreImpl<S : State, A : Action, E : Event> : Store<S, A
8686
)
8787
}
8888

89+
private val dispatchScope by lazy {
90+
CoroutineScope(
91+
coroutineScope.coroutineContext + SupervisorJob(coroutineScope.coroutineContext[Job]),
92+
)
93+
}
94+
8995
private val mutex = Mutex()
9096

9197
private val stateScopes = mutableMapOf<KClass<out S>, CoroutineScope>()
9298

99+
private var activeDispatchJob: Job? = null
100+
93101
final override fun dispatch(action: A) {
94-
coroutineScope.launch {
102+
dispatchScope.launch {
95103
mutex.withLock {
96-
initializeIfNeeded()
97-
onActionDispatched(currentState, action)
104+
val dispatchJob = coroutineContext[Job]
105+
activeDispatchJob = dispatchJob
106+
try {
107+
initializeIfNeeded()
108+
onActionDispatched(currentState, action)
109+
} finally {
110+
if (activeDispatchJob == dispatchJob) {
111+
activeDispatchJob = null
112+
}
113+
}
98114
}
99115
}
100116
}
@@ -243,6 +259,10 @@ internal abstract class StoreImpl<S : State, A : Action, E : Event> : Store<S, A
243259
newState = block()
244260
}
245261

262+
override fun cancelPendingActions() {
263+
clearPendingDispatchJobs()
264+
}
265+
246266
override suspend fun event(event: E) {
247267
emit(event)
248268
}
@@ -280,6 +300,10 @@ internal abstract class StoreImpl<S : State, A : Action, E : Event> : Store<S, A
280300
newState = block()
281301
}
282302

303+
override fun cancelPendingActions() {
304+
clearPendingDispatchJobs()
305+
}
306+
283307
override suspend fun event(event: E) {
284308
emit(event)
285309
}
@@ -348,6 +372,10 @@ internal abstract class StoreImpl<S : State, A : Action, E : Event> : Store<S, A
348372
newState = block()
349373
}
350374

375+
override fun cancelPendingActions() {
376+
clearPendingDispatchJobs()
377+
}
378+
351379
override suspend fun event(event: E) {
352380
emit(event)
353381
}
@@ -399,6 +427,10 @@ internal abstract class StoreImpl<S : State, A : Action, E : Event> : Store<S, A
399427
newState = block()
400428
}
401429

430+
override fun cancelPendingActions() {
431+
clearPendingDispatchJobs()
432+
}
433+
402434
override suspend fun event(event: E) {
403435
emit(event)
404436
}
@@ -430,6 +462,11 @@ internal abstract class StoreImpl<S : State, A : Action, E : Event> : Store<S, A
430462
onExit.invoke(
431463
object : ExitScope<S, E, S> {
432464
override val state = state
465+
466+
override fun cancelPendingActions() {
467+
clearPendingDispatchJobs()
468+
}
469+
433470
override suspend fun event(event: E) {
434471
emit(event)
435472
}
@@ -469,6 +506,10 @@ internal abstract class StoreImpl<S : State, A : Action, E : Event> : Store<S, A
469506
newState = block()
470507
}
471508

509+
override fun cancelPendingActions() {
510+
clearPendingDispatchJobs()
511+
}
512+
472513
override suspend fun event(event: E) {
473514
emit(event)
474515
}
@@ -485,6 +526,14 @@ internal abstract class StoreImpl<S : State, A : Action, E : Event> : Store<S, A
485526
processMiddleware { afterEventEmit(state, event) }
486527
}
487528

529+
private fun clearPendingDispatchJobs() {
530+
val currentJob = activeDispatchJob
531+
val dispatchScopeJob = dispatchScope.coroutineContext[Job] ?: return
532+
dispatchScopeJob.children
533+
.filter { it != currentJob && it.isActive }
534+
.forEach { it.cancel() }
535+
}
536+
488537
private suspend fun processMiddleware(block: suspend Middleware<S, A, E>.() -> Unit) {
489538
try {
490539
coroutineScope {

tart-core/src/commonMain/kotlin/io/yumemi/tart/core/StoreScope.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ interface EnterScope<S : State, A : Action, E : Event, S2 : S> : StoreScope {
3636
*/
3737
fun nextStateBy(block: () -> S)
3838

39+
/**
40+
* Cancels actions that are already queued behind the currently executing store work.
41+
* The action/transaction currently in progress keeps running.
42+
*/
43+
fun cancelPendingActions()
44+
3945
/**
4046
* Emits an event from the enter handler.
4147
* Use this to communicate with the outside world about important occurrences.
@@ -111,6 +117,12 @@ interface EnterScope<S : State, A : Action, E : Event, S2 : S> : StoreScope {
111117
*/
112118
fun nextStateBy(block: () -> S)
113119

120+
/**
121+
* Cancels actions that are already queued behind the currently executing store work.
122+
* The action/transaction currently in progress keeps running.
123+
*/
124+
fun cancelPendingActions()
125+
114126
/**
115127
* Emits an event from the transaction.
116128
* Use this to communicate with the outside world about important occurrences.
@@ -133,6 +145,12 @@ interface ExitScope<S : State, E : Event, S2 : S> : StoreScope {
133145
*/
134146
val state: S2
135147

148+
/**
149+
* Cancels actions that are already queued behind the currently executing store work.
150+
* The action/transaction currently in progress keeps running.
151+
*/
152+
fun cancelPendingActions()
153+
136154
/**
137155
* Emits an event from the exit handler.
138156
* Use this to communicate with the outside world about important occurrences.
@@ -174,6 +192,12 @@ interface ActionScope<S : State, A : Action, E : Event, S2 : S> : StoreScope {
174192
*/
175193
fun nextStateBy(block: () -> S)
176194

195+
/**
196+
* Cancels actions that are already queued behind the currently executing store work.
197+
* The action/transaction currently in progress keeps running.
198+
*/
199+
fun cancelPendingActions()
200+
177201
/**
178202
* Emits an event from the action handler.
179203
* Use this to communicate with the outside world about important occurrences.
@@ -259,6 +283,12 @@ interface ActionScope<S : State, A : Action, E : Event, S2 : S> : StoreScope {
259283
*/
260284
fun nextStateBy(block: () -> S)
261285

286+
/**
287+
* Cancels actions that are already queued behind the currently executing store work.
288+
* The action/transaction currently in progress keeps running.
289+
*/
290+
fun cancelPendingActions()
291+
262292
/**
263293
* Emits an event from the transaction.
264294
* Use this to communicate with the outside world about important occurrences.
@@ -302,6 +332,12 @@ interface ErrorScope<S : State, E : Event, S2 : S, T : Throwable> : StoreScope {
302332
*/
303333
fun nextStateBy(block: () -> S)
304334

335+
/**
336+
* Cancels actions that are already queued behind the currently executing store work.
337+
* The action/transaction currently in progress keeps running.
338+
*/
339+
fun cancelPendingActions()
340+
305341
/**
306342
* Emits an event from the error handler.
307343
* Use this to communicate with the outside world about important occurrences.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package io.yumemi.tart.core
2+
3+
import kotlinx.coroutines.CompletableDeferred
4+
import kotlinx.coroutines.ExperimentalCoroutinesApi
5+
import kotlinx.coroutines.delay
6+
import kotlinx.coroutines.test.StandardTestDispatcher
7+
import kotlinx.coroutines.test.TestDispatcher
8+
import kotlinx.coroutines.test.advanceUntilIdle
9+
import kotlinx.coroutines.test.runCurrent
10+
import kotlinx.coroutines.test.runTest
11+
import kotlin.test.Test
12+
import kotlin.test.assertEquals
13+
14+
@OptIn(ExperimentalCoroutinesApi::class)
15+
class StorePendingActionCancellationTest {
16+
17+
sealed interface AppState : State {
18+
data class Active(val value: Int = 0) : AppState
19+
}
20+
21+
sealed interface AppAction : Action {
22+
data object HoldAndCancel : AppAction
23+
data object LaunchTransactionAndCancel : AppAction
24+
data object Increment : AppAction
25+
}
26+
27+
private fun createTestStore(
28+
testDispatcher: TestDispatcher,
29+
onHoldAndCancelStarted: CompletableDeferred<Unit>? = null,
30+
onHoldAndCancelCompleted: CompletableDeferred<Unit>? = null,
31+
onTransactionStarted: CompletableDeferred<Unit>? = null,
32+
onTransactionCompleted: CompletableDeferred<Unit>? = null,
33+
): Store<AppState, AppAction, Nothing> {
34+
return Store(AppState.Active()) {
35+
coroutineContext(testDispatcher)
36+
37+
state<AppState.Active> {
38+
action<AppAction.HoldAndCancel>(testDispatcher) {
39+
onHoldAndCancelStarted?.complete(Unit)
40+
delay(100)
41+
cancelPendingActions()
42+
onHoldAndCancelCompleted?.complete(Unit)
43+
nextState(state.copy(value = state.value + 100))
44+
}
45+
46+
action<AppAction.LaunchTransactionAndCancel>(testDispatcher) {
47+
launch(testDispatcher) {
48+
transaction(testDispatcher) {
49+
onTransactionStarted?.complete(Unit)
50+
delay(100)
51+
cancelPendingActions()
52+
onTransactionCompleted?.complete(Unit)
53+
nextState(state.copy(value = state.value + 100))
54+
}
55+
}
56+
}
57+
58+
action<AppAction.Increment>(testDispatcher) {
59+
nextState(state.copy(value = state.value + 1))
60+
}
61+
}
62+
}
63+
}
64+
65+
@Test
66+
fun cancelPendingActions_inAction_skipsQueuedDispatches() = runTest {
67+
val testDispatcher = StandardTestDispatcher(testScheduler)
68+
val actionStarted = CompletableDeferred<Unit>()
69+
val actionCompleted = CompletableDeferred<Unit>()
70+
val store = createTestStore(
71+
testDispatcher = testDispatcher,
72+
onHoldAndCancelStarted = actionStarted,
73+
onHoldAndCancelCompleted = actionCompleted,
74+
)
75+
76+
store.dispatch(AppAction.HoldAndCancel)
77+
runCurrent()
78+
actionStarted.await()
79+
80+
store.dispatch(AppAction.Increment)
81+
store.dispatch(AppAction.Increment)
82+
83+
advanceUntilIdle()
84+
85+
assertEquals(true, actionCompleted.isCompleted, "The running action should not cancel itself")
86+
assertEquals(AppState.Active(value = 100), store.currentState)
87+
}
88+
89+
@Test
90+
fun cancelPendingActions_inTransaction_skipsQueuedDispatches() = runTest {
91+
val testDispatcher = StandardTestDispatcher(testScheduler)
92+
val transactionStarted = CompletableDeferred<Unit>()
93+
val transactionCompleted = CompletableDeferred<Unit>()
94+
val store = createTestStore(
95+
testDispatcher = testDispatcher,
96+
onTransactionStarted = transactionStarted,
97+
onTransactionCompleted = transactionCompleted,
98+
)
99+
100+
store.dispatch(AppAction.LaunchTransactionAndCancel)
101+
runCurrent()
102+
transactionStarted.await()
103+
104+
store.dispatch(AppAction.Increment)
105+
store.dispatch(AppAction.Increment)
106+
107+
advanceUntilIdle()
108+
109+
assertEquals(true, transactionCompleted.isCompleted, "The running transaction should complete")
110+
assertEquals(AppState.Active(value = 100), store.currentState)
111+
}
112+
}

0 commit comments

Comments
 (0)