From 60f7ea5a32bf53973ab5825b82e8ba209d011288 Mon Sep 17 00:00:00 2001 From: Ray Ryan Date: Fri, 21 Feb 2025 15:02:19 -0800 Subject: [PATCH] *BREAKING* Introduces `BaseRenderContext.remember` and stable `eventHandlers`. Workflow makes it very convenient to render view models with anonymous lambdas as their event handler functions. Compose hates that. To address that mismatch without forcing everyone to retrofit their apps to use event objects instead of lambdas (it's a little late for that!) we introduce support for stable event handlers: anonymous lambdas whose identity looks the same to Compose across updates. In order to do this we're breaking the existing `eventHandler` and `safeEventHandler` functions a bit. - We introduce a new optional `remember: Boolean? = null` parameter. Set that true to get the new stability. If you leave it to the default `null` we look for a new `STABLE_EVENT_HANDLER : RuntimeConfigOption` to decide what to do. If you set that config option on an existing app and make no other changes, all of your existing `eventHandler` functions will be stable. - When `remember` is true, the existing `name` parameter becomes weight bearing. It's no longer just a logging aid, it's part of a key identifying your stable lambda. The other parts of the key are its return type, and the types of any of its parameters. Duplicating a key within a particular `render()` call is a runtime error, similar to the rules for `renderChild`, `runningWorker`, and `runningSideEffect`. - To make it easier to find fix and prevent those new runtime errors `testRender` and `WorkflowTestParams` now accept optional `RuntimeConfig` parameters, and throw appropriate errors if `STABLE_EVENT_HANDLER` is set. `testRender` also now honors `JvmTestRuntimeConfigTools.getTestRuntimeConfig()`. - Most of the `eventHandler` functions have also been changed to `inline` -- necessary so that we can reify their parameter types for the key scheme described above All of this is built on a new `BaseRenderContext.remember` primitive, which provides a light weight mechanism to save a bit of state across a workflow session without having to find room for it in `StateT`. `BaseRenderContext` also now provides `val runtimeConfig: RuntimeConfig` in support of all of the above. --- .../ActionHandlingTracingInterceptor.kt | 6 +- .../InlineRenderingWorkflow.kt | 20 +- .../sample/poetry/StanzaListWorkflow.kt | 2 +- .../squareup/sample/poetry/StanzaWorkflow.kt | 2 +- .../timemachine/TimeMachineWorkflowTest.kt | 5 +- .../nestedoverlays/NestedOverlaysWorkflow.kt | 48 +- workflow-core/api/workflow-core.api | 155 +++-- .../squareup/workflow1/BaseRenderContext.kt | 308 +++------- .../com/squareup/workflow1/HandlerBox.kt | 413 ++++++++++++++ .../com/squareup/workflow1/RuntimeConfig.kt | 12 +- .../squareup/workflow1/StatefulWorkflow.kt | 529 ++++++++++++++---- .../squareup/workflow1/StatelessWorkflow.kt | 212 ++++++- .../com/squareup/workflow1/WorkflowAction.kt | 7 +- workflow-runtime/api/workflow-runtime.api | 20 +- .../squareup/workflow1/WorkflowInterceptor.kt | 26 + .../internal/ChainedWorkflowInterceptor.kt | 33 +- .../workflow1/internal/RealRenderContext.kt | 37 +- .../workflow1/internal/RememberedNode.kt | 14 + .../workflow1/internal/WorkflowNode.kt | 31 +- .../SimpleLoggingWorkflowInterceptorTest.kt | 11 + .../workflow1/WorkflowInterceptorTest.kt | 89 ++- .../ChainedWorkflowInterceptorTest.kt | 10 +- .../internal/ParameterizedTestRunner.kt | 2 +- .../internal/RealRenderContextTest.kt | 233 ++------ .../workflow1/internal/SubtreeManagerTest.kt | 4 +- .../workflow1/internal/WorkflowNodeTest.kt | 89 ++- workflow-testing/api/workflow-testing.api | 14 +- .../workflow1/testing/RealRenderTester.kt | 33 +- .../testing/RenderIdempotencyChecker.kt | 25 +- .../workflow1/testing/RenderTester.kt | 68 ++- .../workflow1/testing/WorkflowTestParams.kt | 7 +- .../workflow1/testing/WorkflowTestRuntime.kt | 49 +- .../StatefulWorkflowEventHandlerTest.kt | 267 +++++++++ .../StatefulWorkflowSafeEventHandlerTest.kt | 267 +++++++++ .../StatelessWorkflowEventHandlerTest.kt | 267 +++++++++ .../com/squareup/workflow1/StringTypeAlias.kt | 7 + 36 files changed, 2532 insertions(+), 790 deletions(-) create mode 100644 workflow-core/src/commonMain/kotlin/com/squareup/workflow1/HandlerBox.kt rename {workflow-runtime => workflow-core}/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt (89%) create mode 100644 workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RememberedNode.kt create mode 100644 workflow-testing/src/test/java/com/squareup/workflow1/StatefulWorkflowEventHandlerTest.kt create mode 100644 workflow-testing/src/test/java/com/squareup/workflow1/StatefulWorkflowSafeEventHandlerTest.kt create mode 100644 workflow-testing/src/test/java/com/squareup/workflow1/StatelessWorkflowEventHandlerTest.kt create mode 100644 workflow-testing/src/test/java/com/squareup/workflow1/StringTypeAlias.kt diff --git a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/ActionHandlingTracingInterceptor.kt b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/ActionHandlingTracingInterceptor.kt index 9d851dbb12..955cb1597f 100644 --- a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/ActionHandlingTracingInterceptor.kt +++ b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/ActionHandlingTracingInterceptor.kt @@ -12,12 +12,12 @@ import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession * particular events. * * If you want to trace how long Workflow takes to process a UI event, then - * annotate the [RenderContext.eventHandler] name argument with [keyForTrace]. That will cause + * annotate the `RenderContext.eventHandler` name argument with [keyForTrace]. That will cause * this interceptor to pick it up when the action is sent into the sink and trace that main thread * message. * - * If you want to trace how long Workflow takes to process the result of a [Worker], then - * annotate the [Worker] using [TraceableWorker] which will set it up with a key such that when + * If you want to trace how long Workflow takes to process the result of a `Worker`, then + * annotate the `Worker` using [TraceableWorker] which will set it up with a key such that when * the action for the result is sent to the sink the main thread message will be traced. */ class ActionHandlingTracingInterceptor : WorkflowInterceptor, Resettable { diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt index 5f54b0a6c0..0af60d522e 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt @@ -3,7 +3,6 @@ package com.squareup.sample.compose.inlinerendering import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.SizeTransform import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -38,12 +37,15 @@ object InlineRenderingWorkflow : StatefulWorkflow() renderProps: Unit, renderState: Int, context: RenderContext - ) = ComposeScreen { - Box { - Button(onClick = context.eventHandler("increment") { state += 1 }) { - Text("Counter: ") - AnimatedCounter(renderState) { counterValue -> - Text(counterValue.toString()) + ): ComposeScreen { + val onClick = context.eventHandler("increment") { state += 1 } + return ComposeScreen { + Box { + Button(onClick = onClick) { + Text("Counter: ") + AnimatedCounter(renderState) { counterValue -> + Text(counterValue.toString()) + } } } } @@ -68,7 +70,6 @@ internal fun InlineRenderingWorkflowPreview() { InlineRenderingWorkflowRendering() } -@OptIn(ExperimentalAnimationApi::class) @Composable private fun AnimatedCounter( counterValue: Int, @@ -79,6 +80,7 @@ private fun AnimatedCounter( transitionSpec = { ((slideInVertically() + fadeIn()).togetherWith(slideOutVertically() + fadeOut())) .using(SizeTransform(clip = false)) - } + }, + label = "" ) { content(it) } } diff --git a/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaListWorkflow.kt b/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaListWorkflow.kt index 04ec7fdb6d..cea6f73d6a 100644 --- a/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaListWorkflow.kt +++ b/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaListWorkflow.kt @@ -15,7 +15,7 @@ object StanzaListWorkflow : StatelessWorkflow String = { "" } + val eventHandlerTag: (String) -> String = { it } ) const val NO_SELECTED_STANZA = -1 diff --git a/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaWorkflow.kt b/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaWorkflow.kt index 59eb55e104..c0cc446e99 100644 --- a/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaWorkflow.kt +++ b/samples/containers/poetry/src/main/java/com/squareup/sample/poetry/StanzaWorkflow.kt @@ -12,7 +12,7 @@ object StanzaWorkflow : StatelessWorkflow() { data class Props( val poem: Poem, val index: Int, - val eventHandlerTag: (String) -> String = { "" } + val eventHandlerTag: (String) -> String = { it } ) enum class Output { diff --git a/samples/dungeon/timemachine/src/test/java/com/squareup/sample/timemachine/TimeMachineWorkflowTest.kt b/samples/dungeon/timemachine/src/test/java/com/squareup/sample/timemachine/TimeMachineWorkflowTest.kt index 15ac070842..1c5d02d445 100644 --- a/samples/dungeon/timemachine/src/test/java/com/squareup/sample/timemachine/TimeMachineWorkflowTest.kt +++ b/samples/dungeon/timemachine/src/test/java/com/squareup/sample/timemachine/TimeMachineWorkflowTest.kt @@ -25,7 +25,10 @@ class TimeMachineWorkflowTest { val delegateWorkflow = Workflow.stateful( initialState = "initial", render = { renderState -> - DelegateRendering(renderState, setState = eventHandler("") { s -> state = s }) + DelegateRendering( + renderState, + setState = eventHandler("setState") { s -> state = s } + ) } ) val clock = TestTimeSource() diff --git a/samples/nested-overlays/src/main/java/com/squareup/sample/nestedoverlays/NestedOverlaysWorkflow.kt b/samples/nested-overlays/src/main/java/com/squareup/sample/nestedoverlays/NestedOverlaysWorkflow.kt index c3b72131f3..82c7b056a0 100644 --- a/samples/nested-overlays/src/main/java/com/squareup/sample/nestedoverlays/NestedOverlaysWorkflow.kt +++ b/samples/nested-overlays/src/main/java/com/squareup/sample/nestedoverlays/NestedOverlaysWorkflow.kt @@ -62,7 +62,7 @@ object NestedOverlaysWorkflow : StatefulWorkflow() name = R.string.close, onClick = closeOuter ), - context.toggleInnerSheetButton(renderState), + context.toggleInnerSheetButton(name = "inner", renderState), color = android.R.color.holo_green_light, showEditText = true, ), @@ -103,13 +103,27 @@ object NestedOverlaysWorkflow : StatefulWorkflow() name = "outer", overlays = listOfNotNull(outerSheet), body = TopAndBottomBarsScreen( - topBar = if (!renderState.showTopBar) null else context.topBottomBar(renderState), + topBar = if (!renderState.showTopBar) { + null + } else { + context.topBottomBar( + top = true, + renderState + ) + }, content = BodyAndOverlaysScreen( name = "inner", body = bodyBarButtons, overlays = listOfNotNull(innerSheet) ), - bottomBar = if (!renderState.showBottomBar) null else context.topBottomBar(renderState) + bottomBar = if (!renderState.showBottomBar) { + null + } else { + context.topBottomBar( + top = false, + renderState + ) + } ) ) } @@ -117,19 +131,31 @@ object NestedOverlaysWorkflow : StatefulWorkflow() override fun snapshotState(state: State) = null private fun RenderContext.topBottomBar( + top: Boolean, renderState: State - ) = ButtonBar( - toggleInnerSheetButton(renderState), - Button( - name = R.string.cover_all, - onClick = eventHandler("cover everything") { state = state.copy(showOuterSheet = true) } + ): ButtonBar { + val name = if (top) "top" else "bottom" + return ButtonBar( + toggleInnerSheetButton( + name = name, + renderState = renderState, + ), + Button( + name = R.string.cover_all, + onClick = eventHandler("$name cover everything") { + state = state.copy(showOuterSheet = true) + } + ) ) - ) + } - private fun RenderContext.toggleInnerSheetButton(renderState: State) = + private fun RenderContext.toggleInnerSheetButton( + name: String, + renderState: State + ) = Button( name = if (renderState.showInnerSheet) R.string.reveal_body else R.string.cover_body, - onClick = eventHandler("reveal / cover body") { + onClick = eventHandler("$name: reveal / cover body") { state = state.copy(showInnerSheet = !state.showInnerSheet) } ) diff --git a/workflow-core/api/workflow-core.api b/workflow-core/api/workflow-core.api index 3ff8f29942..7cddccb148 100644 --- a/workflow-core/api/workflow-core.api +++ b/workflow-core/api/workflow-core.api @@ -23,38 +23,98 @@ public final class com/squareup/workflow1/ActionsExhausted : com/squareup/workfl } public abstract interface class com/squareup/workflow1/BaseRenderContext { - public abstract fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function10;)Lkotlin/jvm/functions/Function9; - public abstract fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function11;)Lkotlin/jvm/functions/Function10; - public abstract fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; - public abstract fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function2;)Lkotlin/jvm/functions/Function1; - public abstract fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function3;)Lkotlin/jvm/functions/Function2; - public abstract fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function4;)Lkotlin/jvm/functions/Function3; - public abstract fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function5;)Lkotlin/jvm/functions/Function4; - public abstract fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function6;)Lkotlin/jvm/functions/Function5; - public abstract fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function7;)Lkotlin/jvm/functions/Function6; - public abstract fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function8;)Lkotlin/jvm/functions/Function7; - public abstract fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function9;)Lkotlin/jvm/functions/Function8; public abstract fun getActionSink ()Lcom/squareup/workflow1/Sink; + public abstract fun getRuntimeConfig ()Ljava/util/Set; public abstract fun getWorkflowTracer ()Lcom/squareup/workflow1/WorkflowTracer; + public abstract fun remember (Ljava/lang/String;Lkotlin/reflect/KType;[Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; public abstract fun renderChild (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; public abstract fun runningSideEffect (Ljava/lang/String;Lkotlin/jvm/functions/Function2;)V } public final class com/squareup/workflow1/BaseRenderContext$DefaultImpls { - public static fun eventHandler (Lcom/squareup/workflow1/BaseRenderContext;Ljava/lang/String;Lkotlin/jvm/functions/Function10;)Lkotlin/jvm/functions/Function9; - public static fun eventHandler (Lcom/squareup/workflow1/BaseRenderContext;Ljava/lang/String;Lkotlin/jvm/functions/Function11;)Lkotlin/jvm/functions/Function10; - public static fun eventHandler (Lcom/squareup/workflow1/BaseRenderContext;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; - public static fun eventHandler (Lcom/squareup/workflow1/BaseRenderContext;Ljava/lang/String;Lkotlin/jvm/functions/Function2;)Lkotlin/jvm/functions/Function1; - public static fun eventHandler (Lcom/squareup/workflow1/BaseRenderContext;Ljava/lang/String;Lkotlin/jvm/functions/Function3;)Lkotlin/jvm/functions/Function2; - public static fun eventHandler (Lcom/squareup/workflow1/BaseRenderContext;Ljava/lang/String;Lkotlin/jvm/functions/Function4;)Lkotlin/jvm/functions/Function3; - public static fun eventHandler (Lcom/squareup/workflow1/BaseRenderContext;Ljava/lang/String;Lkotlin/jvm/functions/Function5;)Lkotlin/jvm/functions/Function4; - public static fun eventHandler (Lcom/squareup/workflow1/BaseRenderContext;Ljava/lang/String;Lkotlin/jvm/functions/Function6;)Lkotlin/jvm/functions/Function5; - public static fun eventHandler (Lcom/squareup/workflow1/BaseRenderContext;Ljava/lang/String;Lkotlin/jvm/functions/Function7;)Lkotlin/jvm/functions/Function6; - public static fun eventHandler (Lcom/squareup/workflow1/BaseRenderContext;Ljava/lang/String;Lkotlin/jvm/functions/Function8;)Lkotlin/jvm/functions/Function7; - public static fun eventHandler (Lcom/squareup/workflow1/BaseRenderContext;Ljava/lang/String;Lkotlin/jvm/functions/Function9;)Lkotlin/jvm/functions/Function8; public static synthetic fun renderChild$default (Lcom/squareup/workflow1/BaseRenderContext;Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Object; } +public final class com/squareup/workflow1/HandlerBox1 { + public field handler Lkotlin/jvm/functions/Function1; + public fun ()V + public final fun getHandler ()Lkotlin/jvm/functions/Function1; + public final fun getStableHandler ()Lkotlin/jvm/functions/Function1; + public final fun setHandler (Lkotlin/jvm/functions/Function1;)V +} + +public final class com/squareup/workflow1/HandlerBox10 { + public field handler Lkotlin/jvm/functions/Function10; + public fun ()V + public final fun getHandler ()Lkotlin/jvm/functions/Function10; + public final fun getStableHandler ()Lkotlin/jvm/functions/Function10; + public final fun setHandler (Lkotlin/jvm/functions/Function10;)V +} + +public final class com/squareup/workflow1/HandlerBox2 { + public field handler Lkotlin/jvm/functions/Function2; + public fun ()V + public final fun getHandler ()Lkotlin/jvm/functions/Function2; + public final fun getStableHandler ()Lkotlin/jvm/functions/Function2; + public final fun setHandler (Lkotlin/jvm/functions/Function2;)V +} + +public final class com/squareup/workflow1/HandlerBox3 { + public field handler Lkotlin/jvm/functions/Function3; + public fun ()V + public final fun getHandler ()Lkotlin/jvm/functions/Function3; + public final fun getStableHandler ()Lkotlin/jvm/functions/Function3; + public final fun setHandler (Lkotlin/jvm/functions/Function3;)V +} + +public final class com/squareup/workflow1/HandlerBox4 { + public field handler Lkotlin/jvm/functions/Function4; + public fun ()V + public final fun getHandler ()Lkotlin/jvm/functions/Function4; + public final fun getStableHandler ()Lkotlin/jvm/functions/Function4; + public final fun setHandler (Lkotlin/jvm/functions/Function4;)V +} + +public final class com/squareup/workflow1/HandlerBox5 { + public field handler Lkotlin/jvm/functions/Function5; + public fun ()V + public final fun getHandler ()Lkotlin/jvm/functions/Function5; + public final fun getStableHandler ()Lkotlin/jvm/functions/Function5; + public final fun setHandler (Lkotlin/jvm/functions/Function5;)V +} + +public final class com/squareup/workflow1/HandlerBox6 { + public field handler Lkotlin/jvm/functions/Function6; + public fun ()V + public final fun getHandler ()Lkotlin/jvm/functions/Function6; + public final fun getStableHandler ()Lkotlin/jvm/functions/Function6; + public final fun setHandler (Lkotlin/jvm/functions/Function6;)V +} + +public final class com/squareup/workflow1/HandlerBox7 { + public field handler Lkotlin/jvm/functions/Function7; + public fun ()V + public final fun getHandler ()Lkotlin/jvm/functions/Function7; + public final fun getStableHandler ()Lkotlin/jvm/functions/Function7; + public final fun setHandler (Lkotlin/jvm/functions/Function7;)V +} + +public final class com/squareup/workflow1/HandlerBox8 { + public field handler Lkotlin/jvm/functions/Function8; + public fun ()V + public final fun getHandler ()Lkotlin/jvm/functions/Function8; + public final fun getStableHandler ()Lkotlin/jvm/functions/Function8; + public final fun setHandler (Lkotlin/jvm/functions/Function8;)V +} + +public final class com/squareup/workflow1/HandlerBox9 { + public field handler Lkotlin/jvm/functions/Function9; + public fun ()V + public final fun getHandler ()Lkotlin/jvm/functions/Function9; + public final fun getStableHandler ()Lkotlin/jvm/functions/Function9; + public final fun setHandler (Lkotlin/jvm/functions/Function9;)V +} + public abstract interface class com/squareup/workflow1/IdCacheable { public abstract fun getCachedIdentifier ()Lcom/squareup/workflow1/WorkflowIdentifier; public abstract fun setCachedIdentifier (Lcom/squareup/workflow1/WorkflowIdentifier;)V @@ -101,6 +161,22 @@ public final class com/squareup/workflow1/PropsUpdated : com/squareup/workflow1/ public static final field INSTANCE Lcom/squareup/workflow1/PropsUpdated; } +public final class com/squareup/workflow1/RuntimeConfigOptions : java/lang/Enum { + public static final field CONFLATE_STALE_RENDERINGS Lcom/squareup/workflow1/RuntimeConfigOptions; + public static final field Companion Lcom/squareup/workflow1/RuntimeConfigOptions$Companion; + public static final field PARTIAL_TREE_RENDERING Lcom/squareup/workflow1/RuntimeConfigOptions; + public static final field RENDER_ONLY_WHEN_STATE_CHANGES Lcom/squareup/workflow1/RuntimeConfigOptions; + public static final field STABLE_EVENT_HANDLERS Lcom/squareup/workflow1/RuntimeConfigOptions; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/squareup/workflow1/RuntimeConfigOptions; + public static fun values ()[Lcom/squareup/workflow1/RuntimeConfigOptions; +} + +public final class com/squareup/workflow1/RuntimeConfigOptions$Companion { + public final fun getDEFAULT_CONFIG ()Ljava/util/Set; + public final fun getRENDER_PER_ACTION ()Ljava/util/Set; +} + public abstract class com/squareup/workflow1/SessionWorkflow : com/squareup/workflow1/StatefulWorkflow { public fun ()V public final fun initialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;)Ljava/lang/Object; @@ -176,19 +252,13 @@ public abstract class com/squareup/workflow1/StatefulWorkflow : com/squareup/wor } public final class com/squareup/workflow1/StatefulWorkflow$RenderContext : com/squareup/workflow1/BaseRenderContext { - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function10;)Lkotlin/jvm/functions/Function9; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function11;)Lkotlin/jvm/functions/Function10; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function2;)Lkotlin/jvm/functions/Function1; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function3;)Lkotlin/jvm/functions/Function2; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function4;)Lkotlin/jvm/functions/Function3; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function5;)Lkotlin/jvm/functions/Function4; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function6;)Lkotlin/jvm/functions/Function5; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function7;)Lkotlin/jvm/functions/Function6; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function8;)Lkotlin/jvm/functions/Function7; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function9;)Lkotlin/jvm/functions/Function8; + public final fun eventHandler (Ljava/lang/String;Ljava/lang/Boolean;Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; + public static synthetic fun eventHandler$default (Lcom/squareup/workflow1/StatefulWorkflow$RenderContext;Ljava/lang/String;Ljava/lang/Boolean;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lkotlin/jvm/functions/Function0; public fun getActionSink ()Lcom/squareup/workflow1/Sink; + public fun getRuntimeConfig ()Ljava/util/Set; + public final fun getStableEventHandlers ()Z public fun getWorkflowTracer ()Lcom/squareup/workflow1/WorkflowTracer; + public fun remember (Ljava/lang/String;Lkotlin/reflect/KType;[Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; public fun renderChild (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; public fun runningSideEffect (Ljava/lang/String;Lkotlin/jvm/functions/Function2;)V } @@ -202,19 +272,13 @@ public abstract class com/squareup/workflow1/StatelessWorkflow : com/squareup/wo } public final class com/squareup/workflow1/StatelessWorkflow$RenderContext : com/squareup/workflow1/BaseRenderContext { - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function10;)Lkotlin/jvm/functions/Function9; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function11;)Lkotlin/jvm/functions/Function10; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function2;)Lkotlin/jvm/functions/Function1; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function3;)Lkotlin/jvm/functions/Function2; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function4;)Lkotlin/jvm/functions/Function3; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function5;)Lkotlin/jvm/functions/Function4; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function6;)Lkotlin/jvm/functions/Function5; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function7;)Lkotlin/jvm/functions/Function6; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function8;)Lkotlin/jvm/functions/Function7; - public fun eventHandler (Ljava/lang/String;Lkotlin/jvm/functions/Function9;)Lkotlin/jvm/functions/Function8; + public final fun eventHandler (Ljava/lang/String;Ljava/lang/Boolean;Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; + public static synthetic fun eventHandler$default (Lcom/squareup/workflow1/StatelessWorkflow$RenderContext;Ljava/lang/String;Ljava/lang/Boolean;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lkotlin/jvm/functions/Function0; public fun getActionSink ()Lcom/squareup/workflow1/Sink; + public fun getRuntimeConfig ()Ljava/util/Set; + public final fun getStableEventHandlers ()Z public fun getWorkflowTracer ()Lcom/squareup/workflow1/WorkflowTracer; + public fun remember (Ljava/lang/String;Lkotlin/reflect/KType;[Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; public fun renderChild (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; public fun runningSideEffect (Ljava/lang/String;Lkotlin/jvm/functions/Function2;)V } @@ -273,6 +337,9 @@ public final class com/squareup/workflow1/WorkflowAction$Updater { public abstract interface annotation class com/squareup/workflow1/WorkflowExperimentalApi : java/lang/annotation/Annotation { } +public abstract interface annotation class com/squareup/workflow1/WorkflowExperimentalRuntime : java/lang/annotation/Annotation { +} + public final class com/squareup/workflow1/WorkflowIdentifier { public static final field Companion Lcom/squareup/workflow1/WorkflowIdentifier$Companion; public fun equals (Ljava/lang/Object;)Z 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 e266145577..2c9e691280 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt @@ -1,8 +1,7 @@ -// Type variance issue: https://github.com/square/workflow-kotlin/issues/891 @file:Suppress( - "EXPERIMENTAL_API_USAGE", + "ktlint:standard:indent", "ktlint:standard:parameter-list-spacing", - "ktlint:standard:parameter-wrapping" + "ktlint:standard:parameter-wrapping", ) @file:JvmMultifileClass @file:JvmName("Workflows") @@ -49,6 +48,8 @@ import kotlin.reflect.typeOf */ public interface BaseRenderContext { + public val runtimeConfig: RuntimeConfig + /** * Accepts a single [WorkflowAction], invokes that action by calling [WorkflowAction.apply] * to update the current state, and optionally emits the returned output value if it is non-null. @@ -117,7 +118,6 @@ public interface BaseRenderContext { * [Job][kotlinx.coroutines.Job] can be extracted from that and used to get guaranteed to be * executed lifecycle hooks, e.g. via [Job.invokeOnCompletion][kotlinx.coroutines.Job.invokeOnCompletion]. * - * * @param key The string key that is used to distinguish between side effects. * @param sideEffect The suspend function that will be launched in a coroutine to perform the * side effect. @@ -128,221 +128,55 @@ public interface BaseRenderContext { ) /** - * Creates a function which builds a [WorkflowAction] from the - * 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("onClick") { state = SomeNewState } - * } - * } - * ) - * - * with this: + * Rather than calling this directly, prefer the inline extension function + * which will capture [resultType] for you. * - * return SomeScreen( - * onClick = context.eventHandler("onClick") { state = SomeNewState } - * ) + * Remember the value calculated by the [calculation] lambda. + * The [calculation] will be run the first time, and on any subsequent render pass where the + * [inputs] have changed. * - * 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. + * @param key used to distinguish between calculations of the same type + * @param inputs any inputs to the [calculation]. The [calculation] will only rerun + * if any of these inputs changes, so make sure you use the exact list of inputs for + * the calculation here. + * @param calculation a lambda that performs the calculation based on the [inputs]. * - * 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("onClick") { - * // 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. + * @throws IllegalArgumentException if [key] has already been used in the current + * `render` call for a lambda of the same shape */ - public fun eventHandler( - name: String, - // Type variance issue: https://github.com/square/workflow-kotlin/issues/891 - update: WorkflowAction< - @UnsafeVariance PropsT, - StateT, - @UnsafeVariance OutputT - >.Updater.() -> Unit - ): () -> Unit { - return { - actionSink.send(action("eH:$name", update)) - } - } - - public fun eventHandler( - name: String, - update: WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( - EventT - ) -> Unit - ): (EventT) -> Unit { - return { event -> - actionSink.send(action("eH:$name") { update(event) }) - } - } - - public fun eventHandler( - name: String, - update: WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( - E1, - E2 - ) -> Unit - ): (E1, E2) -> Unit { - return { e1, e2 -> - actionSink.send(action("eH:$name") { update(e1, e2) }) - } - } - - public fun eventHandler( - name: String, - update: WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( - E1, - E2, - E3 - ) -> Unit - ): (E1, E2, E3) -> Unit { - return { e1, e2, e3 -> - actionSink.send(action("eH:$name") { update(e1, e2, e3) }) - } - } - - public fun eventHandler( - name: String, - update: WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( - E1, - E2, - E3, - E4 - ) -> Unit - ): (E1, E2, E3, E4) -> Unit { - return { e1, e2, e3, e4 -> - actionSink.send(action("eH:$name") { update(e1, e2, e3, e4) }) - } - } - - public fun eventHandler( - name: String, - update: WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( - E1, - E2, - E3, - E4, - E5 - ) -> Unit - ): (E1, E2, E3, E4, E5) -> Unit { - return { e1, e2, e3, e4, e5 -> - actionSink.send(action("eH:$name") { update(e1, e2, e3, e4, e5) }) - } - } - - public fun eventHandler( - name: String, - update: WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( - E1, - E2, - E3, - E4, - E5, - E6 - ) -> Unit - ): (E1, E2, E3, E4, E5, E6) -> Unit { - return { e1, e2, e3, e4, e5, e6 -> - actionSink.send(action("eH:$name") { update(e1, e2, e3, e4, e5, e6) }) - } - } - - public fun eventHandler( - name: String, - update: WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( - E1, - E2, - E3, - E4, - E5, - E6, - E7 - ) -> Unit - ): (E1, E2, E3, E4, E5, E6, E7) -> Unit { - return { e1, e2, e3, e4, e5, e6, e7 -> - actionSink.send(action("eH:$name") { update(e1, e2, e3, e4, e5, e6, e7) }) - } - } - - public fun eventHandler( - name: String, - update: WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.Updater.( - E1, - E2, - E3, - E4, - E5, - E6, - E7, - E8 - ) -> Unit - ): (E1, E2, E3, E4, E5, E6, E7, E8) -> Unit { - return { e1, e2, e3, e4, e5, e6, e7, e8 -> - actionSink.send(action("eH:$name") { update(e1, e2, e3, e4, e5, e6, e7, e8) }) - } - } - - public fun eventHandler( - name: String, - update: WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.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("eH:$name") { update(e1, e2, e3, e4, e5, e6, e7, e8, e9) }) - } - } + public fun remember( + key: String, + resultType: KType, + vararg inputs: Any?, + calculation: () -> ResultT + ): ResultT +} - public fun eventHandler( - name: String, - update: WorkflowAction<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT>.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("eH:$name") { update(e1, e2, e3, e4, e5, e6, e7, e8, e9, e10) }) - } - } +/** + * Remember the value calculated by the [calculation] lambda. The [calculation] + * will be run the first time, and on any subsequent render pass where the + * [inputs] have changed. + * + * The [StatefulWorkflow.RenderContext.eventHandler] and + * [StatelessWorkflow.RenderContext.eventHandler] functions use this + * mechanism to provide lambdas whose identity are stable across + * multiple render passes. + * + * @param key used to distinguish between calculations of the same type + * @param inputs any inputs to the [calculation]. The [calculation] will only rerun + * if any of these inputs changes, so make sure you use the exact list of inputs for + * the calculation here. + * @param calculation a lambda that performs the calculation based on the [inputs]. + * + * @throws IllegalArgumentException if [key] has already been used in the current + * `render` call for a lambda of the same shape + */ +public inline fun BaseRenderContext<*, *, *>.remember( + key: String, + vararg inputs: Any?, + noinline calculation: () -> ResultT +): ResultT { + return remember(key, typeOf(), inputs = inputs, calculation) } /** @@ -350,20 +184,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 @@ -371,9 +205,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, @@ -386,9 +220,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. @@ -416,10 +250,10 @@ public inline fun */ 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) } @@ -434,11 +268,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 = workflowTracer.trace("CreateWorkerWorkflow") { WorkerWorkflow(workerType, key) } diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/HandlerBox.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/HandlerBox.kt new file mode 100644 index 0000000000..240699c8fa --- /dev/null +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/HandlerBox.kt @@ -0,0 +1,413 @@ +package com.squareup.workflow1 + +import kotlin.reflect.typeOf + +internal class HandlerBox0 { + lateinit var handler: () -> Unit + val stableHandler: () -> Unit = { handler() } +} + +internal fun BaseRenderContext.eventHandler0( + name: String, + remember: Boolean, + update: Updater.() -> Unit +): () -> Unit { + val handler = { actionSink.send(action("eH: $name", update)) } + return if (remember) { + val box = remember(name) { HandlerBox0() } + box.handler = handler + box.stableHandler + } else { + handler + } +} + +@PublishedApi +internal class HandlerBox1 { + lateinit var handler: (E) -> Unit + val stableHandler: (E) -> Unit = { handler(it) } +} + +@PublishedApi +internal inline fun BaseRenderContext.eventHandler1( + name: String, + remember: Boolean, + noinline update: Updater.(EventT) -> Unit +): (EventT) -> Unit { + val handler = { e: EventT -> actionSink.send(action("eH: $name") { update(e) }) } + return if (remember) { + val box = remember(name, typeOf()) { HandlerBox1() } + box.handler = handler + box.stableHandler + } else { + handler + } +} + +@PublishedApi +internal class HandlerBox2 { + lateinit var handler: (E1, E2) -> Unit + val stableHandler: (E1, E2) -> Unit = { e1, e2 -> handler(e1, e2) } +} + +@PublishedApi +internal inline fun BaseRenderContext.eventHandler2( + name: String, + remember: Boolean, + noinline update: Updater.(E1, E2) -> Unit +): (E1, E2) -> Unit { + val handler = { e1: E1, e2: E2 -> actionSink.send(action("eH: $name") { update(e1, e2) }) } + return if (remember) { + val box = remember(name, typeOf(), typeOf()) { HandlerBox2() } + box.handler = handler + box.stableHandler + } else { + handler + } +} + +@PublishedApi +internal class HandlerBox3 { + lateinit var handler: (E1, E2, E3) -> Unit + val stableHandler: (E1, E2, E3) -> Unit = { e1, e2, e3 -> handler(e1, e2, e3) } +} + +@PublishedApi +internal inline fun < + P, + S, + O, + reified E1, + reified E2, + reified E3, + > BaseRenderContext.eventHandler3( + name: String, + remember: Boolean, + noinline update: Updater.(E1, E2, E3) -> Unit +): (E1, E2, E3) -> Unit { + val handler = + { e1: E1, e2: E2, e3: E3 -> actionSink.send(action("eH: $name") { update(e1, e2, e3) }) } + return if (remember) { + val box = + remember(name, typeOf(), typeOf(), typeOf()) { HandlerBox3() } + box.handler = handler + box.stableHandler + } else { + handler + } +} + +@PublishedApi +internal class HandlerBox4 { + lateinit var handler: (E1, E2, E3, E4) -> Unit + val stableHandler: (E1, E2, E3, E4) -> Unit = { e1, e2, e3, e4 -> handler(e1, e2, e3, e4) } +} + +@PublishedApi +internal inline fun < + P, + S, + O, + reified E1, + reified E2, + reified E3, + reified E4, + > BaseRenderContext.eventHandler4( + name: String, + remember: Boolean, + noinline update: Updater.(E1, E2, E3, E4) -> Unit +): (E1, E2, E3, E4) -> Unit { + val handler = { e1: E1, e2: E2, e3: E3, e4: E4 -> + actionSink.send(action("eH: $name") { update(e1, e2, e3, e4) }) + } + return if (remember) { + val box = remember( + name, + typeOf(), + typeOf(), + typeOf(), + typeOf() + ) { HandlerBox4() } + box.handler = handler + box.stableHandler + } else { + handler + } +} + +@PublishedApi +internal class HandlerBox5 { + lateinit var handler: (E1, E2, E3, E4, E5) -> Unit + val stableHandler: (E1, E2, E3, E4, E5) -> Unit = + { e1, e2, e3, e4, e5 -> handler(e1, e2, e3, e4, e5) } +} + +@PublishedApi +internal inline fun < + P, + S, + O, + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + > BaseRenderContext.eventHandler5( + name: String, + remember: Boolean, + noinline update: Updater.(E1, E2, E3, E4, E5) -> Unit +): (E1, E2, E3, E4, E5) -> Unit { + val handler = { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5 -> + actionSink.send(action("eH: $name") { update(e1, e2, e3, e4, e5) }) + } + return if (remember) { + val box = remember( + name, + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf() + ) { HandlerBox5() } + box.handler = handler + box.stableHandler + } else { + handler + } +} + +@PublishedApi +internal class HandlerBox6 { + lateinit var handler: (E1, E2, E3, E4, E5, E6) -> Unit + val stableHandler: (E1, E2, E3, E4, E5, E6) -> Unit = + { e1, e2, e3, e4, e5, e6 -> handler(e1, e2, e3, e4, e5, e6) } +} + +@PublishedApi +internal inline fun < + P, + S, + O, + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + > BaseRenderContext.eventHandler6( + name: String, + remember: Boolean, + noinline update: Updater.(E1, E2, E3, E4, E5, E6) -> Unit +): (E1, E2, E3, E4, E5, E6) -> Unit { + val handler = { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5, e6: E6 -> + actionSink.send(action("eH: $name") { update(e1, e2, e3, e4, e5, e6) }) + } + return if (remember) { + val box = remember( + name, + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf() + ) { HandlerBox6() } + box.handler = handler + box.stableHandler + } else { + handler + } +} + +@PublishedApi +internal class HandlerBox7 { + lateinit var handler: (E1, E2, E3, E4, E5, E6, E7) -> Unit + val stableHandler: (E1, E2, E3, E4, E5, E6, E7) -> Unit = + { e1, e2, e3, e4, e5, e6, e7 -> handler(e1, e2, e3, e4, e5, e6, e7) } +} + +@PublishedApi +internal inline fun < + P, + S, + O, + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + reified E7, + > BaseRenderContext.eventHandler7( + name: String, + remember: Boolean, + noinline update: Updater.(E1, E2, E3, E4, E5, E6, E7) -> Unit +): (E1, E2, E3, E4, E5, E6, E7) -> Unit { + val handler = { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5, e6: E6, e7: E7 -> + actionSink.send(action("eH: $name") { update(e1, e2, e3, e4, e5, e6, e7) }) + } + return if (remember) { + val box = remember( + name, + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf() + ) { HandlerBox7() } + box.handler = handler + box.stableHandler + } else { + handler + } +} + +@PublishedApi +internal class HandlerBox8 { + lateinit var handler: (E1, E2, E3, E4, E5, E6, E7, E8) -> Unit + val stableHandler: (E1, E2, E3, E4, E5, E6, E7, E8) -> Unit = + { e1, e2, e3, e4, e5, e6, e7, e8 -> handler(e1, e2, e3, e4, e5, e6, e7, e8) } +} + +@PublishedApi +internal inline fun < + P, + S, + O, + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + reified E7, + reified E8, + > BaseRenderContext.eventHandler8( + name: String, + remember: Boolean, + noinline update: Updater.(E1, E2, E3, E4, E5, E6, E7, E8) -> Unit +): (E1, E2, E3, E4, E5, E6, E7, E8) -> Unit { + val handler = { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5, e6: E6, e7: E7, e8: E8 -> + actionSink.send(action("eH: $name") { update(e1, e2, e3, e4, e5, e6, e7, e8) }) + } + return if (remember) { + val box = remember( + name, + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf() + ) { HandlerBox8() } + box.handler = handler + box.stableHandler + } else { + handler + } +} + +@PublishedApi +internal class HandlerBox9 { + lateinit var handler: (E1, E2, E3, E4, E5, E6, E7, E8, E9) -> Unit + val stableHandler: (E1, E2, E3, E4, E5, E6, E7, E8, E9) -> Unit = + { e1, e2, e3, e4, e5, e6, e7, e8, e9 -> handler(e1, e2, e3, e4, e5, e6, e7, e8, e9) } +} + +@PublishedApi +internal inline fun < + P, + S, + O, + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + reified E7, + reified E8, + reified E9, + > BaseRenderContext.eventHandler9( + name: String, + remember: Boolean, + noinline update: Updater.(E1, E2, E3, E4, E5, E6, E7, E8, E9) -> Unit +): (E1, E2, E3, E4, E5, E6, E7, E8, E9) -> Unit { + val handler = { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5, e6: E6, e7: E7, e8: E8, e9: E9 -> + actionSink.send(action("eH: $name") { update(e1, e2, e3, e4, e5, e6, e7, e8, e9) }) + } + return if (remember) { + val box = remember( + name, + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf() + ) { HandlerBox9() } + box.handler = handler + box.stableHandler + } else { + handler + } +} + +@PublishedApi +internal class HandlerBox10 { + lateinit var handler: (E1, E2, E3, E4, E5, E6, E7, E8, E9, E10) -> Unit + val stableHandler: (E1, E2, E3, E4, E5, E6, E7, E8, E9, E10) -> Unit = + { e1, e2, e3, e4, e5, e6, e7, e8, e9, e10 -> handler(e1, e2, e3, e4, e5, e6, e7, e8, e9, e10) } +} + +@PublishedApi +internal inline fun < + P, + S, + O, + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + reified E7, + reified E8, + reified E9, + reified E10, + > BaseRenderContext.eventHandler10( + name: String, + remember: Boolean, + noinline update: Updater.(E1, E2, E3, E4, E5, E6, E7, E8, E9, E10) -> Unit +): (E1, E2, E3, E4, E5, E6, E7, E8, E9, E10) -> Unit { + val handler = + { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5, e6: E6, e7: E7, e8: E8, e9: E9, e10: E10 -> + actionSink.send(action("eH: $name") { update(e1, e2, e3, e4, e5, e6, e7, e8, e9, e10) }) + } + return if (remember) { + val box = remember( + name, + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf(), + typeOf() + ) { HandlerBox10() } + box.handler = handler + box.stableHandler + } else { + handler + } +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt similarity index 89% rename from workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt rename to workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt index 5bbc237b19..44b0c3c5b6 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt @@ -58,14 +58,22 @@ public enum class RuntimeConfigOptions { * If we have more actions to process, do so before passing the rendering to the UI layer. */ @WorkflowExperimentalRuntime - CONFLATE_STALE_RENDERINGS; + CONFLATE_STALE_RENDERINGS, + + /** + * Changes the default value of the `remember: Boolean?` parameter of + * `RenderContext.eventHandler` calls from `false` to `true`. + */ + @WorkflowExperimentalRuntime + STABLE_EVENT_HANDLERS, + ; public companion object { /** * Baseline configuration where we render for each action and always pass the rendering to * the view layer. */ - public val RENDER_PER_ACTION: RuntimeConfig = emptySet() + public val RENDER_PER_ACTION: RuntimeConfig = emptySet() public val DEFAULT_CONFIG: RuntimeConfig = RENDER_PER_ACTION } 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 ce1493f090..a3174c3092 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatefulWorkflow.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatefulWorkflow.kt @@ -1,8 +1,10 @@ @file:JvmMultifileClass @file:JvmName("Workflows") +@file:Suppress("ktlint:standard:indent") package com.squareup.workflow1 +import com.squareup.workflow1.RuntimeConfigOptions.STABLE_EVENT_HANDLERS import com.squareup.workflow1.StatefulWorkflow.RenderContext import com.squareup.workflow1.WorkflowAction.Companion.toString import kotlinx.coroutines.CoroutineScope @@ -76,6 +78,266 @@ public abstract class StatefulWorkflow< public inner class RenderContext internal constructor( baseContext: BaseRenderContext ) : BaseRenderContext<@UnsafeVariance PropsT, StateT, @UnsafeVariance OutputT> by baseContext { + @PublishedApi + @OptIn(WorkflowExperimentalRuntime::class) + internal val stableEventHandlers: Boolean = + baseContext.runtimeConfig.contains(STABLE_EVENT_HANDLERS) + + /** + * Creates a function which builds a [WorkflowAction] from the + * 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("onClick") { state = SomeNewState } + * } + * } + * ) + * + * with this: + * + * return SomeScreen( + * onClick = context.eventHandler("onClick") { 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("enter SomeNewState") { + * // This happens eventually. + * state = SomeNewState + * } + * } + * } + * ) + * + * It is also important for Compose developers to understand that + * a new function is created for a particular [eventHandler] call + * each time [render] is called, which causes problems in Compose + * UI code -- the lambdas from the current render pass will not be + * `==` to those from the previous one, and unnecessary recomposition + * will happen as a result. To prevent that problem set the [remember] + * parameter to `true`, or set the [STABLE_EVENT_HANDLERS] option + * in the [runtimeConfig]. + * + * This problem will also be true of hand written handlers like + * the `MyAnalytics.log` example above. Use the [BaseRenderContext.remember] + * function to keep such bespoke lambdas stable. (This is what [eventHandler] + * does when [remember] is true.) + * + * val onClick = remember("onClick") { + * { + * MyAnalytics.log("SomeScreen was clicked") + * + * context.actionSink.send( + * action("enter SomeNewState") { + * state = SomeNewState + * } + * ) + * } + * } + * + * return SomeScreen(onclick) + * + * @param name If [remember] is true, used as a unique key to distinguish + * event handlers with same number and type of parameters. Also used + * for descriptive logging and error messages. + * + * @param remember When true uses [RenderContext.remember] to ensure + * that the same lambda is returned across multiple render passes, + * allowing Compose to avoid unnecessary recomposition on updates. + * + * When false a new lambda is created on each call, and [name] + * is used only for descriptive logging and error messages. + * + * When `null` a default value of `false` is used unless + * [STABLE_EVENT_HANDLERS] has been specified in the [runtimeConfig]. + * + * @throws IllegalArgumentException if [remember] is true and [name] + * has already been used in the current [render] call for a lambda of + * the same shape + */ + public fun eventHandler( + name: String, + remember: Boolean? = null, + update: Updater.() -> Unit + ): () -> Unit = eventHandler0(name, remember ?: stableEventHandlers, update) + + public inline fun eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.(EventT) -> Unit + ): (EventT) -> Unit { + val eh = eventHandler1(name, remember ?: stableEventHandlers, update) + return eh + } + + public inline fun eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.(E1, E2) -> Unit + ): (E1, E2) -> Unit = eventHandler2(name, remember ?: stableEventHandlers, update) + + public inline fun eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.(E1, E2, E3) -> Unit + ): (E1, E2, E3) -> Unit = eventHandler3(name, remember ?: stableEventHandlers, update) + + public inline fun eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.(E1, E2, E3, E4) -> Unit + ): (E1, E2, E3, E4) -> Unit = eventHandler4(name, remember ?: stableEventHandlers, update) + + public inline fun eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.(E1, E2, E3, E4, E5) -> Unit + ): (E1, E2, E3, E4, E5) -> Unit = eventHandler5(name, remember ?: stableEventHandlers, update) + + public inline fun < + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + > eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.( + E1, + E2, + E3, + E4, + E5, + E6, + ) -> Unit + ): (E1, E2, E3, E4, E5, E6) -> Unit = + eventHandler6(name, remember ?: stableEventHandlers, update) + + public inline fun < + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + reified E7, + > eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.( + E1, + E2, + E3, + E4, + E5, + E6, + E7, + ) -> Unit + ): (E1, E2, E3, E4, E5, E6, E7) -> Unit { + return eventHandler7(name, remember ?: stableEventHandlers, update) + } + + public inline fun < + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + reified E7, + reified E8, + > eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.( + E1, + E2, + E3, + E4, + E5, + E6, + E7, + E8, + ) -> Unit + ): (E1, E2, E3, E4, E5, E6, E7, E8) -> Unit = + eventHandler8(name, remember ?: stableEventHandlers, update) + + public inline fun < + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + reified E7, + reified E8, + reified E9, + > eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.( + E1, + E2, + E3, + E4, + E5, + E6, + E7, + E8, + E9, + ) -> Unit + ): (E1, E2, E3, E4, E5, E6, E7, E8, E9) -> Unit = + eventHandler9(name, remember ?: stableEventHandlers, update) + + public inline fun < + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + reified E7, + reified E8, + reified E9, + reified E10, + > eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.( + E1, + E2, + E3, + E4, + E5, + E6, + E7, + E8, + E9, + E10, + ) -> Unit + ): (E1, E2, E3, E4, E5, E6, E7, E8, E9, E10) -> Unit = + eventHandler10(name, remember ?: stableEventHandlers, update) + /** * Like [eventHandler], but no-ops if [state][WorkflowAction.Updater.state] has * changed to a different type than [CurrentStateT] by the time [update] fires. @@ -114,92 +376,107 @@ public abstract class StatefulWorkflow< */ public inline fun safeEventHandler( name: String, + remember: Boolean? = null, crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = ::defaultOnFailedCast, - // Type variance issue: https://github.com/square/workflow-kotlin/issues/891 crossinline update: WorkflowAction< - @UnsafeVariance PropsT, + PropsT, StateT, - @UnsafeVariance OutputT + OutputT >.Updater.(currentState: CurrentStateT) -> Unit - ): () -> Unit { - return eventHandler(name) { + ): () -> Unit = + eventHandler(name, remember) { CurrentStateT::class.safeCast(state)?.let { currentState -> this.update(currentState) } ?: onFailedCast(name, CurrentStateT::class, state) } - } - public inline fun safeEventHandler( + public inline fun safeEventHandler( name: String, + remember: Boolean? = null, crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = ::defaultOnFailedCast, crossinline update: WorkflowAction< - @UnsafeVariance PropsT, + PropsT, StateT, - @UnsafeVariance OutputT + OutputT >.Updater.( currentState: CurrentStateT, event: EventT ) -> Unit - ): (EventT) -> Unit { - return eventHandler(name) { event: EventT -> + ): (EventT) -> Unit = + eventHandler(name, remember) { event -> CurrentStateT::class.safeCast(state) ?.let { currentState -> this.update(currentState, event) } ?: onFailedCast(name, CurrentStateT::class, state) } - } - public inline fun safeEventHandler( + public inline fun < + reified CurrentStateT : StateT & Any, + reified E1, + reified E2 + > safeEventHandler( name: String, + remember: Boolean? = null, crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = ::defaultOnFailedCast, crossinline update: WorkflowAction< - @UnsafeVariance PropsT, + PropsT, StateT, - @UnsafeVariance OutputT + OutputT >.Updater.( currentState: CurrentStateT, e1: E1, e2: E2 ) -> Unit - ): (E1, E2) -> Unit { - return eventHandler(name) { e1: E1, e2: E2 -> + ): (E1, E2) -> Unit = + eventHandler(name, remember) { e1, e2 -> CurrentStateT::class.safeCast(state) ?.let { currentState -> this.update(currentState, e1, e2) } ?: onFailedCast(name, CurrentStateT::class, state) } - } - public inline fun safeEventHandler( + public inline fun < + reified CurrentStateT : StateT & Any, + reified E1, + reified E2, + reified E3 + > safeEventHandler( name: String, + remember: Boolean? = null, crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = ::defaultOnFailedCast, crossinline update: WorkflowAction< - @UnsafeVariance PropsT, + PropsT, StateT, - @UnsafeVariance OutputT + OutputT >.Updater.( currentState: CurrentStateT, e1: E1, e2: E2, e3: E3 ) -> Unit - ): (E1, E2, E3) -> Unit { - return eventHandler(name) { e1: E1, e2: E2, e3: E3 -> + ): (E1, E2, E3) -> Unit = + eventHandler(name, remember) { e1, e2, e3 -> CurrentStateT::class.safeCast(state) ?.let { currentState -> this.update(currentState, e1, e2, e3) } ?: onFailedCast(name, CurrentStateT::class, state) } - } - public inline fun safeEventHandler( + public inline fun < + reified CurrentStateT : StateT & Any, + reified E1, + reified E2, + reified E3, + reified E4, + > safeEventHandler( name: String, + remember: Boolean? = null, crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = ::defaultOnFailedCast, crossinline update: WorkflowAction< - @UnsafeVariance PropsT, + PropsT, StateT, - @UnsafeVariance OutputT + OutputT >.Updater.( currentState: CurrentStateT, e1: E1, @@ -207,22 +484,29 @@ public abstract class StatefulWorkflow< e3: E3, e4: E4 ) -> Unit - ): (E1, E2, E3, E4) -> Unit { - return eventHandler(name) { e1: E1, e2: E2, e3: E3, e4: E4 -> + ): (E1, E2, E3, E4) -> Unit = + eventHandler(name, remember) { e1, e2, e3, e4 -> CurrentStateT::class.safeCast(state) ?.let { currentState -> this.update(currentState, e1, e2, e3, e4) } ?: onFailedCast(name, CurrentStateT::class, state) } - } - public inline fun safeEventHandler( + public inline fun < + reified CurrentStateT : StateT & Any, + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + > safeEventHandler( name: String, + remember: Boolean? = null, crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = ::defaultOnFailedCast, crossinline update: WorkflowAction< - @UnsafeVariance PropsT, + PropsT, StateT, - @UnsafeVariance OutputT + OutputT >.Updater.( currentState: CurrentStateT, e1: E1, @@ -231,30 +515,30 @@ public abstract class StatefulWorkflow< e4: E4, e5: E5 ) -> Unit - ): (E1, E2, E3, E4, E5) -> Unit { - return eventHandler(name) { e1: E1, e2: E2, e3: E3, e4: E4, e5: E5 -> + ): (E1, E2, E3, E4, E5) -> Unit = + eventHandler(name, remember) { e1, e2, e3, e4, 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 + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, > safeEventHandler( name: String, + remember: Boolean? = null, crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = ::defaultOnFailedCast, crossinline update: WorkflowAction< - @UnsafeVariance PropsT, + PropsT, StateT, - @UnsafeVariance OutputT + OutputT >.Updater.( currentState: CurrentStateT, e1: E1, @@ -264,31 +548,31 @@ public abstract class StatefulWorkflow< 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 -> + ): (E1, E2, E3, E4, E5, E6) -> Unit = + eventHandler(name, remember) { e1, e2, e3, e4, e5, 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 + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + reified E7, > safeEventHandler( name: String, + remember: Boolean? = null, crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = ::defaultOnFailedCast, crossinline update: WorkflowAction< - @UnsafeVariance PropsT, + PropsT, StateT, - @UnsafeVariance OutputT + OutputT >.Updater.( currentState: CurrentStateT, e1: E1, @@ -299,32 +583,32 @@ public abstract class StatefulWorkflow< 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 -> + ): (E1, E2, E3, E4, E5, E6, E7) -> Unit = + eventHandler(name, remember) { e1, e2, e3, e4, e5, e6, 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 + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + reified E7, + reified E8, > safeEventHandler( name: String, + remember: Boolean? = null, crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = ::defaultOnFailedCast, crossinline update: WorkflowAction< - @UnsafeVariance PropsT, + PropsT, StateT, - @UnsafeVariance OutputT + OutputT >.Updater.( currentState: CurrentStateT, e1: E1, @@ -336,33 +620,33 @@ public abstract class StatefulWorkflow< 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 -> + ): (E1, E2, E3, E4, E5, E6, E7, E8) -> Unit = + eventHandler(name, remember) { e1, e2, e3, e4, e5, e6, e7, 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 + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + reified E7, + reified E8, + reified E9, > safeEventHandler( name: String, + remember: Boolean? = null, crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = ::defaultOnFailedCast, crossinline update: WorkflowAction< - @UnsafeVariance PropsT, + PropsT, StateT, - @UnsafeVariance OutputT + OutputT >.Updater.( currentState: CurrentStateT, e1: E1, @@ -375,36 +659,34 @@ public abstract class StatefulWorkflow< 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 -> + ): (E1, E2, E3, E4, E5, E6, E7, E8, E9) -> Unit = + eventHandler(name, remember) { e1, e2, e3, e4, e5, e6, e7, e8, 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 + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + reified E7, + reified E8, + reified E9, + reified E10, > safeEventHandler( name: String, + remember: Boolean? = null, crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = ::defaultOnFailedCast, crossinline update: WorkflowAction< - @UnsafeVariance PropsT, + PropsT, StateT, - @UnsafeVariance OutputT + OutputT >.Updater.( currentState: CurrentStateT, e1: E1, @@ -418,17 +700,14 @@ public abstract class StatefulWorkflow< 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 -> + ): (E1, E2, E3, E4, E5, E6, E7, E8, E9, E10) -> Unit = + eventHandler(name, remember) { e1, e2, e3, e4, e5, e6, e7, e8, e9, 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) } - } } /** @@ -463,9 +742,7 @@ public abstract class StatefulWorkflow< name: String, crossinline onFailedCast: (name: String, type: KClass<*>, state: StateT) -> Unit = ::defaultOnFailedCast, - noinline update: WorkflowAction.Updater.( - currentState: CurrentStateT - ) -> Unit + noinline update: Updater.(currentState: CurrentStateT) -> Unit ): WorkflowAction = action({ name }) { CurrentStateT::class.safeCast(state)?.let { currentState -> this.update(currentState) } ?: onFailedCast(name, CurrentStateT::class, state) @@ -593,7 +870,12 @@ public fun RenderContext( */ public inline fun Workflow.Companion.stateful( crossinline initialState: (PropsT, Snapshot?) -> StateT, - crossinline render: BaseRenderContext.( + crossinline render: StatefulWorkflow< + PropsT, + StateT, + OutputT, + * + >.RenderContext.( props: PropsT, state: StateT ) -> RenderingT, @@ -630,7 +912,12 @@ public inline fun Workflow.Companion.state */ public inline fun Workflow.Companion.stateful( crossinline initialState: (Snapshot?) -> StateT, - crossinline render: BaseRenderContext.(state: StateT) -> RenderingT, + crossinline render: StatefulWorkflow< + Unit, + StateT, + OutputT, + * + >.RenderContext.(state: StateT) -> RenderingT, crossinline snapshot: (StateT) -> Snapshot? ): StatefulWorkflow = stateful( { _, initialSnapshot -> initialState(initialSnapshot) }, @@ -645,7 +932,7 @@ public inline fun Workflow.Companion.stateful( */ public inline fun Workflow.Companion.stateful( crossinline initialState: (PropsT) -> StateT, - crossinline render: BaseRenderContext.( + crossinline render: StatefulWorkflow.RenderContext.( props: PropsT, state: StateT ) -> RenderingT, @@ -668,7 +955,12 @@ public inline fun Workflow.Companion.state */ public inline fun Workflow.Companion.stateful( initialState: StateT, - crossinline render: BaseRenderContext.(state: StateT) -> RenderingT + crossinline render: StatefulWorkflow< + Unit, + StateT, + OutputT, + * + >.RenderContext.(state: StateT) -> RenderingT ): StatefulWorkflow = stateful( { initialState }, { _, state -> render(state) } @@ -684,9 +976,9 @@ public inline fun Workflow.Companion.stateful( */ public fun StatefulWorkflow.action( - name: String, - update: WorkflowAction.Updater.() -> Unit - ): WorkflowAction = action({ name }, update) + name: String, + update: Updater.() -> Unit +): WorkflowAction = action({ name }, update) /** * Convenience to create a [WorkflowAction] with parameter types matching those @@ -698,11 +990,12 @@ public fun * [WorkflowAction.toString]. * @param update Function that defines the workflow update. */ +@Suppress("UnusedReceiverParameter") public fun StatefulWorkflow.action( - name: () -> String, - update: WorkflowAction.Updater.() -> Unit - ): WorkflowAction = object : WorkflowAction() { + name: () -> String, + update: Updater.() -> Unit +): WorkflowAction = object : WorkflowAction() { override val debuggingName: String get() = name() diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatelessWorkflow.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatelessWorkflow.kt index 957c0807bc..32932b1dc4 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatelessWorkflow.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatelessWorkflow.kt @@ -1,8 +1,10 @@ @file:JvmMultifileClass @file:JvmName("Workflows") +@file:Suppress("ktlint:standard:indent") package com.squareup.workflow1 +import com.squareup.workflow1.RuntimeConfigOptions.STABLE_EVENT_HANDLERS import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName @@ -30,7 +32,196 @@ public abstract class StatelessWorkflow public inner class RenderContext internal constructor( baseContext: BaseRenderContext ) : BaseRenderContext<@UnsafeVariance PropsT, Nothing, @UnsafeVariance OutputT> by - baseContext as BaseRenderContext + baseContext as BaseRenderContext { + @PublishedApi + @OptIn(WorkflowExperimentalRuntime::class) + internal val stableEventHandlers: Boolean = + baseContext.runtimeConfig.contains(STABLE_EVENT_HANDLERS) + + /** + * Given an [update] function, wraps it in a lambda suitable for use + * as an event handler on a rendered view model. + * See [StatefulWorkflow.RenderContext.eventHandler] for details. + * + * @param name If [remember] is true, used as a unique key to distinguish + * event handlers with same number and type of parameters. Also used + * for descriptive logging and error messages. + * + * @param remember When true uses [RenderContext.remember] to ensure + * that the same lambda is returned across multiple render passes, + * allowing Compose to avoid unnecessary recomposition on updates. + * + * When false a new lambda is created on each call, and [name] + * is used only for descriptive logging and error messages. + * + * When `null` a default value of `false` is used unless + * [STABLE_EVENT_HANDLERS] has been specified in the [runtimeConfig]. + * + * @throws IllegalArgumentException if [remember] is true and [name] + * has already been used in the current [render] call for a lambda of + * the same shape + */ + public fun eventHandler( + name: String, + remember: Boolean? = null, + update: Updater.() -> Unit + ): () -> Unit = eventHandler0(name, remember ?: stableEventHandlers, update) + + public inline fun eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.(EventT) -> Unit + ): (EventT) -> Unit = eventHandler1(name, remember ?: stableEventHandlers, update) + + public inline fun eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.(E1, E2) -> Unit + ): (E1, E2) -> Unit = eventHandler2(name, remember ?: stableEventHandlers, update) + + public inline fun eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.(E1, E2, E3) -> Unit + ): (E1, E2, E3) -> Unit = eventHandler3(name, remember ?: stableEventHandlers, update) + + public inline fun eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.(E1, E2, E3, E4) -> Unit + ): (E1, E2, E3, E4) -> Unit = eventHandler4(name, remember ?: stableEventHandlers, update) + + public inline fun eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.(E1, E2, E3, E4, E5) -> Unit + ): (E1, E2, E3, E4, E5) -> Unit = eventHandler5(name, remember ?: stableEventHandlers, update) + + public inline fun < + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + > eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.( + E1, + E2, + E3, + E4, + E5, + E6, + ) -> Unit + ): (E1, E2, E3, E4, E5, E6) -> Unit = + eventHandler6(name, remember ?: stableEventHandlers, update) + + public inline fun < + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + reified E7, + > eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.( + E1, + E2, + E3, + E4, + E5, + E6, + E7, + ) -> Unit + ): (E1, E2, E3, E4, E5, E6, E7) -> Unit = + eventHandler7(name, remember ?: stableEventHandlers, update) + + public inline fun < + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + reified E7, + reified E8, + > eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.( + E1, + E2, + E3, + E4, + E5, + E6, + E7, + E8, + ) -> Unit + ): (E1, E2, E3, E4, E5, E6, E7, E8) -> Unit = + eventHandler8(name, remember ?: stableEventHandlers, update) + + public inline fun < + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + reified E7, + reified E8, + reified E9, + > eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.( + E1, + E2, + E3, + E4, + E5, + E6, + E7, + E8, + E9, + ) -> Unit + ): (E1, E2, E3, E4, E5, E6, E7, E8, E9) -> Unit = + eventHandler9(name, remember ?: stableEventHandlers, update) + + public inline fun < + reified E1, + reified E2, + reified E3, + reified E4, + reified E5, + reified E6, + reified E7, + reified E8, + reified E9, + reified E10, + > eventHandler( + name: String, + remember: Boolean? = null, + noinline update: Updater.( + E1, + E2, + E3, + E4, + E5, + E6, + E7, + E8, + E9, + E10, + ) -> Unit + ): (E1, E2, E3, E4, E5, E6, E7, E8, E9, E10) -> Unit = + eventHandler10(name, remember ?: stableEventHandlers, update) + } /** * Class type returned by [asStatefulWorkflow]. @@ -136,7 +327,11 @@ public fun RenderContext( * their own internal state. */ public inline fun Workflow.Companion.stateless( - crossinline render: BaseRenderContext.(props: PropsT) -> RenderingT + crossinline render: StatelessWorkflow< + PropsT, + OutputT, + RenderingT + >.RenderContext.(props: PropsT) -> RenderingT ): Workflow = object : StatelessWorkflow() { override fun render( @@ -163,9 +358,9 @@ public fun Workflow.Companion.rendering( */ public fun StatelessWorkflow.action( - name: String, - update: WorkflowAction.Updater.() -> Unit - ): WorkflowAction = action({ name }, update) + name: String, + update: Updater.() -> Unit +): WorkflowAction = action({ name }, update) /** * Convenience to create a [WorkflowAction] with parameter types matching those @@ -177,11 +372,12 @@ public fun * [WorkflowAction.toString]. * @param update Function that defines the workflow update. */ +@Suppress("UnusedReceiverParameter") public fun StatelessWorkflow.action( - name: () -> String, - update: WorkflowAction.Updater.() -> Unit - ): WorkflowAction = object : WorkflowAction() { + name: () -> String, + update: Updater.() -> Unit +): WorkflowAction = object : WorkflowAction() { override val debuggingName: String get() = name() diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowAction.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowAction.kt index 6ecf1b38b3..0812e9e9de 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowAction.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/WorkflowAction.kt @@ -8,6 +8,11 @@ import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName import kotlin.jvm.JvmOverloads +/** + * Convenience alias for working with [WorkflowAction.Updater]. + */ +public typealias Updater = WorkflowAction.Updater + /** * An atomic operation that updates the state of a [Workflow], and also optionally emits an output. * @@ -184,7 +189,7 @@ public object ActionsExhausted : ActionProcessingResult * @param stateChanged: whether or not the action changed the state. * * Note this is NOT a data class to avoid binary compatibility issues with future updates. - * @see [here](https://jakewharton.com/public-api-challenges-in-kotlin/) for more on this. + * [See here](https://jakewharton.com/public-api-challenges-in-kotlin/) for more on this. * * Also note that since we have decided to allow destructuring and implemented componentN() * functions, we should only ever add new properties to the end of this constructor. diff --git a/workflow-runtime/api/workflow-runtime.api b/workflow-runtime/api/workflow-runtime.api index cc40aaf0dc..b139e08d9b 100644 --- a/workflow-runtime/api/workflow-runtime.api +++ b/workflow-runtime/api/workflow-runtime.api @@ -22,21 +22,6 @@ public final class com/squareup/workflow1/RenderingAndSnapshot { public final fun getSnapshot ()Lcom/squareup/workflow1/TreeSnapshot; } -public final class com/squareup/workflow1/RuntimeConfigOptions : java/lang/Enum { - public static final field CONFLATE_STALE_RENDERINGS Lcom/squareup/workflow1/RuntimeConfigOptions; - public static final field Companion Lcom/squareup/workflow1/RuntimeConfigOptions$Companion; - public static final field PARTIAL_TREE_RENDERING Lcom/squareup/workflow1/RuntimeConfigOptions; - public static final field RENDER_ONLY_WHEN_STATE_CHANGES Lcom/squareup/workflow1/RuntimeConfigOptions; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lcom/squareup/workflow1/RuntimeConfigOptions; - public static fun values ()[Lcom/squareup/workflow1/RuntimeConfigOptions; -} - -public final class com/squareup/workflow1/RuntimeConfigOptions$Companion { - public final fun getDEFAULT_CONFIG ()Ljava/util/Set; - public final fun getRENDER_PER_ACTION ()Ljava/util/Set; -} - public class com/squareup/workflow1/SimpleLoggingWorkflowInterceptor : com/squareup/workflow1/WorkflowInterceptor { public fun ()V protected fun log (Ljava/lang/String;)V @@ -64,9 +49,6 @@ public final class com/squareup/workflow1/TreeSnapshot$Companion { public final fun parse (Lokio/ByteString;)Lcom/squareup/workflow1/TreeSnapshot; } -public abstract interface annotation class com/squareup/workflow1/WorkflowExperimentalRuntime : java/lang/annotation/Annotation { -} - public abstract interface class com/squareup/workflow1/WorkflowInterceptor { public abstract fun onInitialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;Lkotlinx/coroutines/CoroutineScope;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public abstract fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; @@ -89,12 +71,14 @@ public final class com/squareup/workflow1/WorkflowInterceptor$DefaultImpls { public abstract interface class com/squareup/workflow1/WorkflowInterceptor$RenderContextInterceptor { public abstract fun onActionSent (Lcom/squareup/workflow1/WorkflowAction;Lkotlin/jvm/functions/Function1;)V + public abstract fun onRemember (Ljava/lang/String;Lkotlin/reflect/KType;[Ljava/lang/Object;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function4;)Ljava/lang/Object; public abstract fun onRenderChild (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;)Ljava/lang/Object; public abstract fun onRunningSideEffect (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V } public final class com/squareup/workflow1/WorkflowInterceptor$RenderContextInterceptor$DefaultImpls { public static fun onActionSent (Lcom/squareup/workflow1/WorkflowInterceptor$RenderContextInterceptor;Lcom/squareup/workflow1/WorkflowAction;Lkotlin/jvm/functions/Function1;)V + public static fun onRemember (Lcom/squareup/workflow1/WorkflowInterceptor$RenderContextInterceptor;Ljava/lang/String;Lkotlin/reflect/KType;[Ljava/lang/Object;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function4;)Ljava/lang/Object; public static fun onRenderChild (Lcom/squareup/workflow1/WorkflowInterceptor$RenderContextInterceptor;Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;)Ljava/lang/Object; public static fun onRunningSideEffect (Lcom/squareup/workflow1/WorkflowInterceptor$RenderContextInterceptor;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V } diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt index 637615650c..b28f560be9 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlin.coroutines.CoroutineContext import kotlin.coroutines.coroutineContext +import kotlin.reflect.KType /** * Provides hooks into the workflow runtime that can be used to instrument or modify the behavior @@ -259,6 +260,19 @@ public interface WorkflowInterceptor { handler: (CO) -> WorkflowAction ) -> CR ): CR = proceed(child, childProps, key, handler) + + public fun onRemember( + key: String, + resultType: KType, + inputs: Array, + calculation: () -> CResult, + proceed: ( + key: String, + resultType: KType, + inputs: Array, + calculation: () -> CResult + ) -> CResult + ): CResult = proceed(key, resultType, inputs, calculation) } } @@ -349,6 +363,7 @@ private class InterceptedRenderContext( ) : BaseRenderContext, Sink> { override val actionSink: Sink> get() = this override val workflowTracer: WorkflowTracer? = baseRenderContext.workflowTracer + override val runtimeConfig: RuntimeConfig = baseRenderContext.runtimeConfig override fun send(value: WorkflowAction) { interceptor.onActionSent(value) { interceptedAction -> @@ -384,6 +399,17 @@ private class InterceptedRenderContext( } } + override fun remember( + key: String, + resultType: KType, + vararg inputs: Any?, + calculation: () -> ResultT + ): ResultT { + return interceptor.onRemember(key, resultType, inputs, calculation) { k, r, i, c -> + baseRenderContext.remember(k, r, inputs = i, c) + } + } + /** * In a block with a CoroutineScope receiver, calls to `coroutineContext` bind * to `CoroutineScope.coroutineContext` instead of `suspend val coroutineContext`. diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt index 7580098f99..90bd820f3d 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt @@ -11,6 +11,7 @@ import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import kotlinx.coroutines.CoroutineScope +import kotlin.reflect.KType internal fun List.chained(): WorkflowInterceptor = when { @@ -38,8 +39,7 @@ internal class ChainedWorkflowInterceptor( session: WorkflowSession ): S { val chainedProceed = interceptors.foldRight(proceed) { workflowInterceptor, proceedAcc -> - { - props, snapshot, workflowScope -> + { props, snapshot, workflowScope -> workflowInterceptor.onInitialState(props, snapshot, workflowScope, proceedAcc, session) } } @@ -54,8 +54,7 @@ internal class ChainedWorkflowInterceptor( session: WorkflowSession ): S { val chainedProceed = interceptors.foldRight(proceed) { workflowInterceptor, proceedAcc -> - { - old, new, state -> + { old, new, state -> workflowInterceptor.onPropsChanged(old, new, state, proceedAcc, session) } } @@ -68,8 +67,7 @@ internal class ChainedWorkflowInterceptor( session: WorkflowSession ): RenderingAndSnapshot { val chainedProceed = interceptors.foldRight(proceed) { workflowInterceptor, proceedAcc -> - { - renderProps -> + { renderProps -> workflowInterceptor.onRenderAndSnapshot(renderProps, proceedAcc, session) } } @@ -84,8 +82,7 @@ internal class ChainedWorkflowInterceptor( session: WorkflowSession ): R { val chainedProceed = interceptors.foldRight(proceed) { workflowInterceptor, proceedAcc -> - { - props, state, outerContextInterceptor -> + { props, state, outerContextInterceptor -> workflowInterceptor.onRender( props, state, @@ -119,8 +116,7 @@ internal class ChainedWorkflowInterceptor( session: WorkflowSession ): Snapshot? { val chainedProceed = interceptors.foldRight(proceed) { workflowInterceptor, proceedAcc -> - { - state -> + { state -> workflowInterceptor.onSnapshotState(state, proceedAcc, session) } } @@ -171,6 +167,23 @@ internal class ChainedWorkflowInterceptor( inner.onRunningSideEffect(iKey, iSideEffect, proceed) } } + + override fun onRemember( + key: String, + resultType: KType, + inputs: Array, + calculation: () -> CResult, + proceed: (String, KType, Array, () -> CResult) -> CResult + ): CResult { + return outer.onRemember( + key, + resultType, + inputs, + calculation + ) { iKey, iResultType, iInputs, iCalculation -> + inner.onRemember(iKey, iResultType, iInputs, iCalculation, proceed) + } + } } } } diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt index 9129bb638b..30a66cee12 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt @@ -1,18 +1,23 @@ package com.squareup.workflow1.internal import com.squareup.workflow1.BaseRenderContext +import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.Sink import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowAction import com.squareup.workflow1.WorkflowTracer +import com.squareup.workflow1.identifier import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.SendChannel +import kotlin.reflect.KType internal class RealRenderContext( private val renderer: Renderer, private val sideEffectRunner: SideEffectRunner, + private val rememberStore: RememberStore, private val eventActionsChannel: SendChannel>, - override val workflowTracer: WorkflowTracer? + override val workflowTracer: WorkflowTracer?, + override val runtimeConfig: RuntimeConfig ) : BaseRenderContext, Sink> { interface Renderer { @@ -31,6 +36,15 @@ internal class RealRenderContext( ) } + interface RememberStore { + fun remember( + key: String, + resultType: KType, + vararg inputs: Any?, + calculation: () -> ResultT + ): ResultT + } + /** * False during the current render call, set to true once this node is finished rendering. * @@ -58,7 +72,7 @@ internal class RealRenderContext( key: String, handler: (ChildOutputT) -> WorkflowAction ): ChildRenderingT { - checkNotFrozen() + checkNotFrozen { "renderChild(${child.identifier})" } return renderer.render(child, props, key, handler) } @@ -66,15 +80,25 @@ internal class RealRenderContext( key: String, sideEffect: suspend CoroutineScope.() -> Unit ) { - checkNotFrozen() + checkNotFrozen { "runningSideEffect($key)" } sideEffectRunner.runningSideEffect(key, sideEffect) } + override fun remember( + key: String, + resultType: KType, + vararg inputs: Any?, + calculation: () -> ResultT + ): ResultT { + checkNotFrozen { "remember($key)" } + return rememberStore.remember(key, resultType, inputs = inputs, calculation) + } + /** * Freezes this context so that any further calls to this context will throw. */ fun freeze() { - checkNotFrozen() + checkNotFrozen { "freeze" } frozen = true } @@ -85,7 +109,8 @@ internal class RealRenderContext( frozen = false } - private fun checkNotFrozen() = check(!frozen) { - "RenderContext cannot be used after render method returns." + private fun checkNotFrozen(reason: () -> String = { "" }) = check(!frozen) { + "RenderContext cannot be used after render method returns" + + "${reason().takeUnless { it.isBlank() }?.let { " ($it)" }}" } } diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RememberedNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RememberedNode.kt new file mode 100644 index 0000000000..65864c1993 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RememberedNode.kt @@ -0,0 +1,14 @@ +package com.squareup.workflow1.internal + +import com.squareup.workflow1.internal.InlineLinkedList.InlineListNode +import kotlin.reflect.KType + +internal class RememberedNode( + val key: String, + val resultType: KType, + val inputs: Array, + val lastCalculated: ResultT +) : InlineListNode> { + + override var nextListNode: RememberedNode<*>? = null +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt index ea4b25299a..d3a013bafd 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt @@ -20,6 +20,7 @@ import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import com.squareup.workflow1.WorkflowTracer import com.squareup.workflow1.applyTo import com.squareup.workflow1.intercept +import com.squareup.workflow1.internal.RealRenderContext.RememberStore import com.squareup.workflow1.internal.RealRenderContext.SideEffectRunner import com.squareup.workflow1.trace import kotlinx.coroutines.CancellationException @@ -36,6 +37,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.selects.SelectBuilder import kotlin.coroutines.CoroutineContext +import kotlin.reflect.KType /** * A node in a state machine tree. Manages the actual state for a given [Workflow]. @@ -62,7 +64,7 @@ internal class WorkflowNode( override val parent: WorkflowSession? = null, private val interceptor: WorkflowInterceptor = NoopWorkflowInterceptor, idCounter: IdCounter? = null -) : CoroutineScope, SideEffectRunner, WorkflowSession { +) : CoroutineScope, SideEffectRunner, RememberStore, WorkflowSession { /** * Context that has a job that will live as long as this node. @@ -88,6 +90,7 @@ internal class WorkflowNode( idCounter = idCounter ) private val sideEffects = ActiveStagingList() + private val remembered = ActiveStagingList>() private var lastProps: PropsT = initialProps private var lastRendering: NullableInitBox = NullableInitBox() private val eventActionsChannel = @@ -98,8 +101,10 @@ internal class WorkflowNode( private val baseRenderContext = RealRenderContext( renderer = subtreeManager, sideEffectRunner = this, + rememberStore = this, eventActionsChannel = eventActionsChannel, workflowTracer = workflowTracer, + runtimeConfig = runtimeConfig ) private val context = RenderContext(baseRenderContext, workflow) @@ -167,6 +172,29 @@ internal class WorkflowNode( ) } + override fun remember( + key: String, + resultType: KType, + vararg inputs: Any?, + calculation: () -> ResultT + ): ResultT { + remembered.forEachStaging { + require(key != it.key || resultType != it.resultType || !inputs.contentEquals(it.inputs)) { + "Expected combination of key, inputs and result type to be unique: \"$key\"" + } + } + + val result = remembered.retainOrCreate( + predicate = { + key == it.key && it.resultType == resultType && inputs.contentEquals(it.inputs) + }, + create = { RememberedNode(key, resultType, inputs, calculation()) } + ) + + @Suppress("UNCHECKED_CAST") + return result.lastCalculated as ResultT + } + /** * Gets the next [result][ActionProcessingResult] from the state machine. This will be an * [OutputT] or null. @@ -252,6 +280,7 @@ internal class WorkflowNode( // be started after context is frozen. sideEffects.forEachStaging { it.job.start() } sideEffects.commitStaging { it.job.cancel() } + remembered.commitStaging { /* Nothing to clean up. */ } } // After we have rendered this subtree, we need another action in order for us to be // considered dirty again. diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptorTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptorTest.kt index 2af982fd30..92b2cc332a 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptorTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptorTest.kt @@ -4,6 +4,7 @@ import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancel import kotlin.coroutines.EmptyCoroutineContext +import kotlin.reflect.KType import kotlin.reflect.typeOf import kotlin.test.Test import kotlin.test.assertEquals @@ -92,6 +93,7 @@ internal class SimpleLoggingWorkflowInterceptorTest { } private object FakeRenderContext : BaseRenderContext { + override val runtimeConfig: RuntimeConfig = emptySet() override val actionSink: Sink> get() = fail() override val workflowTracer: WorkflowTracer? = null @@ -111,5 +113,14 @@ internal class SimpleLoggingWorkflowInterceptorTest { ) { fail() } + + override fun remember( + key: String, + resultType: KType, + vararg inputs: Any?, + calculation: () -> ResultT + ): ResultT { + fail() + } } } diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowInterceptorTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowInterceptorTest.kt index 5a233c00a8..2856978003 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowInterceptorTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowInterceptorTest.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.test.runTest import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext.Key import kotlin.coroutines.EmptyCoroutineContext +import kotlin.reflect.KType import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -60,21 +61,7 @@ internal class WorkflowInterceptorTest { @Test fun intercept_intercepts_calls_to_render() { val recorder = RecordingWorkflowInterceptor() val intercepted = recorder.intercept(TestWorkflow, TestWorkflow.session) - val fakeContext = object : BaseRenderContext { - override val actionSink: Sink> get() = fail() - override val workflowTracer: WorkflowTracer? = null - - override fun renderChild( - child: Workflow, - props: ChildPropsT, - key: String, - handler: (ChildOutputT) -> WorkflowAction - ): ChildRenderingT = fail() - - override fun runningSideEffect( - key: String, - sideEffect: suspend CoroutineScope.() -> Unit - ) = fail() + val fakeContext = object : StubbyContext() { } val rendering = intercepted.render("props", "state", RenderContext(fakeContext, TestWorkflow)) @@ -101,22 +88,9 @@ internal class WorkflowInterceptorTest { val intercepted = recorder.intercept(TestActionWorkflow, TestActionWorkflow.session) val actions = mutableListOf>() - val fakeContext = object : BaseRenderContext { + val fakeContext = object : StubbyContext() { override val actionSink: Sink> = Sink { value -> actions += value } - override val workflowTracer: WorkflowTracer? = null - - override fun renderChild( - child: Workflow, - props: ChildPropsT, - key: String, - handler: (ChildOutputT) -> WorkflowAction - ): ChildRenderingT = fail() - - override fun runningSideEffect( - key: String, - sideEffect: suspend CoroutineScope.() -> Unit - ) = fail() } val rendering = @@ -136,17 +110,7 @@ internal class WorkflowInterceptorTest { val recorder = RecordingWorkflowInterceptor() val workflow = TestSideEffectWorkflow() val intercepted = recorder.intercept(workflow, workflow.session) - val fakeContext = object : BaseRenderContext { - override val actionSink: Sink> get() = fail() - override val workflowTracer: WorkflowTracer? = null - - override fun renderChild( - child: Workflow, - props: ChildPropsT, - key: String, - handler: (ChildOutputT) -> WorkflowAction - ): ChildRenderingT = fail() - + val fakeContext = object : StubbyContext() { override fun runningSideEffect( key: String, sideEffect: suspend CoroutineScope.() -> Unit @@ -198,17 +162,7 @@ internal class WorkflowInterceptorTest { val workflow = TestSideEffectWorkflow(expectContextElementInSideEffect = true) val intercepted = recorder.intercept(workflow, workflow.session) - val fakeContext = object : BaseRenderContext { - override val actionSink: Sink> get() = fail() - override val workflowTracer: WorkflowTracer? = null - - override fun renderChild( - child: Workflow, - props: ChildPropsT, - key: String, - handler: (ChildOutputT) -> WorkflowAction - ): ChildRenderingT = fail() - + val fakeContext = object : StubbyContext() { override fun runningSideEffect( key: String, sideEffect: suspend CoroutineScope.() -> Unit @@ -264,7 +218,13 @@ internal class WorkflowInterceptorTest { renderState: String, context: RenderContext ): TestRendering { - return TestRendering(context.eventHandler("") { state = "$state: fired" }) + return TestRendering( + onEvent = { + context.actionSink.send( + action("") { state = "$state: fired" } + ) + } + ) } override fun snapshotState(state: String): Snapshot? = null @@ -295,4 +255,29 @@ internal class WorkflowInterceptorTest { override fun snapshotState(state: String): Snapshot? = null } + + private abstract class StubbyContext : BaseRenderContext { + override val runtimeConfig: RuntimeConfig = emptySet() + override val actionSink: Sink> get() = fail() + override val workflowTracer: WorkflowTracer? = null + + override fun renderChild( + child: Workflow, + props: ChildPropsT, + key: String, + handler: (ChildOutputT) -> WorkflowAction + ): ChildRenderingT = fail() + + override fun runningSideEffect( + key: String, + sideEffect: suspend CoroutineScope.() -> Unit + ): Unit = fail() + + override fun remember( + key: String, + resultType: KType, + vararg inputs: Any?, + calculation: () -> ResultT + ): ResultT = fail() + } } diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptorTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptorTest.kt index fb6d3fe5de..aa6e000366 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptorTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptorTest.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlin.coroutines.EmptyCoroutineContext +import kotlin.reflect.KType import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertSame @@ -36,7 +37,6 @@ import kotlin.test.fail * parameters and return value to ensure that all values are being threaded through appropriately. */ internal class ChainedWorkflowInterceptorTest { - @Test fun chained_returns_Noop_when_list_is_empty() { val list = emptyList() val chained = list.chained() @@ -321,6 +321,7 @@ internal class ChainedWorkflowInterceptorTest { private fun Snapshot?.readUtf8() = this?.bytes?.parse { it.readUtf8() } private object FakeRenderContext : BaseRenderContext { + override val runtimeConfig: RuntimeConfig = emptySet() override val actionSink: Sink> get() = fail() override val workflowTracer: WorkflowTracer? = null @@ -340,6 +341,13 @@ internal class ChainedWorkflowInterceptorTest { ) { fail() } + + override fun remember( + key: String, + resultType: KType, + vararg inputs: Any?, + calculation: () -> ResultT + ): ResultT = fail() } object TestSession : WorkflowSession { diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ParameterizedTestRunner.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ParameterizedTestRunner.kt index 4f6aa1a15f..794107b358 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ParameterizedTestRunner.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ParameterizedTestRunner.kt @@ -12,7 +12,7 @@ import kotlin.test.assertTrue * Simple parameterized test as we are in KMP commonTest code and don't have junit * libraries like jupiter. * - * We do our best to tell you what the parameter was when the failure occured by wrapping + * We do our best to tell you what the parameter was when the failure occurred by wrapping * assertions from kotlin.test and injecting our own message. */ class ParameterizedTestRunner

{ diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/RealRenderContextTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/RealRenderContextTest.kt index 35cdc97d4c..4350ab9f28 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/RealRenderContextTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/RealRenderContextTest.kt @@ -8,6 +8,7 @@ import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowAction import com.squareup.workflow1.action import com.squareup.workflow1.applyTo +import com.squareup.workflow1.internal.RealRenderContext.RememberStore import com.squareup.workflow1.internal.RealRenderContext.Renderer import com.squareup.workflow1.internal.RealRenderContext.SideEffectRunner import com.squareup.workflow1.internal.RealRenderContextTest.TestRenderer.Rendering @@ -17,6 +18,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlin.reflect.KType +import kotlin.reflect.typeOf import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -26,11 +29,6 @@ import kotlin.test.assertSame import kotlin.test.assertTrue import kotlin.test.fail -/** - * Cheap hack to avoid lines that are too long and cause auto-format v. lint pain. - */ -private typealias S = String - internal class RealRenderContextTest { private class TestRenderer : Renderer { @@ -65,6 +63,17 @@ internal class RealRenderContextTest { } } + private class TestRememberStore : RememberStore { + override fun remember( + key: String, + resultType: KType, + vararg inputs: Any?, + calculation: () -> ResultT + ): ResultT { + return calculation() + } + } + private class TestWorkflow : StatefulWorkflow() { override fun initialState( props: String, @@ -156,192 +165,6 @@ internal class RealRenderContextTest { ) } - @Test fun eventHandler0_gets_event() { - val context = createdPoisonedContext() - val sink: () -> Unit = context.eventHandler("") { setOutput("yay") } - // Enable sink sends. - context.freeze() - - sink() - - val update = eventActionsChannel.tryReceive().getOrNull()!! - val (state, result) = update.applyTo("props", "state") - assertEquals("state", state) - assertEquals("yay", result.output!!.value) - assertFalse(result.stateChanged) - } - - @Test fun eventHandler1_gets_event() { - val context = createdPoisonedContext() - val sink = context.eventHandler("") { it: String -> setOutput(it) } - // Enable sink sends. - context.freeze() - - sink("foo") - - val update = eventActionsChannel.tryReceive().getOrNull()!! - val (state, result) = update.applyTo("props", "state") - assertEquals("state", state) - assertEquals("foo", result.output!!.value) - assertFalse(result.stateChanged) - } - - @Test fun eventHandler2_gets_event() { - val context = createdPoisonedContext() - val sink = context.eventHandler("") { a: String, b: String -> setOutput(a + b) } - // Enable sink sends. - context.freeze() - - sink("foo", "bar") - - val update = eventActionsChannel.tryReceive().getOrNull()!! - val (state, result) = update.applyTo("props", "state") - assertEquals("state", state) - assertEquals("foobar", result.output!!.value) - assertFalse(result.stateChanged) - } - - @Test fun eventHandler3_gets_event() { - val context = createdPoisonedContext() - val sink = context.eventHandler("") { a: String, b: String, c: String, d: String -> - setOutput(a + b + c + d) - } - // Enable sink sends. - context.freeze() - - sink("foo", "bar", "baz", "bang") - - val update = eventActionsChannel.tryReceive().getOrNull()!! - val (state, result) = update.applyTo("props", "state") - assertEquals("state", state) - assertEquals("foobarbazbang", result.output!!.value) - assertFalse(result.stateChanged) - } - - @Test fun eventHandler4_gets_event() { - val context = createdPoisonedContext() - val sink = context.eventHandler("") { a: String, b: String, c: String, d: String -> - setOutput(a + b + c + d) - } - // Enable sink sends. - context.freeze() - - sink("foo", "bar", "baz", "bang") - - val update = eventActionsChannel.tryReceive().getOrNull()!! - val (state, result) = update.applyTo("props", "state") - assertEquals("state", state) - assertEquals("foobarbazbang", result.output!!.value) - assertFalse(result.stateChanged) - } - - @Test fun eventHandler5_gets_event() { - val context = createdPoisonedContext() - val sink = context.eventHandler("") { a: String, b: String, c: String, d: String, e: String -> - setOutput(a + b + c + d + e) - } - // Enable sink sends. - context.freeze() - - sink("foo", "bar", "baz", "bang", "buzz") - - val update = eventActionsChannel.tryReceive().getOrNull()!! - val (state, result) = update.applyTo("props", "state") - assertEquals("state", state) - assertEquals("foobarbazbangbuzz", result.output!!.value) - assertFalse(result.stateChanged) - } - - @Test fun eventHandler6_gets_event() { - val context = createdPoisonedContext() - val sink = - context.eventHandler("") { a: String, b: String, c: String, d: String, e: String, f: String -> - setOutput(a + b + c + d + e + f) - } - // Enable sink sends. - context.freeze() - - sink("foo", "bar", "baz", "bang", "buzz", "qux") - - val update = eventActionsChannel.tryReceive().getOrNull()!! - val (state, result) = update.applyTo("props", "state") - assertEquals("state", state) - assertEquals("foobarbazbangbuzzqux", result.output!!.value) - assertFalse(result.stateChanged) - } - - @Test fun eventHandler7_gets_event() { - val context = createdPoisonedContext() - val sink = - context.eventHandler("") { a: S, b: S, c: S, d: S, e: S, f: S, g: S -> - setOutput(a + b + c + d + e + f + g) - } - // Enable sink sends. - context.freeze() - - sink("foo", "bar", "baz", "bang", "buzz", "qux", "corge") - - val update = eventActionsChannel.tryReceive().getOrNull()!! - val (state, result) = update.applyTo("props", "state") - assertEquals("state", state) - assertEquals("foobarbazbangbuzzquxcorge", result.output!!.value) - assertFalse(result.stateChanged) - } - - @Test fun eventHandler8_gets_event() { - val context = createdPoisonedContext() - val sink = - context.eventHandler("") { a: S, b: S, c: S, d: S, e: S, f: S, g: S, h: S -> - setOutput(a + b + c + d + e + f + g + h) - } - // Enable sink sends. - context.freeze() - - sink("foo", "bar", "baz", "bang", "buzz", "qux", "corge", "fred") - - val update = eventActionsChannel.tryReceive().getOrNull()!! - val (state, result) = update.applyTo("props", "state") - assertEquals("state", state) - assertEquals("foobarbazbangbuzzquxcorgefred", result.output!!.value) - assertFalse(result.stateChanged) - } - - @Test fun eventHandler9_gets_event() { - val context = createdPoisonedContext() - val sink = - context.eventHandler("") { a: S, b: S, c: S, d: S, e: S, f: S, g: S, h: S, i: S -> - setOutput(a + b + c + d + e + f + g + h + i) - } - // Enable sink sends. - context.freeze() - - sink("foo", "bar", "baz", "bang", "buzz", "qux", "corge", "fred", "xyzzy") - - val update = eventActionsChannel.tryReceive().getOrNull()!! - val (state, result) = update.applyTo("props", "state") - assertEquals("state", state) - assertEquals("foobarbazbangbuzzquxcorgefredxyzzy", result.output!!.value) - assertFalse(result.stateChanged) - } - - @Test fun eventHandler10_gets_event() { - val context = createdPoisonedContext() - val sink = - context.eventHandler("") { a: S, b: S, c: S, d: S, e: S, f: S, g: S, h: S, i: S, j: S -> - setOutput(a + b + c + d + e + f + g + h + i + j) - } - // Enable sink sends. - context.freeze() - - sink("foo", "bar", "baz", "bang", "buzz", "qux", "corge", "fred", "xyzzy", "plugh") - - val update = eventActionsChannel.tryReceive().getOrNull()!! - val (state, result) = update.applyTo("props", "state") - assertEquals("state", state) - assertEquals("foobarbazbangbuzzquxcorgefredxyzzyplugh", result.output!!.value) - assertFalse(result.stateChanged) - } - @Test fun renderChild_works() { val context = createTestContext() val workflow = TestWorkflow() @@ -361,6 +184,15 @@ internal class RealRenderContextTest { assertFalse(result.stateChanged) } + @Test fun remember_passes_through_to_remember_store() { + val context = createTestContext() + + assertEquals( + "value", + context.remember("key", typeOf()) { "value" } + ) + } + @Test fun renderChild_handler_tracks_state_change() { val context = createTestContext() val workflow = TestWorkflow() @@ -389,25 +221,32 @@ internal class RealRenderContextTest { val child = Workflow.stateless { fail() } assertFailsWith { context.renderChild(child) } assertFailsWith { context.freeze() } + assertFailsWith { context.remember("key", typeOf()) {} } } private fun createdPoisonedContext(): RealRenderContext { - val workerRunner = PoisonRunner() + val sideEffectRunner = PoisonRunner() + val rememberStore = TestRememberStore() return RealRenderContext( PoisonRenderer(), - workerRunner, + sideEffectRunner, + rememberStore, eventActionsChannel, - workflowTracer = null + workflowTracer = null, + runtimeConfig = emptySet(), ) } private fun createTestContext(): RealRenderContext { - val workerRunner = TestRunner() + val sideEffectRunner = TestRunner() + val rememberStore = TestRememberStore() return RealRenderContext( TestRenderer(), - workerRunner, + sideEffectRunner, + rememberStore, eventActionsChannel, - workflowTracer = null + workflowTracer = null, + runtimeConfig = emptySet(), ) } } diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/SubtreeManagerTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/SubtreeManagerTest.kt index 3653fb2d3f..3a98c77f5e 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/SubtreeManagerTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/SubtreeManagerTest.kt @@ -57,7 +57,9 @@ internal class SubtreeManagerTest { return Rendering( renderProps, renderState, - eventHandler = context.eventHandler("") { out -> setOutput("workflow output:$out") } + eventHandler = context.eventHandler("") { out -> + setOutput("workflow output:$out") + } ) } diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt index 82f4bdc009..9b5e484768 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt @@ -42,6 +42,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout import kotlin.coroutines.CoroutineContext +import kotlin.reflect.typeOf import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -1127,8 +1128,8 @@ internal class WorkflowNodeTest { @Test fun eventSink_send_fails_before_render_pass_completed() { val workflow = Workflow.stateless { - val sink = eventHandler("eventHandler") { _: String -> fail("Expected handler to fail.") } - sink("Foo") + val sink = eventHandler("eventHandler") { fail("Expected handler to fail.") } + sink() } val node = WorkflowNode( workflow.id(), @@ -1141,11 +1142,11 @@ internal class WorkflowNodeTest { val error = assertFailsWith { node.render(workflow.asStatefulWorkflow(), Unit) } + val expectedPrefix = "Expected sink to not be sent to until after the render pass. " + + "Received action: eH: eventHandler" assertTrue( - error.message!!.startsWith( - "Expected sink to not be sent to until after the render pass. " + - "Received action: eH:eventHandler" - ) + error.message!!.startsWith(expectedPrefix), + "Expected prefix: \"$expectedPrefix\", actually: \"${error.message}\"" ) } @@ -1335,6 +1336,82 @@ internal class WorkflowNodeTest { } } + @Test fun remember_remembers_per_key() { + val workflow = Workflow.stateless { props -> + val (key, value) = props.split("-") + remember(key, typeOf()) { value } + } + val stateful = workflow.asStatefulWorkflow() + + val node = WorkflowNode( + workflow.id(), + stateful, + initialProps = "", + snapshot = null, + baseContext = Unconfined, + emitAppliedActionToParent = { it } + ) + val first = node.render(stateful, "key-value") + assertEquals("value", first) + + val second = node.render(stateful, "key-value2") + assertEquals("value", second) + + val third = node.render(stateful, "key2-value3") + assertEquals("value3", third) + } + + @Test fun remember_updates_on_input_change() { + val workflow = Workflow.stateless { props -> + val (key, input, value) = props.split("-") + remember(key, typeOf(), input) { value } + } + val stateful = workflow.asStatefulWorkflow() + val node = WorkflowNode( + workflow.id(), + stateful, + initialProps = "", + snapshot = null, + baseContext = Unconfined, + emitAppliedActionToParent = { it } + ) + + val first = node.render(stateful, "key-input-value") + assertEquals("value", first) + + val second = node.render(stateful, "key-input-value2") + assertEquals("value", second) + + val third = node.render(stateful, "key-input2-value3") + assertEquals("value3", third) + } + + @Test fun remember_updates_on_return_type_change() { + val workflow = Workflow.stateless, Nothing, Any> { props -> + val (key, value) = props + val returnType = if (value is String) typeOf() else typeOf() + remember(key, returnType) { value } + } + val stateful = workflow.asStatefulWorkflow() + val node = WorkflowNode( + workflow.id(), + stateful, + initialProps = "" to ("" as Any), + snapshot = null, + baseContext = Unconfined, + emitAppliedActionToParent = { it } + ) + + val first = node.render(stateful, "key" to "value") + assertEquals("value", first) + + val second = node.render(stateful, "key" to "value2") + assertEquals("value", second) + + val third = node.render(stateful, "key2" to 3) + assertEquals(3, third) + } + private class TestSession(override val sessionId: Long = 0) : WorkflowSession { override val identifier: WorkflowIdentifier = Workflow.rendering(Unit).identifier override val renderKey: String = "" diff --git a/workflow-testing/api/workflow-testing.api b/workflow-testing/api/workflow-testing.api index 9202846791..3a2ac7351c 100644 --- a/workflow-testing/api/workflow-testing.api +++ b/workflow-testing/api/workflow-testing.api @@ -64,9 +64,12 @@ public final class com/squareup/workflow1/testing/RenderTesterKt { public static synthetic fun expectWorkflow$default (Lcom/squareup/workflow1/testing/RenderTester;Lcom/squareup/workflow1/WorkflowIdentifier;Ljava/lang/Object;Lcom/squareup/workflow1/WorkflowOutput;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/squareup/workflow1/testing/RenderTester; public static synthetic fun expectWorkflow$default (Lcom/squareup/workflow1/testing/RenderTester;Lcom/squareup/workflow1/WorkflowIdentifier;Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/squareup/workflow1/testing/RenderTester; public static synthetic fun expectWorkflow$default (Lcom/squareup/workflow1/testing/RenderTester;Lkotlin/reflect/KClass;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowOutput;Ljava/lang/String;ILjava/lang/Object;)Lcom/squareup/workflow1/testing/RenderTester; - public static final fun testRender (Lcom/squareup/workflow1/SessionWorkflow;Ljava/lang/Object;Lkotlinx/coroutines/CoroutineScope;)Lcom/squareup/workflow1/testing/RenderTester; - public static final fun testRender (Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;Ljava/lang/Object;)Lcom/squareup/workflow1/testing/RenderTester; - public static final fun testRender (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;)Lcom/squareup/workflow1/testing/RenderTester; + public static final fun testRender (Lcom/squareup/workflow1/SessionWorkflow;Ljava/lang/Object;Lkotlinx/coroutines/CoroutineScope;Ljava/util/Set;)Lcom/squareup/workflow1/testing/RenderTester; + public static final fun testRender (Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;Ljava/lang/Object;Ljava/util/Set;)Lcom/squareup/workflow1/testing/RenderTester; + public static final fun testRender (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/util/Set;)Lcom/squareup/workflow1/testing/RenderTester; + public static synthetic fun testRender$default (Lcom/squareup/workflow1/SessionWorkflow;Ljava/lang/Object;Lkotlinx/coroutines/CoroutineScope;Ljava/util/Set;ILjava/lang/Object;)Lcom/squareup/workflow1/testing/RenderTester; + public static synthetic fun testRender$default (Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;Ljava/lang/Object;Ljava/util/Set;ILjava/lang/Object;)Lcom/squareup/workflow1/testing/RenderTester; + public static synthetic fun testRender$default (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/util/Set;ILjava/lang/Object;)Lcom/squareup/workflow1/testing/RenderTester; } public final class com/squareup/workflow1/testing/RenderTesterWorkersKt { @@ -104,9 +107,10 @@ public final class com/squareup/workflow1/testing/WorkerTesterKt { public final class com/squareup/workflow1/testing/WorkflowTestParams { public fun ()V - public fun (Lcom/squareup/workflow1/testing/WorkflowTestParams$StartMode;Z)V - public synthetic fun (Lcom/squareup/workflow1/testing/WorkflowTestParams$StartMode;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lcom/squareup/workflow1/testing/WorkflowTestParams$StartMode;ZLjava/util/Set;)V + public synthetic fun (Lcom/squareup/workflow1/testing/WorkflowTestParams$StartMode;ZLjava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getCheckRenderIdempotence ()Z + public final fun getRuntimeConfig ()Ljava/util/Set; public final fun getStartFrom ()Lcom/squareup/workflow1/testing/WorkflowTestParams$StartMode; } diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/RealRenderTester.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/RealRenderTester.kt index 39f8b4a7d8..4761fdba14 100644 --- a/workflow-testing/src/main/java/com/squareup/workflow1/testing/RealRenderTester.kt +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/RealRenderTester.kt @@ -2,6 +2,7 @@ package com.squareup.workflow1.testing import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.RenderContext +import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.Sink import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.Worker @@ -24,6 +25,7 @@ import com.squareup.workflow1.testing.RenderTester.ChildWorkflowMatch.Matched import com.squareup.workflow1.testing.RenderTester.RenderChildInvocation import kotlinx.coroutines.CoroutineScope import kotlin.reflect.KClass +import kotlin.reflect.KType import kotlin.reflect.full.allSupertypes import kotlin.reflect.full.isSuperclassOf import kotlin.reflect.full.isSupertypeOf @@ -34,6 +36,7 @@ internal class RealRenderTester( private val workflow: StatefulWorkflow, private val props: PropsT, private val state: StateT, + override val runtimeConfig: RuntimeConfig, /** * List of [Expectation]s that are expected when the workflow is rendered. New expectations are * registered into this list. Once the render pass has started, expectations are moved from this @@ -107,6 +110,15 @@ internal class RealRenderTester( val (state, actionApplied) = action.applyTo(props, state) state to actionApplied.output } + + private data class TestRememberKey( + val key: String, + val resultType: KType, + val inputs: List, + ) + + private var rememberSet = mutableSetOf() + override val actionSink: Sink> get() = this override val workflowTracer: WorkflowTracer? = null @@ -261,6 +273,19 @@ internal class RealRenderTester( } } + override fun remember( + key: String, + resultType: KType, + vararg inputs: Any?, + calculation: () -> ResultT + ): ResultT { + val mapKey = TestRememberKey(key, resultType, inputs.asList()) + check(rememberSet.add(mapKey)) { + "Expected combination of key, inputs and result type to be unique: \"$key\"" + } + return calculation() + } + override fun requireExplicitWorkerExpectations(): RenderTester = this.apply { explicitWorkerExpectationsRequired = true @@ -310,13 +335,19 @@ internal class RealRenderTester( } else { stateAfterRender } - return RealRenderTester(workflow, newProps, newState) + return RealRenderTester( + workflow = workflow, + props = newProps, + state = newState, + runtimeConfig = runtimeConfig + ) } private fun deepCloneForRender(): BaseRenderContext = RealRenderTester( workflow, props, state, + runtimeConfig = runtimeConfig, // Copy the list of expectations since it's mutable. expectations = ArrayList(expectations), // Don't care about consumed expectations. diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/RenderIdempotencyChecker.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/RenderIdempotencyChecker.kt index 8820b5ca7b..25532d0603 100644 --- a/workflow-testing/src/main/java/com/squareup/workflow1/testing/RenderIdempotencyChecker.kt +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/RenderIdempotencyChecker.kt @@ -7,6 +7,7 @@ import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import java.util.LinkedList +import kotlin.reflect.KType /** * Intercepts the render pass of the root workflow and runs it twice to ensure that well-written @@ -65,7 +66,7 @@ private class RecordingContextInterceptor : } // Else noop } - private val childRenderings = LinkedList() + private val captureStack = LinkedList() override fun onRenderChild( child: Workflow, @@ -80,10 +81,10 @@ private class RecordingContextInterceptor : ) -> CR ): CR = if (!replaying) { proceed(child, childProps, key, handler) - .also { childRenderings.addFirst(it) } + .also { captureStack.addFirst(it) } } else { @Suppress("UNCHECKED_CAST") - childRenderings.removeLast() as CR + captureStack.removeLast() as CR } override fun onRunningSideEffect( @@ -96,4 +97,22 @@ private class RecordingContextInterceptor : } // Else noop. } + + override fun onRemember( + key: String, + resultType: KType, + inputs: Array, + calculation: () -> CResult, + proceed: ( + key: String, + resultType: KType, + inputs: Array, + calculation: () -> CResult + ) -> CResult + ): CResult = if (!replaying) { + proceed(key, resultType, inputs, calculation).also { captureStack.addFirst(it) } + } else { + @Suppress("UNCHECKED_CAST") + captureStack.removeLast() as CResult + } } diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/RenderTester.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/RenderTester.kt index 8435b49540..346525dea1 100644 --- a/workflow-testing/src/main/java/com/squareup/workflow1/testing/RenderTester.kt +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/RenderTester.kt @@ -1,6 +1,9 @@ +@file:Suppress("ktlint:standard:indent") + package com.squareup.workflow1.testing import com.squareup.workflow1.ActionApplied +import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.SessionWorkflow import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.Workflow @@ -8,6 +11,7 @@ import com.squareup.workflow1.WorkflowAction import com.squareup.workflow1.WorkflowExperimentalApi import com.squareup.workflow1.WorkflowIdentifier import com.squareup.workflow1.WorkflowOutput +import com.squareup.workflow1.config.JvmTestRuntimeConfigTools import com.squareup.workflow1.identifier import com.squareup.workflow1.testing.RenderTester.ChildWorkflowMatch import com.squareup.workflow1.workflowIdentifier @@ -25,11 +29,13 @@ import kotlin.reflect.KTypeProjection @OptIn(WorkflowExperimentalApi::class) // Opt-in is only for the argument check. @Suppress("UNCHECKED_CAST") public fun Workflow.testRender( - props: PropsT + props: PropsT, + runtimeConfig: RuntimeConfig? = null, ): RenderTester { val statefulWorkflow = asStatefulWorkflow() as StatefulWorkflow return statefulWorkflow.testRender( props = props, + runtimeConfig = runtimeConfig, initialState = run { require(this !is SessionWorkflow) { "Called testRender on a SessionWorkflow without a CoroutineScope. Use the version that passes a CoroutineScope." @@ -49,13 +55,15 @@ public fun Workflow.t @Suppress("UNCHECKED_CAST") public fun SessionWorkflow.testRender( props: PropsT, - workflowScope: CoroutineScope + workflowScope: CoroutineScope, + runtimeConfig: RuntimeConfig? = null, ): RenderTester { val sessionWorkflow: SessionWorkflow = asStatefulWorkflow() as SessionWorkflow return sessionWorkflow.testRender( props = props, - initialState = sessionWorkflow.initialState(props, null, workflowScope) + runtimeConfig = runtimeConfig, + initialState = sessionWorkflow.initialState(props, null, workflowScope), ) as RenderTester } @@ -66,10 +74,16 @@ public fun SessionWorkflow StatefulWorkflow.testRender( - props: PropsT, - initialState: StateT - ): RenderTester = - RealRenderTester(this, props, initialState) + props: PropsT, + initialState: StateT, + runtimeConfig: RuntimeConfig? = null +): RenderTester = + RealRenderTester( + workflow = this, + props = props, + state = initialState, + runtimeConfig = runtimeConfig ?: JvmTestRuntimeConfigTools.getTestRuntimeConfig() + ) /** * The props must be specified, the initial state may be specified, and then all child workflows @@ -379,12 +393,12 @@ public abstract class RenderTester { @Suppress("NOTHING_TO_INLINE") public inline fun RenderTester.expectWorkflow( - identifier: WorkflowIdentifier, - rendering: ChildRenderingT, - key: String = "", - description: String = "", - noinline assertProps: (props: Any?) -> Unit = {} - ): RenderTester = + identifier: WorkflowIdentifier, + rendering: ChildRenderingT, + key: String = "", + description: String = "", + noinline assertProps: (props: Any?) -> Unit = {} +): RenderTester = expectWorkflow(identifier, rendering, null as WorkflowOutput<*>?, key, description, assertProps) /** @@ -438,13 +452,13 @@ public inline fun */ public fun RenderTester.expectWorkflow( - identifier: WorkflowIdentifier, - rendering: ChildRenderingT, - output: WorkflowOutput?, - key: String = "", - description: String = "", - assertProps: (props: Any?) -> Unit = {} - ): RenderTester = expectWorkflow( + identifier: WorkflowIdentifier, + rendering: ChildRenderingT, + output: WorkflowOutput?, + key: String = "", + description: String = "", + assertProps: (props: Any?) -> Unit = {} +): RenderTester = expectWorkflow( exactMatch = true, description = description.ifBlank { "workflow " + @@ -512,13 +526,13 @@ public fun */ public inline fun RenderTester.expectWorkflow( - workflowType: KClass>, - rendering: ChildRenderingT, - key: String = "", - crossinline assertProps: (props: ChildPropsT) -> Unit = {}, - output: WorkflowOutput? = null, - description: String = "" - ): RenderTester = + workflowType: KClass>, + rendering: ChildRenderingT, + key: String = "", + crossinline assertProps: (props: ChildPropsT) -> Unit = {}, + output: WorkflowOutput? = null, + description: String = "" +): RenderTester = expectWorkflow( workflowType.workflowIdentifier, rendering, diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTestParams.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTestParams.kt index e3a6cff5dd..b6a2272413 100644 --- a/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTestParams.kt +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTestParams.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1.testing +import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.Snapshot import com.squareup.workflow1.TreeSnapshot import com.squareup.workflow1.testing.WorkflowTestParams.StartMode @@ -20,11 +21,15 @@ import org.jetbrains.annotations.TestOnly * for any given state, so performing side effects in `render` will almost always result in bugs. * It is recommended to leave this on, but if you need to debug a test and don't want to have to * deal with the extra passes, you can temporarily set it to false. + * @param runtimeConfig Runtime configuration to apply. If `null` we use + * [JvmTestRuntimeConfigTools.getTestRuntimeConfig][com.squareup.workflow1.config.JvmTestRuntimeConfigTools.getTestRuntimeConfig] + * instead. */ @TestOnly public class WorkflowTestParams( public val startFrom: StartMode = StartFresh, - public val checkRenderIdempotence: Boolean = true + public val checkRenderIdempotence: Boolean = true, + public val runtimeConfig: RuntimeConfig? = null ) { /** * Defines how to start the workflow for tests. diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTestRuntime.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTestRuntime.kt index 9629ffba89..3f08135369 100644 --- a/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTestRuntime.kt +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTestRuntime.kt @@ -1,4 +1,5 @@ @file:OptIn(ExperimentalCoroutinesApi::class) +@file:Suppress("ktlint:standard:indent") package com.squareup.workflow1.testing @@ -176,11 +177,11 @@ public class WorkflowTestRuntime @TestOnly internal @TestOnly public fun Workflow.launchForTestingFromStartWith( - props: PropsT, - testParams: WorkflowTestParams = WorkflowTestParams(), - context: CoroutineContext = EmptyCoroutineContext, - block: WorkflowTestRuntime.() -> T - ): T = asStatefulWorkflow().launchForTestingWith(props, testParams, context, block) + props: PropsT, + testParams: WorkflowTestParams = WorkflowTestParams(), + context: CoroutineContext = EmptyCoroutineContext, + block: WorkflowTestRuntime.() -> T +): T = asStatefulWorkflow().launchForTestingWith(props, testParams, context, block) /** * Creates a [WorkflowTestRuntime] to run this workflow for unit testing. @@ -190,10 +191,10 @@ public fun @TestOnly public fun Workflow.launchForTestingFromStartWith( - testParams: WorkflowTestParams = WorkflowTestParams(), - context: CoroutineContext = EmptyCoroutineContext, - block: WorkflowTestRuntime.() -> T - ): T = launchForTestingFromStartWith(Unit, testParams, context, block) + testParams: WorkflowTestParams = WorkflowTestParams(), + context: CoroutineContext = EmptyCoroutineContext, + block: WorkflowTestRuntime.() -> T +): T = launchForTestingFromStartWith(Unit, testParams, context, block) /** * Creates a [WorkflowTestRuntime] to run this workflow for unit testing. @@ -205,11 +206,11 @@ public fun @TestOnly public fun StatefulWorkflow.launchForTestingFromStateWith( - props: PropsT, - initialState: StateT, - context: CoroutineContext = EmptyCoroutineContext, - block: WorkflowTestRuntime.() -> T - ): T = launchForTestingWith( + props: PropsT, + initialState: StateT, + context: CoroutineContext = EmptyCoroutineContext, + block: WorkflowTestRuntime.() -> T +): T = launchForTestingWith( props, WorkflowTestParams(StartFromState(initialState)), context, @@ -226,10 +227,10 @@ public fun @TestOnly public fun StatefulWorkflow.launchForTestingFromStateWith( - initialState: StateT, - context: CoroutineContext = EmptyCoroutineContext, - block: WorkflowTestRuntime.() -> Unit - ): Unit = launchForTestingFromStateWith(Unit, initialState, context, block) + initialState: StateT, + context: CoroutineContext = EmptyCoroutineContext, + block: WorkflowTestRuntime.() -> Unit +): Unit = launchForTestingFromStateWith(Unit, initialState, context, block) /** * Creates a [WorkflowTestRuntime] to run this workflow for unit testing. @@ -239,11 +240,11 @@ public fun @TestOnly public fun StatefulWorkflow.launchForTestingWith( - props: PropsT, - testParams: WorkflowTestParams = WorkflowTestParams(), - context: CoroutineContext = EmptyCoroutineContext, - block: WorkflowTestRuntime.() -> T - ): T { + props: PropsT, + testParams: WorkflowTestParams = WorkflowTestParams(), + context: CoroutineContext = EmptyCoroutineContext, + block: WorkflowTestRuntime.() -> T +): T { val propsFlow = MutableStateFlow(props) // Any exceptions that are thrown from a launch will be reported to the coroutine's uncaught @@ -274,7 +275,7 @@ public fun props = propsFlow, initialSnapshot = snapshot, interceptors = interceptors, - runtimeConfig = JvmTestRuntimeConfigTools.getTestRuntimeConfig() + runtimeConfig = testParams.runtimeConfig ?: JvmTestRuntimeConfigTools.getTestRuntimeConfig() ) { output -> outputs.send(output) } val tester = WorkflowTestRuntime(propsFlow, renderingsAndSnapshots, outputs) tester.collectFromWorkflowIn(workflowScope) diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/StatefulWorkflowEventHandlerTest.kt b/workflow-testing/src/test/java/com/squareup/workflow1/StatefulWorkflowEventHandlerTest.kt new file mode 100644 index 0000000000..d3200b1ed1 --- /dev/null +++ b/workflow-testing/src/test/java/com/squareup/workflow1/StatefulWorkflowEventHandlerTest.kt @@ -0,0 +1,267 @@ +package com.squareup.workflow1 + +import com.squareup.workflow1.RuntimeConfigOptions.STABLE_EVENT_HANDLERS +import com.squareup.workflow1.testing.WorkflowTestParams +import com.squareup.workflow1.testing.launchForTestingFromStartWith +import kotlin.test.Test +import kotlin.test.assertSame + +/** + * A lot of duplication here with [StatelessWorkflowEventHandlerTest] + */ +@OptIn(WorkflowExperimentalApi::class, WorkflowExperimentalRuntime::class) +class StatefulWorkflowEventHandlerTest { + private data class Params( + val remember: Boolean?, + val runtimeConfig: RuntimeConfig + ) { + val remembering = remember ?: runtimeConfig.contains(STABLE_EVENT_HANDLERS) + } + + private val rememberValues = sequenceOf(true, false, null) + private val configValues = sequenceOf(emptySet(), setOf(STABLE_EVENT_HANDLERS)) + private val values = rememberValues.flatMap { remember -> + configValues.map { Params(remember, it) } + } + private val parameterizedTestRunner = ParameterizedTestRunner() + + @Test fun eventHandler0() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateful(Unit) { + eventHandler("", remember = params.remembering) { setOutput("yay") } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke() + assertEquals("yay", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler1() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = params.remembering) { e1 -> + setOutput(e1) + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("yay") + assertEquals("yay", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler2() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = params.remembering) { e1, e2 -> + setOutput("$e1-$e2") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b") + assertEquals("a-b", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler3() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = params.remembering) { e1, e2, e3 -> + setOutput("$e1-$e2-$e3") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c") + assertEquals("a-b-c", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler4() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = params.remembering) { e1, e2, e3, e4 -> + setOutput("$e1-$e2-$e3-$e4") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d") + assertEquals("a-b-c-d", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler5() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5 -> + setOutput("$e1-$e2-$e3-$e4-$e5") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e") + assertEquals("a-b-c-d-e", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler6() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f") + assertEquals("a-b-c-d-e-f", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler7() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6, e7 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g") + assertEquals("a-b-c-d-e-f-g", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler8() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6, e7, e8 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g", "h") + assertEquals("a-b-c-d-e-f-g-h", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler9() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6, e7, e8, e9 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8-$e9") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g", "h", "i") + assertEquals("a-b-c-d-e-f-g-h-i", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler10() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6, e7, e8, e9, e10 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8-$e9-$e10") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g", "h", "i", "k") + assertEquals("a-b-c-d-e-f-g-h-i-k", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } +} diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/StatefulWorkflowSafeEventHandlerTest.kt b/workflow-testing/src/test/java/com/squareup/workflow1/StatefulWorkflowSafeEventHandlerTest.kt new file mode 100644 index 0000000000..6b58b8ecf9 --- /dev/null +++ b/workflow-testing/src/test/java/com/squareup/workflow1/StatefulWorkflowSafeEventHandlerTest.kt @@ -0,0 +1,267 @@ +package com.squareup.workflow1 + +import com.squareup.workflow1.StatefulWorkflowSafeEventHandlerTest.State.Able +import com.squareup.workflow1.StatefulWorkflowSafeEventHandlerTest.State.Baker +import com.squareup.workflow1.testing.launchForTestingFromStartWith +import com.squareup.workflow1.testing.launchForTestingFromStateWith +import kotlin.reflect.KClass +import kotlin.test.Test +import kotlin.test.assertEquals + +class StatefulWorkflowSafeEventHandlerTest { + private sealed interface State { + data object Able : State + data object Baker : State + } + + private var failedCast = "" + private val onFailedCast: (name: S, type: KClass<*>, state: State) -> Unit = + { name, expectedType, state -> + failedCast = "$name expected: ${expectedType.simpleName} got: $state" + } + + @Test fun safeEventHandler0() { + val w = Workflow.stateful Unit>(Able) { + safeEventHandler( + "name", + update = { setOutput("yay") }, + onFailedCast = onFailedCast + ) + } + w.launchForTestingFromStartWith { + val first = awaitNextRendering() + first.invoke() + assertEquals("yay", awaitNextOutput()) + assertNoFailedCast() + } + w.launchForTestingFromStateWith(Baker) { + val first = awaitNextRendering() + first.invoke() + assertFailedCast() + } + } + + @Test fun safeEventHandler1() { + val w = Workflow.stateful Unit>(Able) { + safeEventHandler( + "name", + update = { _, e1 -> setOutput(e1) }, + onFailedCast = onFailedCast + ) + } + w.launchForTestingFromStartWith { + val first = awaitNextRendering() + first.invoke("yay") + assertEquals("yay", awaitNextOutput()) + assertNoFailedCast() + } + w.launchForTestingFromStateWith(Baker) { + val first = awaitNextRendering() + first.invoke("yay") + assertFailedCast() + } + } + + @Test fun safeEventHandler2() { + val w = Workflow.stateful Unit>(Able) { + safeEventHandler( + "name", + update = { _, e1, e2 -> setOutput("$e1-$e2") }, + onFailedCast = onFailedCast + ) + } + w.launchForTestingFromStartWith { + val first = awaitNextRendering() + first.invoke("a", "b") + assertEquals("a-b", awaitNextOutput()) + assertNoFailedCast() + } + w.launchForTestingFromStateWith(Baker) { + val first = awaitNextRendering() + first.invoke("", "") + assertFailedCast() + } + } + + @Test fun safeEventHandler3() { + val w = Workflow.stateful Unit>(Able) { + safeEventHandler( + "name", + update = { _, e1, e2, e3 -> setOutput("$e1-$e2-$e3") }, + onFailedCast = onFailedCast + ) + } + w.launchForTestingFromStartWith { + val first = awaitNextRendering() + first.invoke("a", "b", "c") + assertEquals("a-b-c", awaitNextOutput()) + assertNoFailedCast() + } + w.launchForTestingFromStateWith(Baker) { + val first = awaitNextRendering() + first.invoke("", "", "") + assertFailedCast() + } + } + + @Test fun safeEventHandler4() { + val w = Workflow.stateful Unit>(Able) { + safeEventHandler( + "name", + update = { _, e1, e2, e3, e4 -> setOutput("$e1-$e2-$e3-$e4") }, + onFailedCast = onFailedCast + ) + } + w.launchForTestingFromStartWith { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d") + assertEquals("a-b-c-d", awaitNextOutput()) + assertNoFailedCast() + } + w.launchForTestingFromStateWith(Baker) { + val first = awaitNextRendering() + first.invoke("", "", "", "") + assertFailedCast() + } + } + + @Test fun safeEventHandler5() { + val w = Workflow.stateful Unit>(Able) { + safeEventHandler( + "name", + update = { _, e1, e2, e3, e4, e5 -> setOutput("$e1-$e2-$e3-$e4-$e5") }, + onFailedCast = onFailedCast + ) + } + w.launchForTestingFromStartWith { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e") + assertEquals("a-b-c-d-e", awaitNextOutput()) + assertNoFailedCast() + } + w.launchForTestingFromStateWith(Baker) { + val first = awaitNextRendering() + first.invoke("", "", "", "", "") + assertFailedCast() + } + } + + @Test fun safeEventHandler6() { + val w = Workflow.stateful Unit>(Able) { + safeEventHandler( + "name", + update = { _, e1, e2, e3, e4, e5, e6 -> setOutput("$e1-$e2-$e3-$e4-$e5-$e6") }, + onFailedCast = onFailedCast + ) + } + w.launchForTestingFromStartWith { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f") + assertEquals("a-b-c-d-e-f", awaitNextOutput()) + assertNoFailedCast() + } + w.launchForTestingFromStateWith(Baker) { + val first = awaitNextRendering() + first.invoke("", "", "", "", "", "") + assertFailedCast() + } + } + + @Test fun safeEventHandler7() { + val w = Workflow.stateful Unit>(Able) { + safeEventHandler( + "name", + update = { _, e1, e2, e3, e4, e5, e6, e7 -> setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7") }, + onFailedCast = onFailedCast + ) + } + w.launchForTestingFromStartWith { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g") + assertEquals("a-b-c-d-e-f-g", awaitNextOutput()) + assertNoFailedCast() + } + w.launchForTestingFromStateWith(Baker) { + val first = awaitNextRendering() + first.invoke("", "", "", "", "", "", "") + assertFailedCast() + } + } + + @Test fun safeEventHandler8() { + val w = Workflow.stateful Unit>(Able) { + safeEventHandler( + "name", + update = { _, e1, e2, e3, e4, e5, e6, e7, e8 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8") + }, + onFailedCast = onFailedCast + ) + } + w.launchForTestingFromStartWith { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g", "h") + assertEquals("a-b-c-d-e-f-g-h", awaitNextOutput()) + assertNoFailedCast() + } + w.launchForTestingFromStateWith(Baker) { + val first = awaitNextRendering() + first.invoke("", "", "", "", "", "", "", "") + assertFailedCast() + } + } + + @Test fun safeEventHandler9() { + val w = Workflow.stateful Unit>(Able) { + safeEventHandler( + "name", + update = { _, e1, e2, e3, e4, e5, e6, e7, e8, e9 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8-$e9") + }, + onFailedCast = onFailedCast + ) + } + w.launchForTestingFromStartWith { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g", "h", "i") + assertEquals("a-b-c-d-e-f-g-h-i", awaitNextOutput()) + assertNoFailedCast() + } + w.launchForTestingFromStateWith(Baker) { + val first = awaitNextRendering() + first.invoke("", "", "", "", "", "", "", "", "") + assertFailedCast() + } + } + + @Test fun safeEventHandler10() { + val w = Workflow.stateful Unit>(Able) { + safeEventHandler( + "name", + update = { _, e1, e2, e3, e4, e5, e6, e7, e8, e9, e10 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8-$e9-$e10") + }, + onFailedCast = onFailedCast + ) + } + w.launchForTestingFromStartWith { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g", "h", "i", "j") + assertEquals("a-b-c-d-e-f-g-h-i-j", awaitNextOutput()) + assertNoFailedCast() + } + w.launchForTestingFromStateWith(Baker) { + val first = awaitNextRendering() + first.invoke("", "", "", "", "", "", "", "", "", "") + assertFailedCast() + } + } + + private fun assertNoFailedCast() { + assertEquals("", failedCast) + } + + private fun assertFailedCast() { + assertEquals("name expected: Able got: Baker", failedCast) + } +} diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/StatelessWorkflowEventHandlerTest.kt b/workflow-testing/src/test/java/com/squareup/workflow1/StatelessWorkflowEventHandlerTest.kt new file mode 100644 index 0000000000..6f0ab5db23 --- /dev/null +++ b/workflow-testing/src/test/java/com/squareup/workflow1/StatelessWorkflowEventHandlerTest.kt @@ -0,0 +1,267 @@ +package com.squareup.workflow1 + +import com.squareup.workflow1.RuntimeConfigOptions.STABLE_EVENT_HANDLERS +import com.squareup.workflow1.testing.WorkflowTestParams +import com.squareup.workflow1.testing.launchForTestingFromStartWith +import kotlin.test.Test +import kotlin.test.assertSame + +/** + * A lot of duplication here with [StatefulWorkflowEventHandlerTest] + */ +@OptIn(WorkflowExperimentalApi::class, WorkflowExperimentalRuntime::class) +class StatelessWorkflowEventHandlerTest { + private data class Params( + val remember: Boolean?, + val runtimeConfig: RuntimeConfig + ) { + val remembering = remember ?: runtimeConfig.contains(STABLE_EVENT_HANDLERS) + } + + private val rememberValues = sequenceOf(true, false, null) + private val configValues = sequenceOf(emptySet(), setOf(STABLE_EVENT_HANDLERS)) + private val values = rememberValues.flatMap { remember -> + configValues.map { Params(remember, it) } + } + private val parameterizedTestRunner = ParameterizedTestRunner() + + @Test fun eventHandler0() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateless Unit> { + eventHandler("", remember = params.remembering) { setOutput("yay") } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke() + assertEquals("yay", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler1() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateless Unit> { + eventHandler("", remember = params.remembering) { e1 -> + setOutput(e1) + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("yay") + assertEquals("yay", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler2() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateless Unit> { + eventHandler("", remember = params.remembering) { e1, e2 -> + setOutput("$e1-$e2") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b") + assertEquals("a-b", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler3() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateless Unit> { + eventHandler("", remember = params.remembering) { e1, e2, e3 -> + setOutput("$e1-$e2-$e3") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c") + assertEquals("a-b-c", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler4() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateless Unit> { + eventHandler("", remember = params.remembering) { e1, e2, e3, e4 -> + setOutput("$e1-$e2-$e3-$e4") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d") + assertEquals("a-b-c-d", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler5() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateless Unit> { + eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5 -> + setOutput("$e1-$e2-$e3-$e4-$e5") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e") + assertEquals("a-b-c-d-e", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler6() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateless Unit> { + eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f") + assertEquals("a-b-c-d-e-f", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler7() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateless Unit> { + eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6, e7 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g") + assertEquals("a-b-c-d-e-f-g", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler8() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateless Unit> { + eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6, e7, e8 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g", "h") + assertEquals("a-b-c-d-e-f-g-h", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler9() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateless Unit> { + eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6, e7, e8, e9 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8-$e9") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g", "h", "i") + assertEquals("a-b-c-d-e-f-g-h-i", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } + + @Test fun eventHandler10() { + parameterizedTestRunner.runParametrizedTest(values) { params -> + Workflow.stateless Unit> { + eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6, e7, e8, e9, e10 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8-$e9-$e10") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g", "h", "i", "k") + assertEquals("a-b-c-d-e-f-g-h-i-k", awaitNextOutput()) + val next = awaitNextRendering() + if (params.remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) + } + } + } + } +} diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/StringTypeAlias.kt b/workflow-testing/src/test/java/com/squareup/workflow1/StringTypeAlias.kt new file mode 100644 index 0000000000..6f5c30276e --- /dev/null +++ b/workflow-testing/src/test/java/com/squareup/workflow1/StringTypeAlias.kt @@ -0,0 +1,7 @@ +package com.squareup.workflow1 + +/** + * We go nuts with param types in a lot of these tests. + * This lets us be a bit more concise. + */ +internal typealias S = String