diff --git a/benchmarks/performance-poetry/complex-poetry/build.gradle.kts b/benchmarks/performance-poetry/complex-poetry/build.gradle.kts index 1b141a7fb..5211e244b 100644 --- a/benchmarks/performance-poetry/complex-poetry/build.gradle.kts +++ b/benchmarks/performance-poetry/complex-poetry/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("com.android.application") `kotlin-android` id("kotlin-parcelize") + id("app.cash.molecule") } android { compileSdk = 32 diff --git a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/MaybeLoadingGatekeeperWorkflow.kt b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/MaybeLoadingGatekeeperWorkflow.kt index d6c64532e..c56c29aa7 100644 --- a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/MaybeLoadingGatekeeperWorkflow.kt +++ b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/MaybeLoadingGatekeeperWorkflow.kt @@ -1,5 +1,6 @@ package com.squareup.benchmarks.performance.complex.poetry +import androidx.compose.runtime.Composable import com.squareup.benchmarks.performance.complex.poetry.instrumentation.ActionHandlingTracingInterceptor import com.squareup.benchmarks.performance.complex.poetry.instrumentation.asTraceableWorker import com.squareup.benchmarks.performance.complex.poetry.views.LoaderSpinner @@ -48,5 +49,36 @@ class MaybeLoadingGatekeeperWorkflow( ) } + @Composable + override fun Rendering( + renderProps: Unit, + renderState: IsLoading, + context: RenderContext, + hoistRendering: @Composable (rendering: MayBeLoadingScreen) -> Unit + ) { + context.runningWorker(isLoading.asTraceableWorker("GatekeeperLoading")) { + action { + state = it + } + } + context.ChildRendering( + childWithLoading, childProps, "", + hoistRendering = { + hoistRendering( + MayBeLoadingScreen( + baseScreen = it, + loaders = if (renderState) listOf(LoaderSpinner) else emptyList() + ) + ) + } + ) { + action(ActionHandlingTracingInterceptor.keyForTrace("GatekeeperChildFinished")) { + setOutput( + Unit + ) + } + } + } + override fun snapshotState(state: IsLoading): Snapshot? = null } diff --git a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoemWorkflow.kt b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoemWorkflow.kt index a24d2cf55..ee9f372bf 100644 --- a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoemWorkflow.kt +++ b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoemWorkflow.kt @@ -1,5 +1,9 @@ package com.squareup.benchmarks.performance.complex.poetry +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemWorkflow.Action.ClearSelection import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemWorkflow.Action.HandleStanzaListOutput import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemWorkflow.Action.SelectNext @@ -16,6 +20,7 @@ import com.squareup.benchmarks.performance.complex.poetry.views.BlankScreen import com.squareup.sample.container.overviewdetail.OverviewDetailScreen import com.squareup.sample.poetry.PoemWorkflow import com.squareup.sample.poetry.PoemWorkflow.ClosePoem +import com.squareup.sample.poetry.StanzaListScreen import com.squareup.sample.poetry.StanzaListWorkflow import com.squareup.sample.poetry.StanzaListWorkflow.NO_SELECTED_STANZA import com.squareup.sample.poetry.StanzaScreen @@ -57,6 +62,7 @@ import kotlinx.coroutines.flow.flow * break ties/conflicts with a token in the start/stop requests. We leave that complexity out * here. ** */ +@OptIn(WorkflowUiExperimentalApi::class) class PerformancePoemWorkflow( private val simulatedPerfConfig: SimulatedPerfConfig = SimulatedPerfConfig.NO_SIMULATED_PERF, private val isLoading: MutableStateFlow, @@ -234,6 +240,164 @@ class PerformancePoemWorkflow( } } + @Composable + override fun Rendering( + renderProps: Poem, + renderState: State, + context: RenderContext, + hoistRendering: @Composable (rendering: OverviewDetailScreen) -> Unit + ) { + when (renderState) { + Initializing -> { + // Again, the entire `Initializing` state is a smell, which is most obvious from the + // use of `Worker.from { Unit }`. A Worker doing no work and only shuttling the state + // along is usually the sign you have an extraneous state that can be collapsed! + // Don't try this at home. + context.runningWorker( + Worker.from { + isLoading.value = true + }, + "initializing" + ) { + action { + isLoading.value = false + state = Selected(NO_SELECTED_STANZA) + } + } + hoistRendering(OverviewDetailScreen(overviewRendering = BackStackScreen(BlankScreen))) + } + else -> { + val (stanzaIndex, currentStateIsLoading, repeat) = when (renderState) { + is ComplexCall -> Triple(renderState.payload, true, renderState.repeater) + is Selected -> Triple(renderState.stanzaIndex, false, 0) + Initializing -> throw IllegalStateException("No longer initializing.") + } + + if (currentStateIsLoading) { + if (repeat > 0) { + // Running a flow that emits 'repeat' number of times + context.runningWorker( + flow { + while (true) { + // As long as this Worker is running we want to be emitting values. + delay(2) + emit(repeat) + } + }.asTraceableWorker("EventRepetition") + ) { + action { + (state as? ComplexCall)?.let { currentState -> + // Still repeating the complex call + state = ComplexCall( + payload = currentState.payload, + repeater = (currentState.repeater - 1).coerceAtLeast(0) + ) + } + } + } + } else { + context.runningWorker( + worker = TraceableWorker.from("PoemLoading") { + isLoading.value = true + delay(simulatedPerfConfig.complexityDelay) + // No Output for Worker is necessary because the selected index + // is already in the state. + } + ) { + action { + isLoading.value = false + (state as? ComplexCall)?.let { currentState -> + state = Selected(currentState.payload) + } + } + } + } + } + + val previousStanzas: MutableState> = remember { + mutableStateOf(emptyList()) + } + val visibleStanza: MutableState = remember { + mutableStateOf(null) + } + + if (stanzaIndex != NO_SELECTED_STANZA) { + renderProps.stanzas.subList(0, stanzaIndex) + .forEachIndexed { index, _ -> + context.ChildRendering( + StanzaWorkflow, + Props( + poem = renderProps, + index = index, + eventHandlerTag = ActionHandlingTracingInterceptor::keyForTrace + ), + key = "$index", + hoistRendering = @Composable { + previousStanzas.value = previousStanzas.value + it + } + ) { + noAction() + } + } + context.ChildRendering( + StanzaWorkflow, + Props( + poem = renderProps, + index = stanzaIndex, + eventHandlerTag = ActionHandlingTracingInterceptor::keyForTrace + ), + key = "$stanzaIndex", + hoistRendering = @Composable { + visibleStanza.value = it + } + ) { + when (it) { + CloseStanzas -> ClearSelection(simulatedPerfConfig) + ShowPreviousStanza -> SelectPrevious(simulatedPerfConfig) + ShowNextStanza -> SelectNext(simulatedPerfConfig) + } + } + } + + val stackedStanzas = visibleStanza.value?.let { + (previousStanzas.value + it).toBackStackScreen() + } + + val stanzaListOverview: MutableState = remember { + mutableStateOf(null) + } + context.ChildRendering( + StanzaListWorkflow, + StanzaListWorkflow.Props( + poem = renderProps, + eventHandlerTag = ActionHandlingTracingInterceptor::keyForTrace + ), + key = "", + hoistRendering = @Composable { + stanzaListOverview.value = it.copy(selection = stanzaIndex) + } + ) { selected -> + HandleStanzaListOutput(simulatedPerfConfig, selected) + } + + hoistRendering( + stackedStanzas + ?.let { + OverviewDetailScreen( + overviewRendering = BackStackScreen(stanzaListOverview.value!!), + detailRendering = it + ) + } ?: OverviewDetailScreen( + overviewRendering = BackStackScreen(stanzaListOverview.value!!), + selectDefault = { + context.actionSink.send(HandleStanzaListOutput(simulatedPerfConfig, 0)) + } + ) + ) + } + } + } + override fun snapshotState(state: State): Snapshot? = null internal sealed class Action : WorkflowAction() { diff --git a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoemsBrowserWorkflow.kt b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoemsBrowserWorkflow.kt index bb12e905c..91326c49a 100644 --- a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoemsBrowserWorkflow.kt +++ b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoemsBrowserWorkflow.kt @@ -1,5 +1,6 @@ package com.squareup.benchmarks.performance.complex.poetry +import androidx.compose.runtime.Composable import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemsBrowserWorkflow.State import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemsBrowserWorkflow.State.ComplexCall import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemsBrowserWorkflow.State.Initializing @@ -42,6 +43,7 @@ import kotlinx.coroutines.flow.MutableStateFlow * break ties/conflicts with a token in the start/stop requests. We leave that complexity out * here. ** */ +@OptIn(WorkflowUiExperimentalApi::class) class PerformancePoemsBrowserWorkflow( private val simulatedPerfConfig: SimulatedPerfConfig, private val poemWorkflow: PoemWorkflow, @@ -70,7 +72,6 @@ class PerformancePoemsBrowserWorkflow( return if (simulatedPerfConfig.useInitializingState) Initializing else NoSelection } - @OptIn(WorkflowUiExperimentalApi::class) override fun render( renderProps: List, renderState: State, @@ -154,6 +155,106 @@ class PerformancePoemsBrowserWorkflow( } } + @Composable + override fun Rendering( + renderProps: List, + renderState: State, + context: RenderContext, + hoistRendering: @Composable (rendering: OverviewDetailScreen) -> Unit + ) { + val poemListProps = Props( + poems = renderProps, + eventHandlerTag = ActionHandlingTracingInterceptor::keyForTrace + ) + context.ChildRendering( + child = PoemListWorkflow, + props = poemListProps, + key = "", + hoistRendering = { poemListRendering -> + when (renderState) { + // Again, then entire `Initializing` state is a smell, which is most obvious from the + // use of `Worker.from { Unit }`. A Worker doing no work and only shuttling the state + // along is usually the sign you have an extraneous state that can be collapsed! + // Don't try this at home. + is Initializing -> { + context.runningWorker(TraceableWorker.from("BrowserInitializing") { Unit }, "init") { + isLoading.value = true + action { + isLoading.value = false + state = NoSelection + } + } + hoistRendering(OverviewDetailScreen(overviewRendering = BackStackScreen(BlankScreen))) + } + is NoSelection -> { + hoistRendering( + OverviewDetailScreen( + overviewRendering = BackStackScreen( + poemListRendering.copy(selection = NO_POEM_SELECTED) + ) + ) + ) + } + is ComplexCall -> { + context.runningWorker( + TraceableWorker.from("ComplexCallBrowser(${renderState.payload})") { + isLoading.value = true + delay(simulatedPerfConfig.complexityDelay) + // No Output for Worker is necessary because the selected index + // is already in the state. + } + ) { + action { + isLoading.value = false + (state as? ComplexCall)?.let { currentState -> + state = if (currentState.payload != NO_POEM_SELECTED) { + Selected(currentState.payload) + } else { + NoSelection + } + } + } + } + val poemOverview = OverviewDetailScreen( + overviewRendering = BackStackScreen( + poemListRendering.copy(selection = renderState.payload) + ) + ) + if (renderState.payload != NO_POEM_SELECTED) { + context.ChildRendering( + poemWorkflow, + renderProps[renderState.payload], + key = "", + hoistRendering = { poem: OverviewDetailScreen -> + hoistRendering(poemOverview + poem) + } + ) { clearSelection } + } else { + hoistRendering(poemOverview) + } + } + is Selected -> { + val poemOverview = OverviewDetailScreen( + overviewRendering = BackStackScreen( + poemListRendering.copy(selection = renderState.poemIndex) + ) + ) + context.ChildRendering( + poemWorkflow, + renderProps[renderState.poemIndex], + key = "", + hoistRendering = { poem: OverviewDetailScreen -> + hoistRendering(poemOverview + poem) + } + ) { clearSelection } + } + } + } + ) { selected -> + choosePoem(selected) + } + } + override fun snapshotState(state: State): Snapshot? = null private fun choosePoem( diff --git a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoetryActivity.kt b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoetryActivity.kt index 19d178423..b74c3d5f2 100644 --- a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoetryActivity.kt +++ b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoetryActivity.kt @@ -83,8 +83,12 @@ class PerformancePoetryActivity : AppCompatActivity() { installedInterceptor = ActionHandlingTracingInterceptor() } - val isFrameTimeout = intent.getBooleanExtra(EXTRA_RUNTIME_FRAME_TIMEOUT, false) - val runtimeConfig = if (isFrameTimeout) FrameTimeout() else RenderPerAction + val isFrameTimeout = true; //intent.getBooleanExtra(EXTRA_RUNTIME_FRAME_TIMEOUT, false) + val runtimeConfig = if (isFrameTimeout) { + FrameTimeout(useComposeInRuntime = true) + } else { + RenderPerAction + } val component = PerformancePoetryComponent(installedInterceptor, simulatedPerfConfig, runtimeConfig) 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 9d851dbb1..d92dfc6e6 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 @@ -1,5 +1,6 @@ package com.squareup.benchmarks.performance.complex.poetry.instrumentation +import androidx.compose.runtime.Composable import androidx.tracing.trace import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.WorkflowAction @@ -70,6 +71,21 @@ class ActionHandlingTracingInterceptor : WorkflowInterceptor, Resettable { ) } + @Composable + override fun Rendering( + renderProps: P, + renderState: S, + hoistRendering: @Composable (R) -> Unit, + context: BaseRenderContext, + session: WorkflowSession, + proceed: @Composable (P, S, RenderContextInterceptor?, @Composable (R) -> Unit) -> Unit + ) = proceed( + renderProps, + renderState, + EventHandlingTracingRenderContextInterceptor(actionCounts), + hoistRendering + ) + override fun reset() { actionCounts.clear() } diff --git a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/PerformanceTracingInterceptor.kt b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/PerformanceTracingInterceptor.kt index 4b0756915..cac0ec9fa 100644 --- a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/PerformanceTracingInterceptor.kt +++ b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/PerformanceTracingInterceptor.kt @@ -1,5 +1,6 @@ package com.squareup.benchmarks.performance.complex.poetry.instrumentation +import androidx.compose.runtime.Composable import androidx.tracing.Trace import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemWorkflow import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemsBrowserWorkflow @@ -28,8 +29,36 @@ class PerformanceTracingInterceptor( proceed: (P, S, RenderContextInterceptor?) -> R, session: WorkflowSession ): R { - val isRoot = session.parent == null val traceIdIndex = NODES_TO_TRACE.indexOfFirst { it.second == session.identifier } + val isRoot = before(traceIdIndex, session) + return proceed(renderProps, renderState, null).also { + after(traceIdIndex = traceIdIndex, isRoot = isRoot) + } + } + + @Composable + override fun Rendering( + renderProps: P, + renderState: S, + hoistRendering: @Composable (R) -> Unit, + context: BaseRenderContext, + session: WorkflowSession, + proceed: @Composable (P, S, RenderContextInterceptor?, @Composable (R) -> Unit) -> Unit + ) { + // TODO: Fix that these are illegal side effects in a Composable + val traceIdIndex = NODES_TO_TRACE.indexOfFirst { it.second == session.identifier } + val isRoot = before(traceIdIndex, session) + proceed(renderProps, renderState, null, hoistRendering).also { + after(traceIdIndex = traceIdIndex, isRoot = isRoot) + } + } + + private fun before( + traceIdIndex: Int, + session: WorkflowSession + ): Boolean { + val isRoot = session.parent == null + val renderPassMarker = totalRenderPasses.toString() .padStart(RENDER_PASS_DIGITS, '0') @@ -44,17 +73,21 @@ class PerformanceTracingInterceptor( "${NODES_TO_TRACE[traceIdIndex].first}_" Trace.beginSection(sectionName) } + return isRoot + } - return proceed(renderProps, renderState, null).also { - if (traceIdIndex > -1 && !sample) { + private fun after( + traceIdIndex: Int, + isRoot: Boolean + ) { + if (traceIdIndex > -1 && !sample) { + Trace.endSection() + } + if (isRoot) { + if (!sample || totalRenderPasses.mod(2) == 0) { Trace.endSection() } - if (isRoot) { - if (!sample || totalRenderPasses.mod(2) == 0) { - Trace.endSection() - } - totalRenderPasses++ - } + totalRenderPasses++ } } diff --git a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/RenderPassCountingInterceptor.kt b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/RenderPassCountingInterceptor.kt index 748b69132..e7ca1f20d 100644 --- a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/RenderPassCountingInterceptor.kt +++ b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/RenderPassCountingInterceptor.kt @@ -1,5 +1,6 @@ package com.squareup.benchmarks.performance.complex.poetry.instrumentation +import androidx.compose.runtime.Composable import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor @@ -25,6 +26,34 @@ class RenderPassCountingInterceptor : WorkflowInterceptor, Resettable { proceed: (P, S, RenderContextInterceptor?) -> R, session: WorkflowSession ): R { + val isRoot = before(session, renderState) + return proceed(renderProps, renderState, null).also { + after(isRoot) + } + } + + @Composable + override fun Rendering( + renderProps: P, + renderState: S, + hoistRendering: @Composable (R) -> Unit, + context: BaseRenderContext, + session: WorkflowSession, + proceed: @Composable + (P, S, RenderContextInterceptor?, @Composable (rendering: R) -> Unit) -> Unit + ) { + // TODO: This counting is a side effect that is technically illegal here in a Composable and it + // is certainly not idempotent. + val isRoot = before(session, renderState) + proceed(renderProps, renderState, null, hoistRendering).also { + after(isRoot) + } + } + + private fun before( + session: WorkflowSession, + renderState: S + ): Boolean { val isRoot = session.parent == null if (isRoot) { @@ -46,12 +75,13 @@ class RenderPassCountingInterceptor : WorkflowInterceptor, Resettable { } nodeStates[session.sessionId] = renderStateString } + return isRoot + } - return proceed(renderProps, renderState, null).also { - if (isRoot) { - renderEfficiencyTracking.totalRenderPasses += 1 - renderEfficiencyTracking.totalNodeStats += renderPassStats - } + private fun after(isRoot: Boolean) { + if (isRoot) { + renderEfficiencyTracking.totalRenderPasses += 1 + renderEfficiencyTracking.totalNodeStats += renderPassStats } } diff --git a/build.gradle.kts b/build.gradle.kts index 5ab4bf20c..463d690d8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,6 +10,7 @@ buildscript { classpath(libs.kotlin.serialization.gradle.plugin) classpath(libs.kotlinx.binaryCompatibility.gradle.plugin) classpath(libs.kotlin.gradle.plugin) + classpath(libs.molecule.gradle.plugin) classpath(libs.google.ksp) classpath(libs.ktlint.gradle) classpath(libs.vanniktech.publish) @@ -19,6 +20,9 @@ buildscript { mavenCentral() gradlePluginPortal() google() + maven { + url = uri("https://oss.sonatype.org/content/repositories/snapshots/") + } // For binary compatibility validator. maven { url = uri("https://kotlin.bintray.com/kotlinx") } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e572fc5df..924ed0edb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,8 +9,6 @@ targetSdk = "30" androidx-activity = "1.3.0" androidx-appcompat = "1.3.1" androidx-benchmark = "1.1.0-rc03" -androidx-compose = "1.1.0-rc01" -androidx-compose-compiler = "1.1.0-rc02" androidx-constraintlayout = "2.1.2" androidx-core = "1.6.0" androidx-fragment = "1.3.6" @@ -32,6 +30,9 @@ androidx-transition = "1.4.1" androidx-viewbinding = "4.2.1" androidx-work = "2.6.0" +compose = "1.1.0" +compose-compiler = "1.1.0" + detekt = "1.19.0" dokka = "1.5.31" dependencyGuard = "0.1.0" @@ -49,8 +50,8 @@ kotest = "5.1.0" kotlin = "1.6.10" kotlinx-binary-compatibility = "0.6.0" -kotlinx-coroutines = "1.5.1" -# The 1.5.1 test artifact is jvm-only. The commonTest module should use 1.6.1. +kotlinx-coroutines = "1.5.2" +# The 1.5.2 test artifact is jvm-only. The commonTest module should use 1.6.1. kotlinx-coroutines-test-common = "1.6.1" kotlinx-serialization-json = "1.3.2" kotlinx-benchmark = "0.4.2" @@ -64,6 +65,9 @@ mockito-core = "3.3.3" mockito-kotlin = "3.2.0" mockk = "1.11.0" + +molecule = "0.3.0-SNAPSHOT" + robolectric = "4.6.1" rxjava2-android = "2.1.1" @@ -100,8 +104,12 @@ kotlinx-benchmark = { id = "org.jetbrains.kotlinx.benchmark", version.ref = "kot ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktech-publish" } +molecule = { id = "app.cash.molecule", version.ref = "molecule" } + [libraries] +molecule-gradle-plugin = { module = "app.cash.molecule:molecule-gradle-plugin", version.ref = "molecule"} + android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "androidTools" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } @@ -112,13 +120,16 @@ androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "a androidx-macro-benchmark = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark" } -androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "androidx-compose" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } + +androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "compose" } -androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "androidx-compose" } +androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } -androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidx-compose" } -androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "androidx-compose" } -androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "androidx-compose" } +compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose"} +compose-compiler = { module = "org.jetbrains.compose.compiler:compiler", version.ref = "compose"} androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" } @@ -203,6 +214,9 @@ mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = " mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule"} +molecule-testing = { module = "app.cash.molecule:molecule-testing", version.ref = "molecule"} + robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } rxjava2-rxandroid = { module = "io.reactivex.rxjava2:rxandroid", version.ref = "rxjava2-android" } diff --git a/samples/compose-samples/build.gradle.kts b/samples/compose-samples/build.gradle.kts index c7ee68f31..91908dac2 100644 --- a/samples/compose-samples/build.gradle.kts +++ b/samples/compose-samples/build.gradle.kts @@ -14,7 +14,7 @@ android { compose = true } composeOptions { - kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 662672fed..29143c826 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,6 +9,8 @@ pluginManagement { google() // For binary compatibility validator. maven { url = uri("https://kotlin.bintray.com/kotlinx") } + // For molecule SNAPSHOT + maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots/")} } } @@ -29,6 +31,8 @@ dependencyResolutionManagement { repositories { mavenCentral() google() + // For molecule SNAPSHOT + maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots/")} // See androidx.dev (can use this for Snapshot builds of AndroidX) // maven { url = java.net.URI.create("https://androidx.dev/snapshots/builds/8224905/artifacts/repository") } } diff --git a/workflow-core/api/workflow-core.api b/workflow-core/api/workflow-core.api index e7fe83557..880b81678 100644 --- a/workflow-core/api/workflow-core.api +++ b/workflow-core/api/workflow-core.api @@ -2,6 +2,7 @@ public abstract interface class com/squareup/workflow1/ActionProcessingResult { } public abstract interface class com/squareup/workflow1/BaseRenderContext { + public abstract fun ChildRendering (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)V public abstract fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function10;)Lkotlin/jvm/functions/Function9; public abstract fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function11;)Lkotlin/jvm/functions/Function10; public abstract fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; @@ -54,6 +55,7 @@ public final class com/squareup/workflow1/ImpostorWorkflow$DefaultImpls { } public abstract class com/squareup/workflow1/LifecycleWorker : com/squareup/workflow1/Worker { + public static final field $stable I public fun ()V public fun doesSameWorkAs (Lcom/squareup/workflow1/Worker;)Z public fun onStarted ()V @@ -62,6 +64,7 @@ public abstract class com/squareup/workflow1/LifecycleWorker : com/squareup/work } public final class com/squareup/workflow1/PropsUpdated : com/squareup/workflow1/ActionProcessingResult { + public static final field $stable I public static final field INSTANCE Lcom/squareup/workflow1/PropsUpdated; } @@ -70,6 +73,7 @@ public abstract interface class com/squareup/workflow1/Sink { } public final class com/squareup/workflow1/Snapshot { + public static final field $stable I public static final field Companion Lcom/squareup/workflow1/Snapshot$Companion; public synthetic fun (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun bytes ()Lokio/ByteString; @@ -114,7 +118,9 @@ public final class com/squareup/workflow1/Snapshots { } public abstract class com/squareup/workflow1/StatefulWorkflow : com/squareup/workflow1/Workflow { + public static final field $stable I public fun ()V + public fun Rendering (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/StatefulWorkflow$RenderContext;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V public final fun asStatefulWorkflow ()Lcom/squareup/workflow1/StatefulWorkflow; public abstract fun initialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;)Ljava/lang/Object; public fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; @@ -124,6 +130,7 @@ public abstract class com/squareup/workflow1/StatefulWorkflow : com/squareup/wor public final class com/squareup/workflow1/StatefulWorkflow$RenderContext : com/squareup/workflow1/BaseRenderContext { public fun (Lcom/squareup/workflow1/StatefulWorkflow;Lcom/squareup/workflow1/BaseRenderContext;)V + public fun ChildRendering (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)V public fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function10;)Lkotlin/jvm/functions/Function9; public fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function11;)Lkotlin/jvm/functions/Function10; public fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; @@ -141,13 +148,16 @@ public final class com/squareup/workflow1/StatefulWorkflow$RenderContext : com/s } public abstract class com/squareup/workflow1/StatelessWorkflow : com/squareup/workflow1/Workflow { + public static final field $stable I public fun ()V + public fun Rendering (Ljava/lang/Object;Lcom/squareup/workflow1/StatelessWorkflow$RenderContext;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V public final fun asStatefulWorkflow ()Lcom/squareup/workflow1/StatefulWorkflow; public abstract fun render (Ljava/lang/Object;Lcom/squareup/workflow1/StatelessWorkflow$RenderContext;)Ljava/lang/Object; } public final class com/squareup/workflow1/StatelessWorkflow$RenderContext : com/squareup/workflow1/BaseRenderContext { public fun (Lcom/squareup/workflow1/StatelessWorkflow;Lcom/squareup/workflow1/BaseRenderContext;)V + public fun ChildRendering (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)V public fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function10;)Lkotlin/jvm/functions/Function9; public fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function11;)Lkotlin/jvm/functions/Function10; public fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; @@ -165,6 +175,7 @@ public final class com/squareup/workflow1/StatelessWorkflow$RenderContext : com/ } public final class com/squareup/workflow1/TimeoutForFrame : com/squareup/workflow1/ActionProcessingResult { + public static final field $stable I public static final field INSTANCE Lcom/squareup/workflow1/TimeoutForFrame; } @@ -222,6 +233,7 @@ public final class com/squareup/workflow1/Workflow$Companion { } public abstract class com/squareup/workflow1/WorkflowAction { + public static final field $stable I public static final field Companion Lcom/squareup/workflow1/WorkflowAction$Companion; public fun ()V public abstract fun apply (Lcom/squareup/workflow1/WorkflowAction$Updater;)V @@ -241,6 +253,7 @@ public final class com/squareup/workflow1/WorkflowAction$Updater { } public final class com/squareup/workflow1/WorkflowIdentifier { + public static final field $stable I public static final field Companion Lcom/squareup/workflow1/WorkflowIdentifier$Companion; public fun (Lcom/squareup/workflow1/WorkflowIdentifierType;Lcom/squareup/workflow1/WorkflowIdentifier;Lkotlin/jvm/functions/Function0;)V public synthetic fun (Lcom/squareup/workflow1/WorkflowIdentifierType;Lcom/squareup/workflow1/WorkflowIdentifier;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -260,10 +273,12 @@ public final class com/squareup/workflow1/WorkflowIdentifierExKt { } public abstract class com/squareup/workflow1/WorkflowIdentifierType { + public static final field $stable I public abstract fun getTypeName ()Ljava/lang/String; } public final class com/squareup/workflow1/WorkflowIdentifierType$Snapshottable : com/squareup/workflow1/WorkflowIdentifierType { + public static final field $stable I public fun (Ljava/lang/String;Lkotlin/reflect/KClass;)V public synthetic fun (Ljava/lang/String;Lkotlin/reflect/KClass;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Lkotlin/reflect/KClass;)V @@ -279,6 +294,7 @@ public final class com/squareup/workflow1/WorkflowIdentifierType$Snapshottable : } public final class com/squareup/workflow1/WorkflowIdentifierType$Unsnapshottable : com/squareup/workflow1/WorkflowIdentifierType { + public static final field $stable I public fun (Lkotlin/reflect/KType;)V public final fun component1 ()Lkotlin/reflect/KType; public final fun copy (Lkotlin/reflect/KType;)Lcom/squareup/workflow1/WorkflowIdentifierType$Unsnapshottable; @@ -291,6 +307,7 @@ public final class com/squareup/workflow1/WorkflowIdentifierType$Unsnapshottable } public final class com/squareup/workflow1/WorkflowOutput : com/squareup/workflow1/ActionProcessingResult { + public static final field $stable I public fun (Ljava/lang/Object;)V public fun equals (Ljava/lang/Object;)Z public final fun getValue ()Ljava/lang/Object; @@ -299,6 +316,9 @@ public final class com/squareup/workflow1/WorkflowOutput : com/squareup/workflow } public final class com/squareup/workflow1/Workflows { + public static final fun ChildRendering (Lcom/squareup/workflow1/BaseRenderContext;Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun ChildRendering (Lcom/squareup/workflow1/BaseRenderContext;Lcom/squareup/workflow1/Workflow;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun ChildRendering (Lcom/squareup/workflow1/BaseRenderContext;Lcom/squareup/workflow1/Workflow;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V public static final fun RenderContext (Lcom/squareup/workflow1/BaseRenderContext;Lcom/squareup/workflow1/StatefulWorkflow;)Lcom/squareup/workflow1/StatefulWorkflow$RenderContext; public static final fun RenderContext (Lcom/squareup/workflow1/BaseRenderContext;Lcom/squareup/workflow1/StatelessWorkflow;)Lcom/squareup/workflow1/StatelessWorkflow$RenderContext; public static final fun action (Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/WorkflowAction; diff --git a/workflow-core/build.gradle.kts b/workflow-core/build.gradle.kts index ed9b091fe..d597d5fdb 100644 --- a/workflow-core/build.gradle.kts +++ b/workflow-core/build.gradle.kts @@ -1,11 +1,13 @@ plugins { `kotlin-multiplatform` published + id("app.cash.molecule") } kotlin { jvm { withJava() } - ios() + // TODO: No native targets yet for Molecule until Compose 1.2.0 available in JB KMP runtime. + // ios() sourceSets { all { @@ -16,9 +18,11 @@ kotlin { val commonMain by getting { dependencies { api(libs.kotlin.jdk6) + api(libs.compose.runtime) api(libs.kotlinx.coroutines.core) // For Snapshot. api(libs.squareup.okio) + implementation(libs.molecule.runtime) } } val commonTest by getting { 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 c727556de..7e66417a2 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/BaseRenderContext.kt @@ -4,6 +4,7 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import com.squareup.workflow1.WorkflowAction.Companion.noAction import kotlinx.coroutines.CoroutineScope import kotlin.jvm.JvmMultifileClass @@ -78,6 +79,15 @@ public interface BaseRenderContext { handler: (ChildOutputT) -> WorkflowAction ): ChildRenderingT + @Composable + public fun ChildRendering( + child: Workflow, + props: ChildPropsT, + key: String, + hoistRendering: @Composable (ChildRenderingT) -> Unit, + handler: (ChildOutputT) -> WorkflowAction + ): Unit + /** * Ensures [sideEffect] is running with the given [key]. * @@ -241,6 +251,41 @@ BaseRenderContext.renderChild( child: Workflow, key: String = "" ): ChildRenderingT = renderChild(child, Unit, key) { noAction() } + +/** + * Convenience alias of [BaseRenderContext.ChildRendering] for workflows that don't take props. + */ +@Composable +public fun +BaseRenderContext.ChildRendering( + child: Workflow, + key: String = "", + hoistRendering: @Composable (ChildRenderingT) -> Unit, + handler: (ChildOutputT) -> WorkflowAction +): Unit = ChildRendering(child, Unit, key, hoistRendering, handler) +/** + * Convenience alias of [BaseRenderContext.ChildRendering] for workflows that don't emit output. + */ +@Composable +public fun +BaseRenderContext.ChildRendering( + child: Workflow, + props: ChildPropsT, + key: String = "", + hoistRendering: @Composable (ChildRenderingT) -> Unit, +): Unit = ChildRendering(child, props, key, hoistRendering) { noAction() } +/** + * Convenience alias of [BaseRenderContext.ChildRendering] for children that don't take props or emit + * output. + */ +@Composable +public fun +BaseRenderContext.ChildRendering( + child: Workflow, + key: String = "", + hoistRendering: @Composable (ChildRenderingT) -> Unit, +): Unit = ChildRendering(child, Unit, key, hoistRendering) { noAction() } + /** * Ensures a [Worker] that never emits anything is running. Since [worker] can't emit anything, * it can't trigger any [WorkflowAction]s. 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 031235ff4..56fa97f8b 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatefulWorkflow.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatefulWorkflow.kt @@ -4,6 +4,7 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import com.squareup.workflow1.StatefulWorkflow.RenderContext import com.squareup.workflow1.WorkflowAction.Companion.toString import kotlin.jvm.JvmMultifileClass @@ -105,6 +106,34 @@ public abstract class StatefulWorkflow< state: StateT ): StateT = state + @Composable + public open fun Rendering( + renderProps: PropsT, + renderState: StateT, + context: RenderContext, + hoistRendering: @Composable (rendering: RenderingT) -> Unit + ): Unit { + // By default call render() as a fallback. + hoistRendering(render(renderProps, renderState, context)) + } + + + /** + * Called whenever the state changes to generate a new [Snapshot] of the state. + * + * **Snapshots must be lazy.** + * + * Serialization must not be done at the time this method is called, + * since the state will be snapshotted frequently but the serialized form may only be needed very + * rarely. + * + * If the workflow does not have any state, or should always be started from scratch, return + * `null` from this method. + * + * @see initialState + */ + public abstract fun snapshotState(state: StateT): Snapshot? + /** * Called at least once† any time one of the following things happens: * - This workflow's [renderProps] changes (via the parent passing a different one in). @@ -129,22 +158,6 @@ public abstract class StatefulWorkflow< context: RenderContext ): RenderingT - /** - * Called whenever the state changes to generate a new [Snapshot] of the state. - * - * **Snapshots must be lazy.** - * - * Serialization must not be done at the time this method is called, - * since the state will be snapshotted frequently but the serialized form may only be needed very - * rarely. - * - * If the workflow does not have any state, or should always be started from scratch, return - * `null` from this method. - * - * @see initialState - */ - public abstract fun snapshotState(state: StateT): Snapshot? - /** * Satisfies the [Workflow] interface by returning `this`. */ 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 f6545524e..8206c03cd 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatelessWorkflow.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatelessWorkflow.kt @@ -3,6 +3,7 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName @@ -57,6 +58,13 @@ public abstract class StatelessWorkflow context: RenderContext ): RenderingT + @Composable + public open fun Rendering( + renderProps: PropsT, + context: RenderContext, + hoistRendering: @Composable (RenderingT) -> Unit + ): Unit = hoistRendering(render(renderProps, context)) + /** * Satisfies the [Workflow] interface by wrapping `this` in a [StatefulWorkflow] with `Unit` * state. diff --git a/workflow-runtime/api/workflow-runtime.api b/workflow-runtime/api/workflow-runtime.api index bac7d553b..be4b92253 100644 --- a/workflow-runtime/api/workflow-runtime.api +++ b/workflow-runtime/api/workflow-runtime.api @@ -1,5 +1,16 @@ +public final class com/squareup/workflow1/Latch { + public fun ()V + public final fun await (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun closeLatch ()V + public final fun isOpen ()Z + public final fun openLatch ()V + public final fun withClosed (Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; +} + public final class com/squareup/workflow1/NoopWorkflowInterceptor : com/squareup/workflow1/WorkflowInterceptor { + public static final field $stable I public static final field INSTANCE Lcom/squareup/workflow1/NoopWorkflowInterceptor; + public fun Rendering (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/BaseRenderContext;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;Lkotlin/jvm/functions/Function6;Landroidx/compose/runtime/Composer;I)V public fun onInitialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;Lkotlin/jvm/functions/Function2;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; @@ -13,6 +24,7 @@ public final class com/squareup/workflow1/RenderWorkflowKt { } public final class com/squareup/workflow1/RenderingAndSnapshot { + public static final field $stable I public fun (Ljava/lang/Object;Lcom/squareup/workflow1/TreeSnapshot;)V public final fun component1 ()Ljava/lang/Object; public final fun component2 ()Lcom/squareup/workflow1/TreeSnapshot; @@ -22,6 +34,7 @@ public final class com/squareup/workflow1/RenderingAndSnapshot { public abstract interface class com/squareup/workflow1/RuntimeConfig { public static final field Companion Lcom/squareup/workflow1/RuntimeConfig$Companion; + public abstract fun getUseComposeInRuntime ()Z } public final class com/squareup/workflow1/RuntimeConfig$Companion { @@ -29,24 +42,37 @@ public final class com/squareup/workflow1/RuntimeConfig$Companion { } public final class com/squareup/workflow1/RuntimeConfig$FrameTimeout : com/squareup/workflow1/RuntimeConfig { + public static final field $stable I public fun ()V - public fun (J)V - public synthetic fun (JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (JZ)V + public synthetic fun (JZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()J - public final fun copy (J)Lcom/squareup/workflow1/RuntimeConfig$FrameTimeout; - public static synthetic fun copy$default (Lcom/squareup/workflow1/RuntimeConfig$FrameTimeout;JILjava/lang/Object;)Lcom/squareup/workflow1/RuntimeConfig$FrameTimeout; + public final fun component2 ()Z + public final fun copy (JZ)Lcom/squareup/workflow1/RuntimeConfig$FrameTimeout; + public static synthetic fun copy$default (Lcom/squareup/workflow1/RuntimeConfig$FrameTimeout;JZILjava/lang/Object;)Lcom/squareup/workflow1/RuntimeConfig$FrameTimeout; public fun equals (Ljava/lang/Object;)Z public final fun getFrameTimeoutMs ()J + public fun getUseComposeInRuntime ()Z public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/squareup/workflow1/RuntimeConfig$RenderPerAction : com/squareup/workflow1/RuntimeConfig { + public static final field $stable I public static final field INSTANCE Lcom/squareup/workflow1/RuntimeConfig$RenderPerAction; + public fun getUseComposeInRuntime ()Z +} + +public final class com/squareup/workflow1/SignalHolder { + public fun ()V + public final fun getSignal$wf1_workflow_runtime ()Lkotlin/jvm/functions/Function0; + public final fun setSignal$wf1_workflow_runtime (Lkotlin/jvm/functions/Function0;)V } public class com/squareup/workflow1/SimpleLoggingWorkflowInterceptor : com/squareup/workflow1/WorkflowInterceptor { + public static final field $stable I public fun ()V + public fun Rendering (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/BaseRenderContext;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;Lkotlin/jvm/functions/Function6;Landroidx/compose/runtime/Composer;I)V protected fun log (Ljava/lang/String;)V protected fun logAfterMethod (Ljava/lang/String;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;[Lkotlin/Pair;)V protected fun logBeforeMethod (Ljava/lang/String;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;[Lkotlin/Pair;)V @@ -59,6 +85,7 @@ public class com/squareup/workflow1/SimpleLoggingWorkflowInterceptor : com/squar } public final class com/squareup/workflow1/TreeSnapshot { + public static final field $stable I public static final field Companion Lcom/squareup/workflow1/TreeSnapshot$Companion; public fun (Lcom/squareup/workflow1/Snapshot;Lkotlin/jvm/functions/Function0;)V public fun equals (Ljava/lang/Object;)Z @@ -77,6 +104,7 @@ public abstract interface annotation class com/squareup/workflow1/WorkflowExperi } public abstract interface class com/squareup/workflow1/WorkflowInterceptor { + public abstract fun Rendering (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/BaseRenderContext;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;Lkotlin/jvm/functions/Function6;Landroidx/compose/runtime/Composer;I)V public abstract fun onInitialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;Lkotlin/jvm/functions/Function2;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; public abstract fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; @@ -85,6 +113,7 @@ public abstract interface class com/squareup/workflow1/WorkflowInterceptor { } public final class com/squareup/workflow1/WorkflowInterceptor$DefaultImpls { + public static fun Rendering (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/BaseRenderContext;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;Lkotlin/jvm/functions/Function6;Landroidx/compose/runtime/Composer;I)V public static fun onInitialState (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;Lkotlin/jvm/functions/Function2;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public static fun onPropsChanged (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public static fun onRender (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; @@ -93,12 +122,14 @@ public final class com/squareup/workflow1/WorkflowInterceptor$DefaultImpls { } public abstract interface class com/squareup/workflow1/WorkflowInterceptor$RenderContextInterceptor { + public abstract fun ChildRendering (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function7;Landroidx/compose/runtime/Composer;I)V public abstract fun onActionSent (Lcom/squareup/workflow1/WorkflowAction;Lkotlin/jvm/functions/Function1;)V 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 ChildRendering (Lcom/squareup/workflow1/WorkflowInterceptor$RenderContextInterceptor;Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function7;Landroidx/compose/runtime/Composer;I)V public static fun onActionSent (Lcom/squareup/workflow1/WorkflowInterceptor$RenderContextInterceptor;Lcom/squareup/workflow1/WorkflowAction;Lkotlin/jvm/functions/Function1;)V 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 @@ -115,6 +146,16 @@ public final class com/squareup/workflow1/WorkflowInterceptorKt { public static final fun intercept (Lcom/squareup/workflow1/WorkflowInterceptor;Lcom/squareup/workflow1/StatefulWorkflow;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/StatefulWorkflow; } +public final class com/squareup/workflow1/WorkflowRuntimeClock : androidx/compose/runtime/MonotonicFrameClock { + public fun (Lcom/squareup/workflow1/Latch;)V + public fun fold (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;)Ljava/lang/Object; + public fun get (Lkotlin/coroutines/CoroutineContext$Key;)Lkotlin/coroutines/CoroutineContext$Element; + public fun getKey ()Lkotlin/coroutines/CoroutineContext$Key; + public fun minusKey (Lkotlin/coroutines/CoroutineContext$Key;)Lkotlin/coroutines/CoroutineContext; + public fun plus (Lkotlin/coroutines/CoroutineContext;)Lkotlin/coroutines/CoroutineContext; + public fun withFrameNanos (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class com/squareup/workflow1/internal/ActiveStagingList { public fun ()V public final fun commitStaging (Lkotlin/jvm/functions/Function1;)V @@ -125,6 +166,7 @@ public final class com/squareup/workflow1/internal/ActiveStagingList { public final class com/squareup/workflow1/internal/ChainedWorkflowInterceptor : com/squareup/workflow1/WorkflowInterceptor { public fun (Ljava/util/List;)V + public fun Rendering (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/BaseRenderContext;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;Lkotlin/jvm/functions/Function6;Landroidx/compose/runtime/Composer;I)V public fun onInitialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;Lkotlin/jvm/functions/Function2;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; @@ -164,6 +206,7 @@ public abstract interface class com/squareup/workflow1/internal/InlineLinkedList public final class com/squareup/workflow1/internal/RealRenderContext : com/squareup/workflow1/BaseRenderContext, com/squareup/workflow1/Sink { public fun (Lcom/squareup/workflow1/internal/RealRenderContext$Renderer;Lcom/squareup/workflow1/internal/RealRenderContext$SideEffectRunner;Lkotlinx/coroutines/channels/SendChannel;)V + public fun ChildRendering (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)V public fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function10;)Lkotlin/jvm/functions/Function9; public fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function11;)Lkotlin/jvm/functions/Function10; public fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; @@ -176,6 +219,7 @@ public final class com/squareup/workflow1/internal/RealRenderContext : com/squar public fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function8;)Lkotlin/jvm/functions/Function7; public fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function9;)Lkotlin/jvm/functions/Function8; public final fun freeze ()V + public final fun freezeIdempotently ()V public fun getActionSink ()Lcom/squareup/workflow1/Sink; 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 @@ -184,6 +228,7 @@ public final class com/squareup/workflow1/internal/RealRenderContext : com/squar } public abstract interface class com/squareup/workflow1/internal/RealRenderContext$Renderer { + public abstract fun Rendering (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)V public abstract fun render (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; } @@ -204,6 +249,7 @@ public final class com/squareup/workflow1/internal/SideEffectNode : com/squareup public final class com/squareup/workflow1/internal/SubtreeManager : com/squareup/workflow1/internal/RealRenderContext$Renderer { public fun (Ljava/util/Map;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;Lcom/squareup/workflow1/WorkflowInterceptor;Lcom/squareup/workflow1/internal/IdCounter;)V public synthetic fun (Ljava/util/Map;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;Lcom/squareup/workflow1/WorkflowInterceptor;Lcom/squareup/workflow1/internal/IdCounter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Rendering (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)V public final fun commitRenderedChildren ()V public final fun createChildSnapshots ()Ljava/util/Map; public fun render (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; @@ -212,6 +258,7 @@ public final class com/squareup/workflow1/internal/SubtreeManager : com/squareup public final class com/squareup/workflow1/internal/SystemUtilsKt { public static final fun currentTimeMillis ()J + public static final fun nanoTime ()J } public final class com/squareup/workflow1/internal/ThrowablesKt { @@ -220,6 +267,7 @@ public final class com/squareup/workflow1/internal/ThrowablesKt { public final class com/squareup/workflow1/internal/WorkflowChildNode : com/squareup/workflow1/internal/InlineLinkedList$InlineListNode { public fun (Lcom/squareup/workflow1/Workflow;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/internal/WorkflowNode;)V + public final fun Rendering (Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V public final fun acceptChildOutput (Ljava/lang/Object;)Lcom/squareup/workflow1/WorkflowAction; public final fun getId ()Lcom/squareup/workflow1/internal/WorkflowNodeId; public synthetic fun getNextListNode ()Lcom/squareup/workflow1/internal/InlineLinkedList$InlineListNode; @@ -236,6 +284,7 @@ public final class com/squareup/workflow1/internal/WorkflowChildNode : com/squar public final class com/squareup/workflow1/internal/WorkflowNode : com/squareup/workflow1/WorkflowInterceptor$WorkflowSession, com/squareup/workflow1/internal/RealRenderContext$SideEffectRunner, kotlinx/coroutines/CoroutineScope { public fun (Lcom/squareup/workflow1/internal/WorkflowNodeId;Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;Lcom/squareup/workflow1/TreeSnapshot;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;Lcom/squareup/workflow1/WorkflowInterceptor;Lcom/squareup/workflow1/internal/IdCounter;)V public synthetic fun (Lcom/squareup/workflow1/internal/WorkflowNodeId;Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;Lcom/squareup/workflow1/TreeSnapshot;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;Lcom/squareup/workflow1/WorkflowInterceptor;Lcom/squareup/workflow1/internal/IdCounter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun Rendering (Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V public final fun cancel (Ljava/util/concurrent/CancellationException;)V public static synthetic fun cancel$default (Lcom/squareup/workflow1/internal/WorkflowNode;Ljava/util/concurrent/CancellationException;ILjava/lang/Object;)V public fun getCoroutineContext ()Lkotlin/coroutines/CoroutineContext; @@ -283,6 +332,7 @@ public final class com/squareup/workflow1/internal/WorkflowRunner { public fun (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/flow/StateFlow;Lcom/squareup/workflow1/TreeSnapshot;Lcom/squareup/workflow1/WorkflowInterceptor;Lcom/squareup/workflow1/RuntimeConfig;)V public final fun cancelRuntime (Ljava/util/concurrent/CancellationException;)V public static synthetic fun cancelRuntime$default (Lcom/squareup/workflow1/internal/WorkflowRunner;Ljava/util/concurrent/CancellationException;ILjava/lang/Object;)V + public final fun nextComposedRendering (Landroidx/compose/runtime/Composer;I)Lcom/squareup/workflow1/RenderingAndSnapshot; public final fun nextRendering ()Lcom/squareup/workflow1/RenderingAndSnapshot; public final fun processActions (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } diff --git a/workflow-runtime/build.gradle.kts b/workflow-runtime/build.gradle.kts index 57f66e1cc..2370ac86e 100644 --- a/workflow-runtime/build.gradle.kts +++ b/workflow-runtime/build.gradle.kts @@ -5,6 +5,7 @@ plugins { `kotlin-multiplatform` published id("org.jetbrains.kotlinx.benchmark") + id("app.cash.molecule") } kotlin { @@ -25,7 +26,8 @@ kotlin { } } } - ios() + // TODO: No native targets yet for Molecule until Compose 1.2.0 available in JB KMP runtime. + // ios() sourceSets { all { @@ -36,7 +38,9 @@ kotlin { val commonMain by getting { dependencies { api(project(":workflow-core")) + api(libs.compose.runtime) api(libs.kotlinx.coroutines.core) + implementation(libs.molecule.runtime) } } val commonTest by getting { diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt index 476faefd3..b05352f8b 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt @@ -1,5 +1,7 @@ package com.squareup.workflow1 +import androidx.compose.runtime.BroadcastFrameClock +import app.cash.molecule.launchMolecule import com.squareup.workflow1.internal.WorkflowRunner import com.squareup.workflow1.internal.chained import kotlinx.coroutines.CancellationException @@ -11,6 +13,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlinx.coroutines.yield /** * Launches the [workflow] in a new coroutine in [scope] and returns a [StateFlow] of its @@ -116,24 +120,41 @@ public fun renderWorkflowIn( val runner = WorkflowRunner(scope, workflow, props, initialSnapshot, chainedInterceptor, runtimeConfig) + var composeWaitingForFrame = false + val composeRuntimeClock = BroadcastFrameClock { + composeWaitingForFrame = true + } + // Rendering is synchronous, so we can run the first render pass before launching the runtime // coroutine to calculate the initial rendering. - val renderingsAndSnapshots = MutableStateFlow( - try { - runner.nextRendering() - } catch (e: Throwable) { - // If any part of the workflow runtime fails, the scope should be cancelled. We're not in a - // coroutine yet however, so if the first render pass fails it won't cancel the runtime, - // but this is an implementation detail so we must cancel the scope manually to keep the - // contract. - val cancellation = - (e as? CancellationException) ?: CancellationException("Workflow runtime failed", e) - runner.cancelRuntime(cancellation) - throw e + val renderingsAndSnapshots = if (runtimeConfig.useComposeInRuntime) { + val clockedScope = scope + composeRuntimeClock + + clockedScope.launchMolecule { + runner.nextComposedRendering() } - ) + } else { + MutableStateFlow( + try { + runner.nextRendering() + } catch (e: Throwable) { + // If any part of the workflow runtime fails, the scope should be cancelled. We're not in a + // coroutine yet however, so if the first render pass fails it won't cancel the runtime, + // but this is an implementation detail so we must cancel the scope manually to keep the + // contract. + val cancellation = + (e as? CancellationException) ?: CancellationException("Workflow runtime failed", e) + runner.cancelRuntime(cancellation) + throw e + } + ) + } scope.launch { + // if (runtimeConfig.useComposeInRuntime) { + // //synchronous first render. + // renderSignal.emit(Unit) + // } while (isActive) { // It might look weird to start by consuming the output before getting the rendering below, // but remember the first render pass already occurred above, before this coroutine was even @@ -146,10 +167,23 @@ public fun renderWorkflowIn( // After receiving an output, the next render pass must be done before emitting that output, // so that the workflow states appear consistent to observers of the outputs and renderings. - renderingsAndSnapshots.value = runner.nextRendering() + if (runtimeConfig.useComposeInRuntime) { + if (composeWaitingForFrame) { + composeWaitingForFrame = false + composeRuntimeClock.sendFrame(0L) + yield() + } + } else { + (renderingsAndSnapshots as MutableStateFlow).value = runner.nextRendering() + } output?.let { onOutput(it.value) } } } return renderingsAndSnapshots } + +internal class SignalHolder { + internal var signal: (() -> Unit)? = null +} + diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt index b9e5f3435..478f251b4 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt @@ -19,18 +19,24 @@ public annotation class WorkflowExperimentalRuntime * A specification of the Workflow Runtime. */ public sealed interface RuntimeConfig { + public val useComposeInRuntime: Boolean /** * This version of the runtime will process as many actions as possible after one is received * until [frameTimeoutMs] has passed, at which point it will render(). */ @WorkflowExperimentalRuntime - public data class FrameTimeout(public val frameTimeoutMs: Long = 30L) : RuntimeConfig + public data class FrameTimeout( + public val frameTimeoutMs: Long = 30L, + public override val useComposeInRuntime: Boolean = false + ) : RuntimeConfig /** * This is the baseline runtime which will process one action at a time, calling render() after * each one. */ - public object RenderPerAction : RuntimeConfig + public object RenderPerAction : RuntimeConfig { + override val useComposeInRuntime: Boolean = false + } public companion object { public val DEFAULT_CONFIG: RuntimeConfig = RenderPerAction diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt index 2b6e62433..8384830ba 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import kotlinx.coroutines.CoroutineScope @@ -48,6 +49,18 @@ public open class SimpleLoggingWorkflowInterceptor : WorkflowInterceptor { proceed(renderProps, renderState, SimpleLoggingContextInterceptor(session)) } + @Composable + override fun Rendering( + renderProps: P, + renderState: S, + hoistRendering: @Composable (R) -> Unit, + context: BaseRenderContext, + session: WorkflowSession, + proceed: @Composable (P, S, RenderContextInterceptor?, @Composable (R) -> Unit) -> Unit + ): Unit = logMethod("onRender", session) { + proceed(renderProps, renderState, SimpleLoggingContextInterceptor(session), hoistRendering) + } + override fun onSnapshotState( state: S, proceed: (S) -> Snapshot?, 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 6cc3c808e..8748fedfb 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import kotlinx.coroutines.CoroutineScope @@ -98,6 +99,16 @@ public interface WorkflowInterceptor { session: WorkflowSession ): R = proceed(renderProps, renderState, null) + @Composable + public fun Rendering( + renderProps: P, + renderState: S, + hoistRendering: @Composable (R) -> Unit, + context: BaseRenderContext, + session: WorkflowSession, + proceed: @Composable (P, S, RenderContextInterceptor?, @Composable (R) -> Unit) -> Unit + ): Unit = proceed(renderProps, renderState, null, hoistRendering) + /** * Intercepts calls to [StatefulWorkflow.snapshotState]. */ @@ -226,6 +237,22 @@ public interface WorkflowInterceptor { handler: (CO) -> WorkflowAction ) -> CR ): CR = proceed(child, childProps, key, handler) + + @Composable + public fun ChildRendering( + child: Workflow, + childProps: CP, + key: String, + hoistRendering: @Composable (CR) -> Unit, + handler: (CO) -> WorkflowAction, + proceed: @Composable ( + child: Workflow, + childProps: CP, + key: String, + hoistRendering: @Composable (CR) -> Unit, + handler: (CO) -> WorkflowAction + ) -> Unit + ): Unit = proceed(child, childProps, key, hoistRendering, handler) } } @@ -270,6 +297,35 @@ internal fun WorkflowInterceptor.intercept( session = workflowSession, ) + @Composable + override fun Rendering( + renderProps: P, + renderState: S, + context: RenderContext, + hoistRendering: @Composable (rendering: R) -> Unit + ) { + Rendering( + renderProps = renderProps, + renderState = renderState, + hoistRendering = hoistRendering, + context = context, + session = workflowSession, + proceed = @Composable { props: P, + state: S, + interceptor: RenderContextInterceptor?, + hoistRenderingOriginal: @Composable (rendering: R) -> Unit -> + val interceptedContext = interceptor?.let { InterceptedRenderContext(context, it) } + ?: context + workflow.Rendering( + props, + state, + RenderContext(interceptedContext, this), + hoistRenderingOriginal + ) + } + ) + } + override fun snapshotState(state: S) = onSnapshotState(state, workflow::snapshotState, workflowSession) @@ -299,6 +355,24 @@ private class InterceptedRenderContext( baseRenderContext.renderChild(iChild, iProps, iKey, iHandler) } + @Composable + override fun ChildRendering( + child: Workflow, + props: ChildPropsT, + key: String, + hoistRendering: @Composable (ChildRenderingT) -> Unit, + handler: (ChildOutputT) -> WorkflowAction + ): Unit = + interceptor.ChildRendering( + child, + props, + key, + hoistRendering, + handler + ) @Composable { iChild, iProps, iKey, iHoistRendering, iHandler -> + baseRenderContext.ChildRendering(iChild, iProps, iKey, iHoistRendering, iHandler) + } + override fun runningSideEffect( key: String, sideEffect: suspend CoroutineScope.() -> Unit diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowRuntimeClock.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowRuntimeClock.kt new file mode 100644 index 000000000..f5145a119 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowRuntimeClock.kt @@ -0,0 +1,81 @@ +package com.squareup.workflow1 + +import androidx.compose.runtime.MonotonicFrameClock +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + +/** + * Could use [PausableMonotonicFrameClock] but we'd need to wrap that around something. + */ +internal class WorkflowRuntimeClock( + private var workflowFrameLatch: Latch +) : MonotonicFrameClock { + override suspend fun withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R { + workflowFrameLatch.await() + return onFrame(0L) // frame time not used in Compose runtime. + } +} + +/** + * Class internal to androidx.compose.runtime. Useful here! + */ +internal class Latch { + + private val lock = Any() + private var awaiters = mutableListOf>() + private var spareList = mutableListOf>() + + private var _isOpen = true + val isOpen get() = synchronized(lock) { _isOpen } + + inline fun withClosed(block: () -> R): R { + closeLatch() + return try { + block() + } finally { + openLatch() + } + } + + fun closeLatch() { + synchronized(lock) { + _isOpen = false + } + } + + fun openLatch() { + synchronized(lock) { + if (isOpen) return + + // Rotate the lists so that if a resumed continuation on an immediate dispatcher + // bound to the thread calling openLatch immediately awaits again we don't disrupt + // iteration of resuming the rest. This is also why we set isClosed before resuming. + val toResume = awaiters + awaiters = spareList + spareList = toResume + _isOpen = true + + for (i in 0 until toResume.size) { + toResume[i].resume(Unit) + } + toResume.clear() + } + } + + suspend fun await() { + if (isOpen) return + + suspendCancellableCoroutine { co -> + synchronized(lock) { + awaiters.add(co) + } + + co.invokeOnCancellation { + synchronized(lock) { + awaiters.remove(co) + } + } + } + } +} 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 82493b0c8..a7ac903cf 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 @@ -1,5 +1,6 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.Snapshot @@ -81,6 +82,39 @@ internal class ChainedWorkflowInterceptor( return chainedProceed(renderProps, renderState, null) } + @Composable + override fun Rendering( + renderProps: P, + renderState: S, + hoistRendering: @Composable (R) -> Unit, + context: BaseRenderContext, + session: WorkflowSession, + proceed: @Composable (P, S, RenderContextInterceptor?, @Composable (R) -> Unit) -> Unit + ) { + val chainedProceed = interceptors.foldRight(proceed) { workflowInterceptor, proceedAcc -> + { props, state, outerContextInterceptor, outerHoistRendering -> + // Holding compiler's hand for function type. + val proceedInternal = + @Composable { p: P, + s: S, + innerContextInterceptor: RenderContextInterceptor?, + innerHoistRendering: @Composable (R) -> Unit -> + val contextInterceptor = outerContextInterceptor.wrap(innerContextInterceptor) + proceedAcc(p, s, contextInterceptor, innerHoistRendering) + } + workflowInterceptor.Rendering( + props, + state, + outerHoistRendering, + context, + proceed = proceedInternal, + session = session, + ) + } + } + chainedProceed(renderProps, renderState, null, hoistRendering) + } + override fun onSnapshotState( state: S, proceed: (S) -> Snapshot?, @@ -129,6 +163,31 @@ internal class ChainedWorkflowInterceptor( inner.onRenderChild(c, p, k, h, proceed) } + @Composable + override fun ChildRendering( + child: Workflow, + childProps: CP, + key: String, + hoistRendering: @Composable (CR) -> Unit, + handler: (CO) -> WorkflowAction, + proceed: @Composable ( + child: Workflow, + childProps: CP, + key: String, + onRender: @Composable (CR) -> Unit, + handler: (CO) -> WorkflowAction + ) -> Unit + ): Unit = + outer.ChildRendering( + child, + childProps, + key, + hoistRendering, + handler + ) @Composable { c, p, k, o, h -> + inner.ChildRendering(c, p, k, o, h, proceed) + } + override fun onRunningSideEffect( key: String, sideEffect: suspend () -> Unit, 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 fdc42ecc7..e323dd8bc 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 @@ -2,6 +2,7 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.Sink import com.squareup.workflow1.Workflow @@ -22,6 +23,15 @@ internal class RealRenderContext( key: String, handler: (ChildOutputT) -> WorkflowAction ): ChildRenderingT + + @Composable + fun Rendering( + child: Workflow, + props: ChildPropsT, + key: String, + hoistRendering: @Composable (ChildRenderingT) -> Unit, + handler: (ChildOutputT) -> WorkflowAction + ): Unit } interface SideEffectRunner { @@ -61,6 +71,18 @@ internal class RealRenderContext( return renderer.render(child, props, key, handler) } + @Composable + override fun ChildRendering( + child: Workflow, + props: ChildPropsT, + key: String, + hoistRendering: @Composable (ChildRenderingT) -> Unit, + handler: (ChildOutputT) -> WorkflowAction + ) { + checkNotFrozen() + return renderer.Rendering(child, props, key, hoistRendering, handler) + } + override fun runningSideEffect( key: String, sideEffect: suspend CoroutineScope.() -> Unit @@ -77,6 +99,10 @@ internal class RealRenderContext( frozen = true } + fun freezeIdempotently() { + frozen = true + } + private fun checkNotFrozen() = check(!frozen) { "RenderContext cannot be used after render method returns." } diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt index 434b16c19..b73dfa187 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable import com.squareup.workflow1.ActionProcessingResult import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.TreeSnapshot @@ -114,6 +115,39 @@ internal class SubtreeManager( key: String, handler: (ChildOutputT) -> WorkflowAction ): ChildRenderingT { + val stagedChild = prepareStagedChild( + child, + props, + key, + handler + ) + return stagedChild.render(child.asStatefulWorkflow(), props) + } + + @Composable + override fun Rendering( + child: Workflow, + props: ChildPropsT, + key: String, + hoistRendering: @Composable (ChildRenderingT) -> Unit, + handler: (ChildOutputT) -> WorkflowAction + ) { + // TODO: Check on this side effect + val stagedChild = prepareStagedChild( + child, + props, + key, + handler + ) + stagedChild.Rendering(child.asStatefulWorkflow(), props, hoistRendering) + } + + private fun prepareStagedChild( + child: Workflow, + props: ChildPropsT, + key: String, + handler: (ChildOutputT) -> WorkflowAction + ): WorkflowChildNode<*, *, *, *, *> { // Prevent duplicate workflows with the same key. children.forEachStaging { require(!(it.matches(child, key))) { @@ -127,7 +161,7 @@ internal class SubtreeManager( create = { createChildNode(child, props, key, handler) } ) stagedChild.setHandler(handler) - return stagedChild.render(child.asStatefulWorkflow(), props) + return stagedChild } /** diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt index 6249da899..f701de085 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt @@ -5,3 +5,8 @@ package com.squareup.workflow1.internal * to use after we have processed some actions. Use this milliseconds since epoch for that. */ internal expect fun currentTimeMillis(): Long + +/** + * Current time in nanoseconds. + */ +internal expect fun nanoTime(): Long diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowChildNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowChildNode.kt index b6a833a95..953aa7606 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowChildNode.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowChildNode.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowAction @@ -58,6 +59,19 @@ internal class WorkflowChildNode< ) as R } + @Composable + fun Rendering( + workflow: StatefulWorkflow<*, *, *, *>, + props: Any?, + hoistRendering: @Composable (R) -> Unit + ) { + @Suppress("UNCHECKED_CAST") + workflowNode.Rendering( + workflow as StatefulWorkflow, + props as ChildPropsT + ) { rendering: Any? -> hoistRendering(rendering as R) } + } + /** * Wrapper around [handler] that allows calling it with erased types. */ 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 db4921d8c..ed240d1d8 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 @@ -1,5 +1,8 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import com.squareup.workflow1.ActionProcessingResult import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.RenderContext @@ -77,7 +80,7 @@ internal class WorkflowNode( init { interceptor.onSessionStarted(this, this) - state = interceptor.intercept(workflow, this) + state = interceptor.intercept(workflow = workflow, workflowSession = this) .initialState(initialProps, snapshot?.workflowSnapshot) } @@ -103,11 +106,25 @@ internal class WorkflowNode( ): RenderingT = renderWithStateType(workflow as StatefulWorkflow, input) + @Suppress("UNCHECKED_CAST") + @Composable + fun Rendering( + workflow: StatefulWorkflow, + input: PropsT, + hoistRendering: @Composable (RenderingT) -> Unit + ): Unit = + RenderingWithStateType( + workflow as StatefulWorkflow, + input, + hoistRendering + ) + /** * Walk the tree of state machines again, this time gathering snapshots and aggregating them * automatically. */ fun snapshot(workflow: StatefulWorkflow<*, *, *, *>): TreeSnapshot { + // TODO: Figure out how to use `rememberSaveable` for Compose runtime here. @Suppress("UNCHECKED_CAST") val typedWorkflow = workflow as StatefulWorkflow val childSnapshots = subtreeManager.createChildSnapshots() @@ -189,14 +206,43 @@ internal class WorkflowNode( .render(props, state, RenderContext(context, workflow)) context.freeze() + commitAndUpdateScopes() + + return rendering + } + + @Composable + private fun RenderingWithStateType( + workflow: StatefulWorkflow, + props: PropsT, + hoistRendering: @Composable (RenderingT) -> Unit + ) { + val composedState = remember(state, props) { + updatePropsAndState(workflow, props) + mutableStateOf(state) + } + + val context = RealRenderContext( + renderer = subtreeManager, + sideEffectRunner = this, + eventActionsChannel = eventActionsChannel + ) + interceptor.intercept(workflow, this) + .Rendering(props, composedState.value, RenderContext(context, workflow), hoistRendering) + + context.freezeIdempotently() + + // Idempotent (I'm pretty sure?) + commitAndUpdateScopes() + } + + private fun commitAndUpdateScopes() { // Tear down workflows and workers that are obsolete. subtreeManager.commitRenderedChildren() // Side effect jobs are launched lazily, since they can send actions to the sink, and can only // be started after context is frozen. sideEffects.forEachStaging { it.job.start() } sideEffects.commitStaging { it.job.cancel() } - - return rendering } private fun updatePropsAndState( diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt index 9a7e70fec..0e6d6215d 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt @@ -1,5 +1,12 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import com.squareup.workflow1.ActionProcessingResult import com.squareup.workflow1.PropsUpdated import com.squareup.workflow1.RenderingAndSnapshot @@ -71,6 +78,22 @@ internal class WorkflowRunner( return RenderingAndSnapshot(rendering, snapshot) } + @Composable + fun nextComposedRendering(): RenderingAndSnapshot { + val composedProps = rememberUpdatedState(currentProps) + + val rendering: MutableState = remember { + mutableStateOf(null) + } + + // First `hoistRendering` call will happen synchronously + rootNode.Rendering(workflow, composedProps.value, hoistRendering = { rendering.value = it }) + + // TODO: Stop doing this as a side effect here and move snapshotting into @Composables + val snapshot = rootNode.snapshot(workflow) + return RenderingAndSnapshot(rendering.value!!, snapshot) + } + /** * Stop processing and go to render on 1 of 3 conditions: * 1. Props have changed. 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 eafd9cb17..39e236d05 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptorTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptorTest.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancel @@ -97,6 +98,17 @@ internal class SimpleLoggingWorkflowInterceptorTest { fail() } + @Composable + override fun ChildRendering( + child: Workflow, + props: ChildPropsT, + key: String, + hoistRendering: @Composable (ChildRenderingT) -> Unit, + handler: (ChildOutputT) -> WorkflowAction + ) { + fail() + } + override fun runningSideEffect( key: String, sideEffect: suspend CoroutineScope.() -> Unit 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 f53335429..5f581b842 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowInterceptorTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowInterceptorTest.kt @@ -2,6 +2,7 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import kotlinx.coroutines.CoroutineScope @@ -62,6 +63,17 @@ internal class WorkflowInterceptorTest { handler: (ChildOutputT) -> WorkflowAction ): ChildRenderingT = fail() + @Composable + override fun ChildRendering( + child: Workflow, + props: ChildPropsT, + key: String, + hoistRendering: @Composable (ChildRenderingT) -> Unit, + handler: (ChildOutputT) -> WorkflowAction + ) { + fail() + } + override fun runningSideEffect( key: String, sideEffect: suspend CoroutineScope.() -> Unit @@ -102,6 +114,17 @@ internal class WorkflowInterceptorTest { handler: (ChildOutputT) -> WorkflowAction ): ChildRenderingT = fail() + @Composable + override fun ChildRendering( + child: Workflow, + props: ChildPropsT, + key: String, + hoistRendering: @Composable (ChildRenderingT) -> Unit, + handler: (ChildOutputT) -> WorkflowAction + ) { + fail() + } + override fun runningSideEffect( key: String, sideEffect: suspend CoroutineScope.() -> Unit @@ -135,6 +158,17 @@ internal class WorkflowInterceptorTest { handler: (ChildOutputT) -> WorkflowAction ): ChildRenderingT = fail() + @Composable + override fun ChildRendering( + child: Workflow, + props: ChildPropsT, + key: String, + hoistRendering: @Composable (ChildRenderingT) -> Unit, + handler: (ChildOutputT) -> WorkflowAction + ) { + fail() + } + override fun runningSideEffect( key: String, sideEffect: suspend CoroutineScope.() -> Unit @@ -194,6 +228,17 @@ internal class WorkflowInterceptorTest { handler: (ChildOutputT) -> WorkflowAction ): ChildRenderingT = fail() + @Composable + override fun ChildRendering( + child: Workflow, + props: ChildPropsT, + key: String, + hoistRendering: @Composable (ChildRenderingT) -> Unit, + handler: (ChildOutputT) -> WorkflowAction + ) { + fail() + } + override fun runningSideEffect( key: String, sideEffect: suspend CoroutineScope.() -> Unit 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 896f77570..b033c2861 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 @@ -2,6 +2,7 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.Sink @@ -308,6 +309,17 @@ internal class ChainedWorkflowInterceptorTest { fail() } + @Composable + override fun ChildRendering( + child: Workflow, + props: ChildPropsT, + key: String, + hoistRendering: @Composable (ChildRenderingT) -> Unit, + handler: (ChildOutputT) -> WorkflowAction + ) { + fail() + } + override fun runningSideEffect( key: String, sideEffect: suspend CoroutineScope.() -> Unit 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 0162798b5..fc30e7980 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 @@ -2,6 +2,7 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.Workflow @@ -47,6 +48,25 @@ internal class RealRenderContextTest { key, handler as (Any) -> WorkflowAction ) as ChildRenderingT + + @Suppress("UNCHECKED_CAST") + @Composable + override fun Rendering( + child: Workflow, + props: ChildPropsT, + key: String, + hoistRendering: @Composable (ChildRenderingT) -> Unit, + handler: (ChildOutputT) -> WorkflowAction + ) { + hoistRendering( + Rendering( + child, + props, + key, + handler as (Any) -> WorkflowAction + ) as ChildRenderingT + ) + } } private class TestRunner : SideEffectRunner { @@ -82,6 +102,15 @@ internal class RealRenderContextTest { key: String, handler: (ChildOutputT) -> WorkflowAction ): ChildRenderingT = fail() + + @Composable + override fun Rendering( + child: Workflow, + props: ChildPropsT, + key: String, + hoistRendering: @Composable (ChildRenderingT) -> Unit, + handler: (ChildOutputT) -> WorkflowAction + ): Unit = fail() } private class PoisonRunner : SideEffectRunner { diff --git a/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt b/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt index 354e7ef9c..b41392a09 100644 --- a/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt +++ b/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/SystemUtils.kt @@ -1,3 +1,5 @@ package com.squareup.workflow1.internal internal actual fun currentTimeMillis(): Long = System.currentTimeMillis() + +internal actual fun nanoTime(): Long = System.nanoTime() diff --git a/workflow-testing/api/workflow-testing.api b/workflow-testing/api/workflow-testing.api index 940ad14b6..2220f8e26 100644 --- a/workflow-testing/api/workflow-testing.api +++ b/workflow-testing/api/workflow-testing.api @@ -1,6 +1,7 @@ public final class com/squareup/workflow1/testing/RealRenderTester : com/squareup/workflow1/testing/RenderTester, com/squareup/workflow1/BaseRenderContext, com/squareup/workflow1/Sink, com/squareup/workflow1/testing/RenderTestResult { public fun (Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;Ljava/lang/Object;Ljava/util/List;Ljava/util/List;ZLcom/squareup/workflow1/WorkflowAction;Ljava/util/List;Ljava/util/List;)V public synthetic fun (Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;Ljava/lang/Object;Ljava/util/List;Ljava/util/List;ZLcom/squareup/workflow1/WorkflowAction;Ljava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun ChildRendering (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V public fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function10;)Lkotlin/jvm/functions/Function9; public fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function11;)Lkotlin/jvm/functions/Function10; public fun eventHandler (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; @@ -91,6 +92,7 @@ public final class com/squareup/workflow1/testing/RealRenderTesterKt { public final class com/squareup/workflow1/testing/RenderIdempotencyChecker : com/squareup/workflow1/WorkflowInterceptor { public static final field INSTANCE Lcom/squareup/workflow1/testing/RenderIdempotencyChecker; + public fun Rendering (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/BaseRenderContext;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;Lkotlin/jvm/functions/Function4;)V public fun onInitialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;Lkotlin/jvm/functions/Function2;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; 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 4e230c57a..1ed0a0a24 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 @@ -1,5 +1,6 @@ package com.squareup.workflow1.testing +import androidx.compose.runtime.Composable import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.RenderContext import com.squareup.workflow1.Sink @@ -221,6 +222,69 @@ internal class RealRenderTester( return match.childRendering as ChildRenderingT } + @Composable + override fun ChildRendering( + child: Workflow, + props: ChildPropsT, + key: String, + hoistRendering: @Composable (ChildRenderingT) -> Unit, + handler: (ChildOutputT) -> WorkflowAction + ) { + val identifierPair = Pair(child.identifier, key) + require(identifierPair !in renderedChildren) { + "Expected keys to be unique for ${child.identifier}: key=\"$key\"" + } + renderedChildren += identifierPair + + val description = buildString { + append("child ") + append(child.identifier) + if (key.isNotEmpty()) { + append(" with key \"$key\"") + } + } + val invocation = createRenderChildInvocation(child, props, key) + val matches = expectations.filterIsInstance() + .mapNotNull { + val matchResult = it.matcher(invocation) + if (matchResult is Matched) Pair(it, matchResult) else null + } + if (matches.isEmpty()) { + throw AssertionError("Tried to render unexpected $description") + } + + val exactMatches = matches.filter { it.first.exactMatch } + val (_, match) = when { + exactMatches.size == 1 -> { + exactMatches.single() + .also { (expected, _) -> + expectations -= expected + consumedExpectations += expected + } + } + exactMatches.size > 1 -> { + throw AssertionError( + "Multiple expectations matched $description:\n" + + exactMatches.joinToString(separator = "\n") { " ${it.first.describe()}" } + ) + } + // Inexact matches are not consumable. + else -> matches.first() + } + + if (match.output != null) { + check(processedAction == null) { + "Expected only one output to be expected: $description expected to emit " + + "${match.output.value} but $processedAction was already processed." + } + @Suppress("UNCHECKED_CAST") + processedAction = handler(match.output.value as ChildOutputT) + } + + @Suppress("UNCHECKED_CAST") + hoistRendering(match.childRendering as ChildRenderingT) + } + override fun runningSideEffect( key: String, sideEffect: suspend CoroutineScope.() -> Unit diff --git a/workflow-tracing/api/workflow-tracing.api b/workflow-tracing/api/workflow-tracing.api index bdce09a3c..4732d4757 100644 --- a/workflow-tracing/api/workflow-tracing.api +++ b/workflow-tracing/api/workflow-tracing.api @@ -21,6 +21,7 @@ public final class com/squareup/workflow1/diagnostic/tracing/TracingWorkflowInte public fun (Lcom/squareup/workflow1/diagnostic/tracing/MemoryStats;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V public fun (Lcom/squareup/workflow1/diagnostic/tracing/MemoryStats;Lkotlin/jvm/functions/Function2;)V public synthetic fun (Lcom/squareup/workflow1/diagnostic/tracing/MemoryStats;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Rendering (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/BaseRenderContext;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;Lkotlin/jvm/functions/Function4;)V public fun onInitialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;Lkotlin/jvm/functions/Function2;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; diff --git a/workflow-ui/compose-tooling/build.gradle.kts b/workflow-ui/compose-tooling/build.gradle.kts index f7ffc1e37..d7906a0a9 100644 --- a/workflow-ui/compose-tooling/build.gradle.kts +++ b/workflow-ui/compose-tooling/build.gradle.kts @@ -11,7 +11,7 @@ plugins { android { buildFeatures.compose = true composeOptions { - kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } } diff --git a/workflow-ui/compose/build.gradle.kts b/workflow-ui/compose/build.gradle.kts index ee921395c..468b71a81 100644 --- a/workflow-ui/compose/build.gradle.kts +++ b/workflow-ui/compose/build.gradle.kts @@ -11,7 +11,7 @@ plugins { android { buildFeatures.compose = true composeOptions { - kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } }