Repository: freeletics/FlowRedux (kotlin)

Issue:
Support null values for identity?
Right now `untilIdentityChanged( identity : (InputState) -> Any)` doesn't allow null values as return type, hence support null as  identity. 

But I am wondering if `null` is actually a valid identity.

Use case: maybe something like a "master-detail" UI, i.e. a gmail tablet ui (email client).


```kotlin
data class InboxState(
    val emails : List<Email>
    val selectedEmail : SelectedEmail?
)

data class SelectedEmail(
    val emailId : Int, 
    val details : EmailDetails?
)
```

```
spec {
    inState<InboxState> {
        onEnter { loadEmails() }
        on<EmailSelected> { action, state -> state.mutate {
            copy(selectedEmail = SelectedEmail(
                    emailId = action.selectedEmail.id,
                    details = null
                ))
            } 
        }

        untilIdentityChanged({ state -> state.selectedEmail?.emailId }) { // selectedEmail can be null
            // this block will be canceled and restarted whenever emailId changes
            onEnter { state -> 
                val s = state.snapshot
                if (s.selectedEmail != null) {
                    val details = loadEmailDetails(s.selectedEmail.emailId)
                    state.mutate { 
                        copy(selectedEmail = selectedEmail.copy(details = details))
                    }
                } else {
                    state.noChange()
                }
            }
        }
    }
}
```

What do you think @gabrielittner ?

Pull request description:
Closes #546 

The tests are basically just a copy of the existing tests with null values.

Patch:
diff --git a/flowredux/src/commonMain/kotlin/com/freeletics/flowredux/dsl/ConditionBuilderBlock.kt b/flowredux/src/commonMain/kotlin/com/freeletics/flowredux/dsl/ConditionBuilderBlock.kt
index 97dc05de..9bf5e2f0 100644
--- a/flowredux/src/commonMain/kotlin/com/freeletics/flowredux/dsl/ConditionBuilderBlock.kt
+++ b/flowredux/src/commonMain/kotlin/com/freeletics/flowredux/dsl/ConditionBuilderBlock.kt
@@ -18,7 +18,7 @@ public class ConditionBuilderBlock<InputState : S, S : Any, A : Any> internal co
      * Afterwards a the blocks are started again for the new `identity`.
      */
     public fun untilIdentityChanges(
-        identity: (InputState) -> Any,
+        identity: (InputState) -> Any?,
         block: IdentityBuilderBlock<InputState, S, A>.() -> Unit,
     ) {
         sideEffectBuilders += IdentityBuilderBlock<InputState, S, A>(isInState, identity)
diff --git a/flowredux/src/commonMain/kotlin/com/freeletics/flowredux/dsl/IdentityBuilderBlock.kt b/flowredux/src/commonMain/kotlin/com/freeletics/flowredux/dsl/IdentityBuilderBlock.kt
index 56f34cf6..a35eea56 100644
--- a/flowredux/src/commonMain/kotlin/com/freeletics/flowredux/dsl/IdentityBuilderBlock.kt
+++ b/flowredux/src/commonMain/kotlin/com/freeletics/flowredux/dsl/IdentityBuilderBlock.kt
@@ -8,7 +8,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
 @FlowReduxDsl
 public class IdentityBuilderBlock<InputState : S, S : Any, A : Any> internal constructor(
     override val isInState: SideEffectBuilder.IsInState<S>,
-    private val identity: (InputState) -> Any,
+    private val identity: (InputState) -> Any?,
 ) : BaseBuilderBlock<InputState, S, A>() {
 
     @Suppress("UNCHECKED_CAST")
diff --git a/flowredux/src/commonMain/kotlin/com/freeletics/flowredux/dsl/InStateBuilderBlock.kt b/flowredux/src/commonMain/kotlin/com/freeletics/flowredux/dsl/InStateBuilderBlock.kt
index 710574af..95abbefb 100644
--- a/flowredux/src/commonMain/kotlin/com/freeletics/flowredux/dsl/InStateBuilderBlock.kt
+++ b/flowredux/src/commonMain/kotlin/com/freeletics/flowredux/dsl/InStateBuilderBlock.kt
@@ -32,7 +32,7 @@ public class InStateBuilderBlock<InputState : S, S : Any, A : Any> internal cons
      * Afterwards a the blocks are started again for the new `identity`.
      */
     public fun untilIdentityChanges(
-        identity: (InputState) -> Any,
+        identity: (InputState) -> Any?,
         block: IdentityBuilderBlock<InputState, S, A>.() -> Unit,
     ) {
         sideEffectBuilders += IdentityBuilderBlock<InputState, S, A>(isInState, identity)


Test patch:
diff --git a/flowredux/src/commonTest/kotlin/com/freeletics/flowredux/StateAndAction.kt b/flowredux/src/commonTest/kotlin/com/freeletics/flowredux/StateAndAction.kt
index 68f66869..7321d582 100644
--- a/flowredux/src/commonTest/kotlin/com/freeletics/flowredux/StateAndAction.kt
+++ b/flowredux/src/commonTest/kotlin/com/freeletics/flowredux/StateAndAction.kt
@@ -15,5 +15,7 @@ internal sealed class TestState {
 
     data class GenericState(val aString: String, val anInt: Int) : TestState()
 
+    data class GenericNullableState(val aString: String?, val anInt: Int?) : TestState()
+
     data class CounterState(val counter: Int) : TestState()
 }
diff --git a/flowredux/src/commonTest/kotlin/com/freeletics/flowredux/dsl/IdentityBlockTest.kt b/flowredux/src/commonTest/kotlin/com/freeletics/flowredux/dsl/IdentityBlockTest.kt
index ed686418..0c6bd016 100644
--- a/flowredux/src/commonTest/kotlin/com/freeletics/flowredux/dsl/IdentityBlockTest.kt
+++ b/flowredux/src/commonTest/kotlin/com/freeletics/flowredux/dsl/IdentityBlockTest.kt
@@ -215,4 +215,204 @@ internal class IdentityBlockTest {
         assertEquals(1, cancellations[0].first)
         assertIsNot<StateChangeCancellationException>(cancellations[0].second)
     }
+
+    @Test
+    fun blockStartsWhenIdentityChangesBetweenNullAndNotNull() = runTest {
+        var counter = 0
+
+        val gs1 = TestState.GenericNullableState(null, null)
+
+        val sm = StateMachine {
+            inState<TestState.Initial> {
+                on<TestAction.A1> { _, state ->
+                    state.override { gs1 }
+                }
+            }
+
+            inState<TestState.GenericNullableState> {
+                untilIdentityChanges({ it.anInt }) {
+                    onEnterEffect {
+                        counter++
+                    }
+                }
+
+                on<TestAction.A1> { _, state ->
+                    state.mutate { copy(anInt = (anInt ?: 0) + 1) }
+                }
+
+                on<TestAction.A2> { _, state ->
+                    state.mutate { copy(anInt = null) }
+                }
+            }
+        }
+
+        sm.state.test {
+            assertEquals(TestState.Initial, awaitItem())
+            sm.dispatchAsync(TestAction.A1)
+            assertEquals(gs1, awaitItem())
+            sm.dispatchAsync(TestAction.A1)
+            assertEquals(gs1.copy(anInt = 1), awaitItem())
+            sm.dispatchAsync(TestAction.A2)
+            assertEquals(gs1, awaitItem())
+        }
+
+        assertEquals(3, counter)
+    }
+
+    @Test
+    fun blockDoesNotStartAgainIfIdentityStaysNull() = runTest {
+        var counter = 0
+
+        val gs1 = TestState.GenericNullableState(null, null)
+
+        val sm = StateMachine {
+            inState<TestState.Initial> {
+                on<TestAction.A1> { _, state ->
+                    state.override { gs1 }
+                }
+            }
+
+            inState<TestState.GenericNullableState> {
+                untilIdentityChanges({ it.anInt }) {
+                    onEnterEffect {
+                        counter++
+                    }
+                }
+
+                on<TestAction.A1> { _, state ->
+                    state.mutate { copy(aString = aString + "1") }
+                }
+            }
+        }
+
+        sm.state.test {
+            assertEquals(TestState.Initial, awaitItem())
+            sm.dispatchAsync(TestAction.A1)
+            assertEquals(gs1, awaitItem())
+            sm.dispatchAsync(TestAction.A1)
+            assertEquals(gs1.copy(aString = "null1"), awaitItem())
+            sm.dispatchAsync(TestAction.A1)
+            assertEquals(gs1.copy(aString = "null11"), awaitItem())
+            sm.dispatchAsync(TestAction.A1)
+            assertEquals(gs1.copy(aString = "null111"), awaitItem())
+            sm.dispatchAsync(TestAction.A1)
+            assertEquals(gs1.copy(aString = "null1111"), awaitItem())
+        }
+
+        assertEquals(1, counter)
+    }
+
+    @Test
+    fun blockIsCancelledIfIdentityChangesBetweenNullAndNotNull() = runTest {
+        val cancellations = mutableListOf<Pair<Int?, Throwable>>()
+
+        val gs1 = TestState.GenericNullableState(null, null)
+
+        val sm = StateMachine {
+            inState<TestState.Initial> {
+                on<TestAction.A1> { _, state ->
+                    state.override { gs1 }
+                }
+            }
+
+            inState<TestState.GenericNullableState> {
+                untilIdentityChanges({ it.anInt }) {
+                    onEnter {
+                        try {
+                            awaitCancellation()
+                        } catch (t: Throwable) {
+                            cancellations.add(it.snapshot.anInt to t)
+                            throw t
+                        }
+                    }
+                }
+
+                on<TestAction.A1> { _, state ->
+                    state.mutate { copy(anInt = (anInt ?: 0) + 1) }
+                }
+            }
+        }
+
+        sm.state.test {
+            assertEquals(TestState.Initial, awaitItem())
+            sm.dispatchAsync(TestAction.A1)
+            assertEquals(gs1, awaitItem())
+            sm.dispatchAsync(TestAction.A1)
+            assertEquals(gs1.copy(anInt = 1), awaitItem())
+            sm.dispatchAsync(TestAction.A1)
+            assertEquals(gs1.copy(anInt = 2), awaitItem())
+            sm.dispatchAsync(TestAction.A1)
+            assertEquals(gs1.copy(anInt = 3), awaitItem())
+            sm.dispatchAsync(TestAction.A1)
+            assertEquals(gs1.copy(anInt = 4), awaitItem())
+
+            assertEquals(4, cancellations.size)
+        }
+
+        assertEquals(5, cancellations.size)
+        assertEquals(null, cancellations[0].first)
+        assertIs<StateChangeCancellationException>(cancellations[0].second)
+        assertEquals(1, cancellations[1].first)
+        assertIs<StateChangeCancellationException>(cancellations[1].second)
+        assertEquals(2, cancellations[2].first)
+        assertIs<StateChangeCancellationException>(cancellations[2].second)
+        assertEquals(3, cancellations[3].first)
+        assertIs<StateChangeCancellationException>(cancellations[3].second)
+        // this last cancellation comes when the state machine shuts down
+        assertEquals(4, cancellations[4].first)
+        assertIsNot<StateChangeCancellationException>(cancellations[4].second)
+    }
+
+    @Test
+    fun blockIsNotCancelledIfIdentityStaysNull() = runTest {
+        val cancellations = mutableListOf<Pair<Int?, Throwable>>()
+
+        val gs1 = TestState.GenericNullableState(null, null)
+
+        val sm = StateMachine {
+            inState<TestState.Initial> {
+                on<TestAction.A1> { _, state ->
+                    state.override { gs1 }
+                }
+            }
+
+            inState<TestState.GenericNullableState> {
+                untilIdentityChanges({ it.anInt }) {
+                    onEnter {
+                        try {
+                            awaitCancellation()
+                        } catch (t: Throwable) {
+                            cancellations.add(it.snapshot.anInt to t)
+                            throw t
+                        }
+                    }
+                }
+
+                on<TestAction.A1> { _, state ->
+                    state.mutate { copy(aString = aString + "1") }
+                }
+            }
+        }
+
+        sm.state.test {
+            assertEquals(TestState.Initial, awaitItem())
+            sm.dispatchAsync(TestAction.A1)
+            assertEquals(gs1, awaitItem())
+            sm.dispatchAsync(TestAction.A1)
+            assertEquals(gs1.copy(aString = "null1"), awaitItem())
+            sm.dispatchAsync(TestAction.A1)
+            assertEquals(gs1.copy(aString = "null11"), awaitItem())
+            sm.dispatchAsync(TestAction.A1)
+            assertEquals(gs1.copy(aString = "null111"), awaitItem())
+            sm.dispatchAsync(TestAction.A1)
+            assertEquals(gs1.copy(aString = "null1111"), awaitItem())
+
+            assertEquals(0, cancellations.size)
+        }
+
+        assertEquals(1, cancellations.size)
+        // this cancellation comes when the state machine shuts down
+        assertEquals(null, cancellations[0].first)
+        assertIsNot<StateChangeCancellationException>(cancellations[0].second)
+    }
 }