Skip to content

Commit

Permalink
more sketching the impl
Browse files Browse the repository at this point in the history
  • Loading branch information
zach-klippenstein committed Feb 28, 2025
1 parent c007b73 commit 2abe340
Show file tree
Hide file tree
Showing 11 changed files with 303 additions and 107 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,9 @@
package com.squareup.workflow1

import androidx.compose.runtime.Composable
import androidx.compose.runtime.Composition
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.MonotonicFrameClock
import androidx.compose.runtime.Recomposer
import androidx.compose.runtime.mutableStateOf
import com.squareup.workflow1.WorkflowAction.Companion.noAction
import com.squareup.workflow1.compose.WorkflowComposable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.launch
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.jvm.JvmMultifileClass
import kotlin.jvm.JvmName
import kotlin.reflect.KType
Expand Down Expand Up @@ -99,37 +92,12 @@ public interface BaseRenderContext<out PropsT, StateT, in OutputT> {
* invalidated, this workflow will be re-rendered and the [content] recomposed to return its new
* value.
*
* @see ComposeWorkflow
* @see com.squareup.workflow1.compose.ComposeWorkflow
*/
public fun <ChildRenderingT> renderComposable(
key: String = "",
content: @WorkflowComposable @Composable () -> ChildRenderingT
): ChildRenderingT {
val renderer: WorkflowComposableRenderer = TODO()
val frameClock: MonotonicFrameClock = TODO()
val coroutineContext = EmptyCoroutineContext + frameClock
val recomposer = Recomposer(coroutineContext)
val composition = Composition(UnitApplier, recomposer)

// TODO I think we need more than a simple UNDISPATCHED start to make this work – we have to
// pump the dispatcher until the composition is finished.
CoroutineScope(coroutineContext).launch(start = CoroutineStart.UNDISPATCHED) {
try {
recomposer.runRecomposeAndApplyChanges()
} finally {
composition.dispose()
}
}

val rendering = mutableStateOf<ChildRenderingT?>(null)
composition.setContent {
CompositionLocalProvider(LocalWorkflowComposableRenderer provides renderer) {
rendering.value = content()
}
}
@Suppress("UNCHECKED_CAST")
return rendering.value as ChildRenderingT
}
): ChildRenderingT

/**
* Ensures [sideEffect] is running with the given [key].
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package com.squareup.workflow1
package com.squareup.workflow1.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import com.squareup.workflow1.BaseRenderContext
import com.squareup.workflow1.StatefulWorkflow
import com.squareup.workflow1.Workflow
import com.squareup.workflow1.WorkflowAction

/**
* A [Workflow]-like interface that participates in a workflow tree via its [Rendering] composable.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.squareup.workflow1
package com.squareup.workflow1.compose

import androidx.compose.runtime.ComposableTargetMarker
import kotlin.annotation.AnnotationRetention.BINARY
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package com.squareup.workflow1.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import com.squareup.workflow1.BaseRenderContext
import com.squareup.workflow1.Workflow

/**
* Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or
* [BaseRenderContext.renderComposable]) and returns its rendering.
*
* @param onOutput An optional function that, if non-null, will be called when the child emits an
* output. If null, the child's outputs will be ignored.
*/
@WorkflowComposable
@Composable
fun <ChildPropsT, ChildOutputT, ChildRenderingT> renderChild(
workflow: Workflow<ChildPropsT, ChildOutputT, ChildRenderingT>,
props: ChildPropsT,
onOutput: ((ChildOutputT) -> Unit)?
): ChildRenderingT {
val host = LocalWorkflowCompositionHost.current
return host.renderChild(workflow, props, onOutput)
}

/**
* Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or
* [BaseRenderContext.renderComposable]) and returns its rendering.
*
* @param onOutput An optional function that, if non-null, will be called when the child emits an
* output. If null, the child's outputs will be ignored.
*/
@WorkflowComposable
@Composable
inline fun <ChildPropsT, ChildRenderingT> renderChild(
workflow: Workflow<ChildPropsT, Nothing, ChildRenderingT>,
props: ChildPropsT,
): ChildRenderingT = renderChild(workflow, props, onOutput = null)

/**
* Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or
* [BaseRenderContext.renderComposable]) and returns its rendering.
*
* @param onOutput An optional function that, if non-null, will be called when the child emits an
* output. If null, the child's outputs will be ignored.
*/
@WorkflowComposable
@Composable
inline fun <ChildOutputT, ChildRenderingT> renderChild(
workflow: Workflow<Unit, ChildOutputT, ChildRenderingT>,
noinline onOutput: ((ChildOutputT) -> Unit)?
): ChildRenderingT = renderChild(workflow, props = Unit, onOutput)

/**
* Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or
* [BaseRenderContext.renderComposable]) and returns its rendering.
*
* @param onOutput An optional function that, if non-null, will be called when the child emits an
* output. If null, the child's outputs will be ignored.
*/
@WorkflowComposable
@Composable
inline fun <ChildRenderingT> renderChild(
workflow: Workflow<Unit, Nothing, ChildRenderingT>,
): ChildRenderingT = renderChild(workflow, Unit, onOutput = null)

@WorkflowComposable
@Composable
fun <ChildPropsT, ChildOutputT, ChildRenderingT> renderChild(
workflow: ComposeWorkflow<ChildPropsT, ChildOutputT, ChildRenderingT>,
props: ChildPropsT,
handler: ((ChildOutputT) -> Unit)?
): ChildRenderingT {
val childRendering = remember { mutableStateOf<ChildRenderingT?>(null) }
// Since this function returns a value, it can't restart without also restarting its parent.
// IsolateRecomposeScope allows the subtree to restart and only restarts us if the rendering value
// actually changed.
RecomposeScopeIsolator(
child = workflow,
props = props,
handler = handler,
result = childRendering
)
@Suppress("UNCHECKED_CAST")
return childRendering.value as ChildRenderingT
}

@Composable
private fun <PropsT, OutputT, RenderingT> RecomposeScopeIsolator(
child: ComposeWorkflow<PropsT, OutputT, RenderingT>,
props: PropsT,
handler: ((OutputT) -> Unit)?,
result: MutableState<RenderingT>,
) {
result.value = child.Rendering(props, handler ?: {})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.squareup.workflow1.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.Stable
import androidx.compose.runtime.staticCompositionLocalOf
import com.squareup.workflow1.Workflow

// TODO @InternalWorkflowApi
public val LocalWorkflowCompositionHost: ProvidableCompositionLocal<WorkflowCompositionHost> =
staticCompositionLocalOf { error("No WorkflowCompositionHost provided.") }

/**
* Represents the owner of this [WorkflowComposable] composition.
*/
// TODO move these into a separate, internal-only, implementation-depended-on module to hide from
// consumers by default?
// TODO @InternalWorkflowApi
@Stable
public interface WorkflowCompositionHost {

/**
* Renders a child [Workflow] and returns its rendering. See the top-level composable
* [com.squareup.workflow1.compose.renderChild] for main documentation.
*/
@Composable
public fun <ChildPropsT, ChildOutputT, ChildRenderingT> renderChild(
workflow: Workflow<ChildPropsT, ChildOutputT, ChildRenderingT>,
props: ChildPropsT,
onOutput: ((ChildOutputT) -> Unit)?
): ChildRenderingT
}
7 changes: 7 additions & 0 deletions workflow-runtime/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ kotlin {
if (targets == "kmp" || targets == "js") {
js(IR) { browser() }
}
sourceSets {
getByName("commonMain") {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
}
}
}
}

dependencies {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.squareup.workflow1.internal

import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.RecomposeScope
import androidx.compose.runtime.RememberObserver
import com.squareup.workflow1.WorkflowAction
import com.squareup.workflow1.action
import kotlinx.coroutines.CoroutineScope

internal class ComposedWorkflowChild<ChildOutputT, ParentPropsT, ParentOutputT, ParentRenderingT>(
compositeHashKey: Int,
private val coroutineScope: CoroutineScope,
private val compositionContext: CompositionContext,
private val recomposeScope: RecomposeScope
) : RememberObserver {
val workflowKey: String = "composed-workflow:${compositeHashKey.toString(radix = 16)}"
private var disposed = false

var onOutput: ((ChildOutputT) -> Unit)? = null
val handler: (ChildOutputT) -> WorkflowAction<ParentPropsT, ParentOutputT, ParentRenderingT> =
{ output ->
action(workflowKey) {
// This action is being applied to the composition host workflow, which we don't want to
// update at all.
// The onOutput callback instead will update any compose snapshot state required.
// Technically we could probably invoke it directly from the handler, not wait until the
// queued action is processed, but this ensures consistency with the rest of the workflow
// runtime: the callback won't fire before other callbacks ahead in the queue.
// We check disposed since a previous update may have caused a recomposition that removed
// this child from composition and since it doesn't have its own channel, we have to no-op.
if (!disposed) {
onOutput?.invoke(output)
}

// TODO After invoking callback, send apply notifications and check if composition has any
// invalidations. Iff it does, then mark the current workflow node as needing re-render
// regardless of state change.
}
}

override fun onAbandoned() {
onForgotten()
}

override fun onRemembered() {
}

override fun onForgotten() {
disposed = true
TODO("notify parent that we're gone")
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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
Expand All @@ -22,6 +23,11 @@ internal class RealRenderContext<out PropsT, StateT, OutputT>(
key: String,
handler: (ChildOutputT) -> WorkflowAction<PropsT, StateT, OutputT>
): ChildRenderingT

fun <ChildRenderingT> renderComposable(
key: String,
content: @Composable () -> ChildRenderingT
): ChildRenderingT
}

interface SideEffectRunner {
Expand Down Expand Up @@ -62,6 +68,14 @@ internal class RealRenderContext<out PropsT, StateT, OutputT>(
return renderer.render(child, props, key, handler)
}

override fun <ChildRenderingT> renderComposable(
key: String,
content: @Composable () -> ChildRenderingT
): ChildRenderingT {
checkNotFrozen()
return renderer.renderComposable(key, content)
}

override fun runningSideEffect(
key: String,
sideEffect: suspend CoroutineScope.() -> Unit
Expand Down
Loading

0 comments on commit 2abe340

Please sign in to comment.