From 10f5ef7df06bdf60648925c0a86fbcd27173801e Mon Sep 17 00:00:00 2001 From: Ray Ryan Date: Tue, 24 Sep 2024 13:04:32 -0700 Subject: [PATCH 1/2] safeAction, safeEventHandler People are confused by the fact that a `WorkflowAction` can't assume that a sealed class / interface `StateT` is the same subtype that it was at render time when the action fires. And those that do understand it resent this boilerplate: ```kotlin action { (state as? SpecificState)?.let { currentState -> // whatever } } ``` So we introduce `StatefulWorkflow.safeAction` and `StatefulWorkflow.RenderContext.safeEventHandler` as conveniences to do that cast for you. --- .../sample/gameworkflow/RunGameWorkflow.kt | 61 ++- workflow-core/api/workflow-core.api | 1 + .../squareup/workflow1/BaseRenderContext.kt | 95 +++-- .../squareup/workflow1/StatefulWorkflow.kt | 378 +++++++++++++++++- 4 files changed, 470 insertions(+), 65 deletions(-) diff --git a/samples/tictactoe/common/src/main/java/com/squareup/sample/gameworkflow/RunGameWorkflow.kt b/samples/tictactoe/common/src/main/java/com/squareup/sample/gameworkflow/RunGameWorkflow.kt index e9a438921..dbe15b1f8 100644 --- a/samples/tictactoe/common/src/main/java/com/squareup/sample/gameworkflow/RunGameWorkflow.kt +++ b/samples/tictactoe/common/src/main/java/com/squareup/sample/gameworkflow/RunGameWorkflow.kt @@ -18,7 +18,6 @@ import com.squareup.sample.gameworkflow.SyncState.SAVING import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.Workflow -import com.squareup.workflow1.action import com.squareup.workflow1.runningWorker import com.squareup.workflow1.rx2.asWorker import com.squareup.workflow1.ui.Screen @@ -88,8 +87,12 @@ class RealRunGameWorkflow( namePrompt = NewGameScreen( renderState.defaultXName, renderState.defaultOName, - onCancel = context.eventHandler { setOutput(CanceledStart) }, - onStartGame = context.eventHandler { x, o -> state = Playing(PlayerInfo(x, o)) } + onCancel = context.safeEventHandler { + setOutput(CanceledStart) + }, + onStartGame = context.safeEventHandler { _, x, o -> + state = Playing(PlayerInfo(x, o)) + } ) ) } @@ -119,15 +122,11 @@ class RealRunGameWorkflow( message = "Do you really want to concede the game?", positive = "I Quit", negative = "No", - confirmQuit = context.eventHandler { - (state as? MaybeQuitting)?.let { oldState -> - state = MaybeQuittingForSure(oldState.playerInfo, oldState.completedGame) - } + confirmQuit = context.safeEventHandler { oldState -> + state = MaybeQuittingForSure(oldState.playerInfo, oldState.completedGame) }, - continuePlaying = context.eventHandler { - (state as? MaybeQuitting)?.let { oldState -> - state = Playing(oldState.playerInfo, oldState.completedGame.lastTurn) - } + continuePlaying = context.safeEventHandler { oldState -> + state = Playing(oldState.playerInfo, oldState.completedGame.lastTurn) } ) ) @@ -142,15 +141,11 @@ class RealRunGameWorkflow( message = "Really?", positive = "Yes!!", negative = "Sigh, no", - confirmQuit = context.eventHandler { - (state as? MaybeQuittingForSure)?.let { oldState -> - state = GameOver(oldState.playerInfo, oldState.completedGame) - } + confirmQuit = context.safeEventHandler { oldState -> + state = GameOver(oldState.playerInfo, oldState.completedGame) }, - continuePlaying = context.eventHandler { - (state as? MaybeQuittingForSure)?.let { oldState -> - state = Playing(oldState.playerInfo, oldState.completedGame.lastTurn) - } + continuePlaying = context.safeEventHandler { oldState -> + state = Playing(oldState.playerInfo, oldState.completedGame.lastTurn) } ) ) @@ -169,43 +164,37 @@ class RealRunGameWorkflow( renderState, onTrySaveAgain = context.trySaveAgain(), onPlayAgain = context.playAgain(), - onExit = context.eventHandler { setOutput(FinishedPlaying) } + onExit = context.safeEventHandler { setOutput(FinishedPlaying) } ) ) } } - private fun stopPlaying(game: CompletedGame) = action { - val oldState = state as Playing + private fun stopPlaying(game: CompletedGame) = safeAction("stopPlaying") { oldState -> state = when (game.ending) { Quitted -> MaybeQuitting(oldState.playerInfo, game) else -> GameOver(oldState.playerInfo, game) } } - private fun handleLogGame(result: GameLog.LogResult) = action { - val oldState = state as GameOver + private fun handleLogGame(result: GameLog.LogResult) = safeAction { oldState -> state = when (result) { TRY_LATER -> oldState.copy(syncState = SAVE_FAILED) LOGGED -> oldState.copy(syncState = SAVED) } } - private fun RenderContext.playAgain() = eventHandler { - (state as? GameOver)?.let { oldState -> - val (x, o) = oldState.playerInfo - state = NewGame(x, o) - } + private fun RenderContext.playAgain() = safeEventHandler { oldState -> + val (x, o) = oldState.playerInfo + state = NewGame(x, o) } - private fun RenderContext.trySaveAgain() = eventHandler { - (state as? GameOver)?.let { oldState -> - check(oldState.syncState == SAVE_FAILED) { - "Should only fire trySaveAgain in syncState $SAVE_FAILED, " + - "was ${oldState.syncState}" - } - state = oldState.copy(syncState = SAVING) + private fun RenderContext.trySaveAgain() = safeEventHandler { oldState -> + check(oldState.syncState == SAVE_FAILED) { + "Should only fire trySaveAgain in syncState $SAVE_FAILED, " + + "was ${oldState.syncState}" } + state = oldState.copy(syncState = SAVING) } override fun snapshotState(state: RunGameState): Snapshot = state.toSnapshot() diff --git a/workflow-core/api/workflow-core.api b/workflow-core/api/workflow-core.api index 3c80e3c26..01b49cc96 100644 --- a/workflow-core/api/workflow-core.api +++ b/workflow-core/api/workflow-core.api @@ -155,6 +155,7 @@ public final class com/squareup/workflow1/Snapshots { public abstract class com/squareup/workflow1/StatefulWorkflow : com/squareup/workflow1/IdCacheable, com/squareup/workflow1/Workflow { public fun ()V public final fun asStatefulWorkflow ()Lcom/squareup/workflow1/StatefulWorkflow; + public final fun defaultOnFailedCast (Ljava/lang/String;Lkotlin/reflect/KClass;Ljava/lang/Object;)V public fun getCachedIdentifier ()Lcom/squareup/workflow1/WorkflowIdentifier; public abstract fun initialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;)Ljava/lang/Object; public fun initialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;Lkotlinx/coroutines/CoroutineScope;)Ljava/lang/Object; diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt index 70e2a86a1..b8d0d8a28 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt @@ -130,6 +130,45 @@ public interface BaseRenderContext { * given [update] function, and immediately passes it to [actionSink]. Handy for * attaching event handlers to renderings. * + * It is important to understand that the [update] lambda you provide here + * may not run synchronously. This function and its overloads provide a short cut + * that lets you replace this snippet: + * + * return SomeScreen( + * onClick = { + * context.actionSink.send( + * action { state = SomeNewState } + * } + * } + * ) + * + * with this: + * + * return SomeScreen( + * onClick = context.eventHandler { state = SomeNewState } + * ) + * + * Notice how your [update] function is passed to the [actionSink][BaseRenderContext.actionSink] + * to be eventually executed as the body of a [WorkflowAction]. If several actions get stacked + * up at once (think about accidental rapid taps on a button), that could take a while. + * + * If you require something to happen the instant a UI action happens, [eventHandler] + * is the wrong choice. You'll want to write your own call to `actionSink.send`: + * + * return SomeScreen( + * onClick = { + * // This happens immediately. + * MyAnalytics.log("SomeScreen was clicked") + * + * context.actionSink.send( + * action { + * // This happens eventually. + * state = SomeNewState + * } + * } + * } + * ) + * * @param name A string describing the update, included in the action's [toString] * as a debugging aid * @param update Function that defines the workflow update. @@ -264,7 +303,7 @@ public interface BaseRenderContext { public fun eventHandler( name: () -> String = { "eventHandler" }, update: WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT> - .Updater.(E1, E2, E3, E4, E5, E6, E7, E8, E9) -> Unit + .Updater.(E1, E2, E3, E4, E5, E6, E7, E8, E9) -> Unit ): (E1, E2, E3, E4, E5, E6, E7, E8, E9) -> Unit { return { e1, e2, e3, e4, e5, e6, e7, e8, e9 -> actionSink.send(action(name) { update(e1, e2, e3, e4, e5, e6, e7, e8, e9) }) @@ -274,7 +313,7 @@ public interface BaseRenderContext { public fun eventHandler( name: () -> String = { "eventHandler" }, update: WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT> - .Updater.(E1, E2, E3, E4, E5, E6, E7, E8, E9, E10) -> Unit + .Updater.(E1, E2, E3, E4, E5, E6, E7, E8, E9, E10) -> Unit ): (E1, E2, E3, E4, E5, E6, E7, E8, E9, E10) -> Unit { return { e1, e2, e3, e4, e5, e6, e7, e8, e9, e10 -> actionSink.send(action(name) { update(e1, e2, e3, e4, e5, e6, e7, e8, e9, e10) }) @@ -287,20 +326,20 @@ public interface BaseRenderContext { */ public fun BaseRenderContext.renderChild( - child: Workflow, - key: String = "", - handler: (ChildOutputT) -> WorkflowAction - ): ChildRenderingT = renderChild(child, Unit, key, handler) + child: Workflow, + key: String = "", + handler: (ChildOutputT) -> WorkflowAction +): ChildRenderingT = renderChild(child, Unit, key, handler) /** * Convenience alias of [BaseRenderContext.renderChild] for workflows that don't emit output. */ public fun BaseRenderContext.renderChild( - child: Workflow, - props: ChildPropsT, - key: String = "" - ): ChildRenderingT = renderChild(child, props, key) { noAction() } + child: Workflow, + props: ChildPropsT, + key: String = "" +): ChildRenderingT = renderChild(child, props, key) { noAction() } /** * Convenience alias of [BaseRenderContext.renderChild] for children that don't take props or emit @@ -308,9 +347,9 @@ public fun */ public fun BaseRenderContext.renderChild( - child: Workflow, - key: String = "" - ): ChildRenderingT = renderChild(child, Unit, key) { noAction() } + child: Workflow, + key: String = "" +): ChildRenderingT = renderChild(child, Unit, key) { noAction() } /** * Ensures a [LifecycleWorker] is running. Since [worker] can't emit anything, @@ -323,9 +362,9 @@ public fun */ public inline fun BaseRenderContext.runningWorker( - worker: W, - key: String = "" - ) { + worker: W, + key: String = "" +) { runningWorker(worker, key) { // The compiler thinks this code is unreachable, and it is correct. But we have to pass a lambda // here so we might as well check at runtime as well. @@ -348,9 +387,9 @@ public inline fun ) public inline fun , PropsT, StateT, OutputT> BaseRenderContext.runningWorker( - worker: W, - key: String = "" - ) { + worker: W, + key: String = "" +) { runningWorker(worker, key) { // The compiler thinks this code is unreachable, and it is correct. But we have to pass a lambda // here so we might as well check at runtime as well. @@ -378,10 +417,10 @@ public inline fun , PropsT, StateT, OutputT> */ public inline fun , PropsT, StateT, OutputT> BaseRenderContext.runningWorker( - worker: W, - key: String = "", - noinline handler: (T) -> WorkflowAction - ) { + worker: W, + key: String = "", + noinline handler: (T) -> WorkflowAction +) { runningWorker(worker, typeOf(), key, handler) } @@ -396,11 +435,11 @@ public inline fun , PropsT, StateT, OutputT> @PublishedApi internal fun BaseRenderContext.runningWorker( - worker: Worker, - workerType: KType, - key: String = "", - handler: (T) -> WorkflowAction - ) { + worker: Worker, + workerType: KType, + key: String = "", + handler: (T) -> WorkflowAction +) { val workerWorkflow = WorkerWorkflow(workerType, key) renderChild(workerWorkflow, props = worker, key = key, handler = handler) } diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatefulWorkflow.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatefulWorkflow.kt index a805048c0..fa04bb83a 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatefulWorkflow.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatefulWorkflow.kt @@ -8,6 +8,8 @@ import com.squareup.workflow1.WorkflowAction.Companion.toString import kotlinx.coroutines.CoroutineScope import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName +import kotlin.reflect.KClass +import kotlin.reflect.safeCast /** * A composable, stateful object that can [handle events][RenderContext.actionSink], @@ -73,7 +75,381 @@ public abstract class StatefulWorkflow< public inner class RenderContext internal constructor( baseContext: BaseRenderContext - ) : BaseRenderContext<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT> by baseContext + ) : BaseRenderContext<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT> by baseContext { + + /** + * Like [eventHandler], but no-ops if [state][WorkflowAction.Updater.state] has + * changed to a different type than [CurrentStateT] by the time [update] fires. + * + * It is also important to understand that **even if [update] is called, there is + * no guarantee that it will be called synchronously**. See [eventHandler] for more + * details on that. + * + * when(renderState) { + * is NewGame -> { + * NewGameScreen( + * onCancel = context.safeEventHandler { + * setOutput(CanceledStart) + * }, + * onStartGame = + * context.safeEventHandler { currentState, x, o -> + * state = Playing(currentState.gameType, PlayerInfo(x, o)) + * } + * ) + * } + * + * This is not an uncommon case. Consider accidental rapid taps on + * a button, where the first tap event moves the receiving [StatefulWorkflow] + * to a new state. There is no reason to expect that the later taps will not + * fire the (now stale) event handler a few more times. No promise can be + * made that the [state][WorkflowAction.Updater.state] received by a [WorkflowAction] + * will be of the same type as the `renderState` parameter that was received by + * the [render] call that created it. + * + * @param CurrentStateT the subtype of [StateT] required by [update], which will not + * be invoked if casting [state][WorkflowAction.Updater.state] to [CurrentStateT] fails. + * @param name A string describing the handler for debugging. + * @param onFailedCast Optional function invoked when casting fails. Default implementation + * logs a warning with [println] + * @param update Function that defines the workflow update. + */ + public inline fun safeEventHandler( + name: String = "safeEventHandler", + crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = + ::defaultOnFailedCast, + crossinline update: // Type variance issue: https://github.com/square/workflow-kotlin/issues/891 + WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( + currentState: CurrentStateT + ) -> Unit + ): () -> Unit { + return eventHandler({ name }) { + CurrentStateT::class.safeCast(state)?.let { currentState -> this.update(currentState) } + ?: onFailedCast(name, CurrentStateT::class, state) + } + } + + public inline fun safeEventHandler( + name: String = "safeEventHandler", + crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = + ::defaultOnFailedCast, + crossinline update: + WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( + currentState: CurrentStateT, + event: EventT + ) -> Unit + ): (EventT) -> Unit { + return eventHandler({ name }) { event: EventT -> + CurrentStateT::class.safeCast(state) + ?.let { currentState -> this.update(currentState, event) } + ?: onFailedCast(name, CurrentStateT::class, state) + } + } + + public inline fun safeEventHandler( + name: String = "safeEventHandler", + crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = + ::defaultOnFailedCast, + crossinline update: + WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( + currentState: CurrentStateT, + e1: E1, + e2: E2 + ) -> Unit + ): (E1, E2) -> Unit { + return eventHandler({ name }) { e1: E1, e2: E2 -> + CurrentStateT::class.safeCast(state) + ?.let { currentState -> this.update(currentState, e1, e2) } + ?: onFailedCast(name, CurrentStateT::class, state) + } + } + + public inline fun safeEventHandler( + name: String = "safeEventHandler", + crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = + ::defaultOnFailedCast, + crossinline update: + WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( + currentState: CurrentStateT, + e1: E1, + e2: E2, + e3: E3 + ) -> Unit + ): (E1, E2, E3) -> Unit { + return eventHandler({ name }) { e1: E1, e2: E2, e3: E3 -> + CurrentStateT::class.safeCast(state) + ?.let { currentState -> this.update(currentState, e1, e2, e3) } + ?: onFailedCast(name, CurrentStateT::class, state) + } + } + + public inline fun safeEventHandler( + name: String = "safeEventHandler", + crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = + ::defaultOnFailedCast, + crossinline update: + WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( + currentState: CurrentStateT, + e1: E1, + e2: E2, + e3: E3, + e4: E4 + ) -> Unit + ): (E1, E2, E3, E4) -> Unit { + return eventHandler({ name }) { e1: E1, e2: E2, e3: E3, e4: E4 -> + CurrentStateT::class.safeCast(state) + ?.let { currentState -> this.update(currentState, e1, e2, e3, e4) } + ?: onFailedCast(name, CurrentStateT::class, state) + } + } + + public inline fun safeEventHandler( + name: String = "safeEventHandler", + crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = + ::defaultOnFailedCast, + crossinline update: + WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( + currentState: CurrentStateT, + e1: E1, + e2: E2, + e3: E3, + e4: E4, + e5: E5 + ) -> Unit + ): (E1, E2, E3, E4, E5) -> Unit { + return eventHandler({ name }) { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5 -> + CurrentStateT::class.safeCast(state) + ?.let { currentState -> this.update(currentState, e1, e2, e3, e4, e5) } + ?: onFailedCast(name, CurrentStateT::class, state) + } + } + + public inline fun < + reified CurrentStateT : StateT & Any, + E1, + E2, + E3, + E4, + E5, + E6 + > safeEventHandler( + name: String = "safeEventHandler", + crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = + ::defaultOnFailedCast, + crossinline update: + WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( + currentState: CurrentStateT, + e1: E1, + e2: E2, + e3: E3, + e4: E4, + e5: E5, + e6: E6 + ) -> Unit + ): (E1, E2, E3, E4, E5, E6) -> Unit { + return eventHandler({ name }) { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5, e6: E6 -> + CurrentStateT::class.safeCast(state) + ?.let { currentState -> this.update(currentState, e1, e2, e3, e4, e5, e6) } + ?: onFailedCast(name, CurrentStateT::class, state) + } + } + + public inline fun < + reified CurrentStateT : StateT & Any, + E1, + E2, + E3, + E4, + E5, + E6, + E7 + > safeEventHandler( + name: String = "safeEventHandler", + crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = + ::defaultOnFailedCast, + crossinline update: + WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( + currentState: CurrentStateT, + e1: E1, + e2: E2, + e3: E3, + e4: E4, + e5: E5, + e6: E6, + e7: E7 + ) -> Unit + ): (E1, E2, E3, E4, E5, E6, E7) -> Unit { + return eventHandler({ name }) { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5, e6: E6, e7: E7 -> + CurrentStateT::class.safeCast(state) + ?.let { currentState -> this.update(currentState, e1, e2, e3, e4, e5, e6, e7) } + ?: onFailedCast(name, CurrentStateT::class, state) + } + } + + public inline fun < + reified CurrentStateT : StateT & Any, + E1, + E2, + E3, + E4, + E5, + E6, + E7, + E8 + > safeEventHandler( + name: String = "safeEventHandler", + crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = + ::defaultOnFailedCast, + crossinline update: + WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( + currentState: CurrentStateT, + e1: E1, + e2: E2, + e3: E3, + e4: E4, + e5: E5, + e6: E6, + e7: E7, + e8: E8 + ) -> Unit + ): (E1, E2, E3, E4, E5, E6, E7, E8) -> Unit { + return eventHandler( + { name } + ) { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5, e6: E6, e7: E7, e8: E8 -> + CurrentStateT::class.safeCast(state) + ?.let { currentState -> this.update(currentState, e1, e2, e3, e4, e5, e6, e7, e8) } + ?: onFailedCast(name, CurrentStateT::class, state) + } + } + + public inline fun < + reified CurrentStateT : StateT & Any, + E1, + E2, + E3, + E4, + E5, + E6, + E7, + E8, + E9 + > safeEventHandler( + name: String = "safeEventHandler", + crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = + ::defaultOnFailedCast, + crossinline update: + WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( + currentState: CurrentStateT, + e1: E1, + e2: E2, + e3: E3, + e4: E4, + e5: E5, + e6: E6, + e7: E7, + e8: E8, + e9: E9 + ) -> Unit + ): (E1, E2, E3, E4, E5, E6, E7, E8, E9) -> Unit { + return eventHandler( + { name } + ) { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5, e6: E6, e7: E7, e8: E8, e9: E9 -> + CurrentStateT::class.safeCast(state) + ?.let { currentState -> this.update(currentState, e1, e2, e3, e4, e5, e6, e7, e8, e9) } + ?: onFailedCast(name, CurrentStateT::class, state) + } + } + + public inline fun < + reified CurrentStateT : StateT & Any, + E1, + E2, + E3, + E4, + E5, + E6, + E7, + E8, + E9, + E10 + > safeEventHandler( + name: String = "safeEventHandler", + crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = + ::defaultOnFailedCast, + crossinline update: + WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( + currentState: CurrentStateT, + e1: E1, + e2: E2, + e3: E3, + e4: E4, + e5: E5, + e6: E6, + e7: E7, + e8: E8, + e9: E9, + e10: E10 + ) -> Unit + ): (E1, E2, E3, E4, E5, E6, E7, E8, E9, E10) -> Unit { + return eventHandler( + { name } + ) { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5, e6: E6, e7: E7, e8: E8, e9: E9, e10: E10 -> + CurrentStateT::class.safeCast(state) + ?.let { currentState -> + this.update(currentState, e1, e2, e3, e4, e5, e6, e7, e8, e9, e10) + } + ?: onFailedCast(name, CurrentStateT::class, state) + } + } + } + + /** + * Like [action], but no-ops if [state][WorkflowAction.Updater.state] has + * changed to a different type than [CurrentStateT] by the time [update] fires. + * + * private fun stopPlaying( + * game: CompletedGame + * ) = safeAction("stopPlaying") { currentState -> + * state = when (game.ending) { + * Quitting -> MaybeQuitting(currentState.playerInfo, game) + * else -> GameOver(currentState.playerInfo, game) + * } + * } + * + * This is not an uncommon case. Consider accidental rapid taps on + * a button, where the first tap event moves the receiving [StatefulWorkflow] + * to a new state. There is no reason to expect that the later taps will not + * fire the (now stale) event handler a few more times. No promise can be + * made that the [state][WorkflowAction.Updater.state] received by a [WorkflowAction] + * will be of the same type as the `renderState` parameter that was received by + * the [render] call that created it. + * + * @param CurrentStateT the subtype of [StateT] required by [update], which will not + * be invoked if casting [state][WorkflowAction.Updater.state] to [CurrentStateT] fails. + * @param name A string describing the action for debugging. + * @param onFailedCast Optional function invoked when casting fails. Default implementation + * logs a warning with [println] + * @param update Function that defines the workflow update. + */ + public inline fun safeAction( + name: String = "safeAction", + crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = + ::defaultOnFailedCast, + noinline update: WorkflowAction.Updater.( + currentState: CurrentStateT + ) -> Unit + ): WorkflowAction = action({ name }) { + CurrentStateT::class.safeCast(state)?.let { currentState -> this.update(currentState) } + ?: onFailedCast(name, CurrentStateT::class, state) + } + + @PublishedApi + internal fun defaultOnFailedCast( + name: String, + expectedType: KClass<*>, + state: StateT + ) { + println("$name expected state of type ${expectedType.simpleName}, got $state") + } /** * Called from [RenderContext.renderChild] when the state machine is first started, to get the From 7071aa5a657b4cdb1caf391a9f50515e15de56db Mon Sep 17 00:00:00 2001 From: rjrjr Date: Wed, 25 Sep 2024 21:59:36 +0000 Subject: [PATCH 2/2] Apply changes from ktLintFormat Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../squareup/workflow1/BaseRenderContext.kt | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt index b8d0d8a28..95dd37a21 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt @@ -303,7 +303,7 @@ public interface BaseRenderContext { public fun eventHandler( name: () -> String = { "eventHandler" }, update: WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT> - .Updater.(E1, E2, E3, E4, E5, E6, E7, E8, E9) -> Unit + .Updater.(E1, E2, E3, E4, E5, E6, E7, E8, E9) -> Unit ): (E1, E2, E3, E4, E5, E6, E7, E8, E9) -> Unit { return { e1, e2, e3, e4, e5, e6, e7, e8, e9 -> actionSink.send(action(name) { update(e1, e2, e3, e4, e5, e6, e7, e8, e9) }) @@ -313,7 +313,7 @@ public interface BaseRenderContext { public fun eventHandler( name: () -> String = { "eventHandler" }, update: WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT> - .Updater.(E1, E2, E3, E4, E5, E6, E7, E8, E9, E10) -> Unit + .Updater.(E1, E2, E3, E4, E5, E6, E7, E8, E9, E10) -> Unit ): (E1, E2, E3, E4, E5, E6, E7, E8, E9, E10) -> Unit { return { e1, e2, e3, e4, e5, e6, e7, e8, e9, e10 -> actionSink.send(action(name) { update(e1, e2, e3, e4, e5, e6, e7, e8, e9, e10) }) @@ -326,20 +326,20 @@ public interface BaseRenderContext { */ public fun BaseRenderContext.renderChild( - child: Workflow, - key: String = "", - handler: (ChildOutputT) -> WorkflowAction -): ChildRenderingT = renderChild(child, Unit, key, handler) + child: Workflow, + key: String = "", + handler: (ChildOutputT) -> WorkflowAction + ): ChildRenderingT = renderChild(child, Unit, key, handler) /** * Convenience alias of [BaseRenderContext.renderChild] for workflows that don't emit output. */ public fun BaseRenderContext.renderChild( - child: Workflow, - props: ChildPropsT, - key: String = "" -): ChildRenderingT = renderChild(child, props, key) { noAction() } + child: Workflow, + props: ChildPropsT, + key: String = "" + ): ChildRenderingT = renderChild(child, props, key) { noAction() } /** * Convenience alias of [BaseRenderContext.renderChild] for children that don't take props or emit @@ -347,9 +347,9 @@ public fun */ public fun BaseRenderContext.renderChild( - child: Workflow, - key: String = "" -): ChildRenderingT = renderChild(child, Unit, key) { noAction() } + child: Workflow, + key: String = "" + ): ChildRenderingT = renderChild(child, Unit, key) { noAction() } /** * Ensures a [LifecycleWorker] is running. Since [worker] can't emit anything, @@ -362,9 +362,9 @@ public fun */ public inline fun BaseRenderContext.runningWorker( - worker: W, - key: String = "" -) { + worker: W, + key: String = "" + ) { runningWorker(worker, key) { // The compiler thinks this code is unreachable, and it is correct. But we have to pass a lambda // here so we might as well check at runtime as well. @@ -387,9 +387,9 @@ public inline fun ) public inline fun , PropsT, StateT, OutputT> BaseRenderContext.runningWorker( - worker: W, - key: String = "" -) { + worker: W, + key: String = "" + ) { runningWorker(worker, key) { // The compiler thinks this code is unreachable, and it is correct. But we have to pass a lambda // here so we might as well check at runtime as well. @@ -417,10 +417,10 @@ public inline fun , PropsT, StateT, OutputT> */ public inline fun , PropsT, StateT, OutputT> BaseRenderContext.runningWorker( - worker: W, - key: String = "", - noinline handler: (T) -> WorkflowAction -) { + worker: W, + key: String = "", + noinline handler: (T) -> WorkflowAction + ) { runningWorker(worker, typeOf(), key, handler) } @@ -435,11 +435,11 @@ public inline fun , PropsT, StateT, OutputT> @PublishedApi internal fun BaseRenderContext.runningWorker( - worker: Worker, - workerType: KType, - key: String = "", - handler: (T) -> WorkflowAction -) { + worker: Worker, + workerType: KType, + key: String = "", + handler: (T) -> WorkflowAction + ) { val workerWorkflow = WorkerWorkflow(workerType, key) renderChild(workerWorkflow, props = worker, key = key, handler = handler) }