All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.com.squareup.workflow1.RenderWorkflow.kt Maven / Gradle / Ivy

There is a newer version: 1.12.1-beta17
Show newest version
package com.squareup.workflow1

import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS
import com.squareup.workflow1.RuntimeConfigOptions.RENDER_ONLY_WHEN_STATE_CHANGES
import com.squareup.workflow1.internal.WorkflowRunner
import com.squareup.workflow1.internal.chained
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

/**
 * Launches the [workflow] in a new coroutine in [scope] and returns a [StateFlow] of its
 * [renderings][RenderingT] and [snapshots][Snapshot]. The workflow tree is seeded with
 * [initialSnapshot] and the current value value of [props]. Subsequent values emitted from [props]
 * will be used to re-render the workflow.
 *
 * This is the primary low-level entry point into the workflow runtime. If you are writing an app,
 * you should probably be using a higher-level entry point that will also let you define UI bindings
 * for your renderings.
 *
 * ## Initialization
 *
 * When this function is called, the workflow runtime is started immediately, before the function
 * even returns. The current value of the [props] [StateFlow] is used to perform the initial render
 * pass. The result of this render pass is used to initialize the [StateFlow] of renderings and
 * snapshots that is returned.
 *
 * Once the initial render pass is complete, the workflow runtime will continue executing in a new
 * coroutine launched in [scope].
 *
 * ## Scoping
 *
 * The workflow runtime makes use of
 * [structured concurrency](https://medium.com/@elizarov/structured-concurrency-722d765aa952).
 *
 * The runtime is started in [scope], which defines the context for the entire workflow tree – most
 * importantly, the [Job] that governs the runtime's lifetime and exception
 * reporting path, and the [CoroutineDispatcher][kotlinx.coroutines.CoroutineDispatcher] that
 * decides on what thread(s) to run workflow code. Note that if the scope's dispatcher executes
 * on threads different than the caller, then the initial render pass will occur on the current
 * thread but all subsequent render passes, and actions, will be executed on that dispatcher. This
 * shouldn't affect well-written workflows, since the render method should not perform side effects
 * anyway.
 *
 * All workers that are run by this runtime will be collected in coroutines that are children of
 * [scope]. When the root workflow emits an output, [onOutput] will be invoked in a child of
 * [scope].
 *
 * To stop the workflow runtime, simply cancel [scope]. Any running workers will be cancelled, and
 * if [onOutput] is currently running it will be cancelled as well.
 *
 * ## Error handling
 *
 * If the initial render pass throws an exception, that exception will be thrown from this function.
 * Any exceptions thrown from the runtime (and any workflows or workers) after that will bubble up
 * and be handled by [scope] (usually by cancelling it).
 *
 * Since the [onOutput] function is executed in [scope], any exceptions it throws will also bubble
 * up to [scope]. Any exceptions thrown by subscribers of the returned [StateFlow] will _not_ cancel
 * [scope] or cancel the runtime, but will be handled in the [CoroutineScope] of the subscriber.
 *
 * @param workflow
 * The root workflow to render.
 *
 * @param scope
 * The [CoroutineScope] in which to launch the workflow runtime. Any exceptions thrown
 * in any workflows, after the initial render pass, will be handled by this scope, and cancelling
 * this scope will cancel the workflow runtime and any running workers. Note that any dispatcher
 * in this scope will _not_ be used to execute the very first render pass.
 *
 * @param props
 * Specifies the initial [PropsT] to use to render the root workflow, and will cause a re-render
 * when new props are emitted. If this flow completes _after_ emitting at least one value, the
 * runtime will _not_ fail or stop, it will continue running with the last-emitted input.
 * To only pass a single props value, simply create a [MutableStateFlow] with the value.
 *
 * @param initialSnapshot
 * If not null or empty, used to restore the workflow. Should be obtained from a previous runtime's
 * [RenderingAndSnapshot].
 *
 * @param interceptors
 * An optional list of [WorkflowInterceptor]s that will wrap every workflow rendered by the runtime.
 * Interceptors will be invoked in 0-to-`length` order: the interceptor at index 0 will process the
 * workflow first, then the interceptor at index 1, etc.
 *
 * @param onOutput
 * A function that will be called whenever the root workflow emits an [OutputT]. This is a suspend
 * function, and is invoked synchronously within the runtime: if it suspends, the workflow runtime
 * will effectively be paused until it returns. This means that it will propagate backpressure if
 * used to forward outputs to a [Flow] or [Channel][kotlinx.coroutines.channels.Channel], for
 * example.
 *
 * @param runtimeConfig
 * Configuration parameters for the Workflow Runtime.
 *
 * @return
 * A [StateFlow] of [RenderingAndSnapshot]s that will emit any time the root workflow creates a new
 * rendering.
 */
@OptIn(WorkflowExperimentalRuntime::class)
public fun  renderWorkflowIn(
  workflow: Workflow,
  scope: CoroutineScope,
  props: StateFlow,
  initialSnapshot: TreeSnapshot? = null,
  interceptors: List = emptyList(),
  runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG,
  onOutput: suspend (OutputT) -> Unit
): StateFlow> {
  val chainedInterceptor = interceptors.chained()

  val runner =
    WorkflowRunner(scope, workflow, props, initialSnapshot, chainedInterceptor, runtimeConfig)

  // 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
    }
  )

  suspend fun  sendOutput(
    actionResult: ActionProcessingResult,
    onOutput: suspend (OutputT) -> Unit
  ) {
    when (actionResult) {
      is ActionApplied<*> -> {
        @Suppress("UNCHECKED_CAST")
        (actionResult as? ActionApplied)?.let {
          it.output?.let { actualOutput ->
            onOutput(actualOutput.value)
          }
        }
      }

      else -> {} // no -op
    }
  }

  /**
   * If [runtimeConfig] contains [RuntimeConfigOptions.RENDER_ONLY_WHEN_STATE_CHANGES] then
   * send any output, but return true which means restart the runtime loop and process another
   * action.
   */
  suspend fun shortCircuitForUnchangedState(actionResult: ActionProcessingResult): Boolean {
    if (runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES) &&
      actionResult is ActionApplied<*> && !actionResult.stateChanged
    ) {
      // Possibly send output and process more actions. No state change so no re-render.
      sendOutput(actionResult, onOutput)
      return true
    }
    return false
  }

  scope.launch {
    while (isActive) {
      // It might look weird to start by processing an action before getting the rendering below,
      // but remember the first render pass already occurred above, before this coroutine was even
      // launched.
      var actionResult: ActionProcessingResult = runner.processAction()

      if (shortCircuitForUnchangedState(actionResult)) continue

      // After resuming from runner.processAction() our coroutine could now be cancelled, check so
      // we don't surprise anyone with an unexpected rendering pass. Show's over, go home.
      if (!isActive) return@launch

      var nextRenderAndSnapshot: RenderingAndSnapshot = runner.nextRendering()

      if (runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) {
        // Only null will allow us to continue processing actions and conflating stale renderings.
        // If this is not null, then we had an Output and we want to send it with the Rendering
        // (stale or not).
        while (actionResult is ActionApplied<*> && actionResult.output == null) {
          // We have more actions we can process, so this rendering is stale.
          actionResult = runner.processAction(waitForAnAction = false)

          if (!isActive) return@launch

          // If no actions processed, then no new rendering needed.
          if (actionResult == ActionsExhausted) break

          nextRenderAndSnapshot = runner.nextRendering()
        }
      }

      // Pass on to the UI.
      renderingsAndSnapshots.value = nextRenderAndSnapshot
      // And emit the Output.
      sendOutput(actionResult, onOutput)
    }
  }

  return renderingsAndSnapshots
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy