LiveView Integration

View Source

< Handler Stacks | Up: Recipes | Index | Durable Computation >

AsyncPageMachine bridges effectful computations into Phoenix LiveView, enabling pausable page flows that can be tested without a LiveView process.

Why extract page state machines

LiveView tests are slow. Even with DoubleDown replacing the database sandbox (often a 250× speedup), the process mechanics dominate — mount, render, socket assigns, DOM diffs. The test time floor is set by the LiveView process itself.

But a LiveView page is a state machine. The Moore model maps directly:

Moore conceptLiveView
Statesocket.assigns
Transitionhandle_event(event, _, socket)
Output (UI)render(socket.assigns)

If you extract the state machine into a pure module — one that receives events and returns new state, with no LiveView dependency — you can test the page logic with plain assert. No process. No LiveViewTest. No DOM.

Use AsyncPageMachine + Yield to structure the state machine as an effectful computation. Test it deterministically with Coroutine. Wrap it in a thin LiveView module that delegates events.

Pattern

  1. Write the page flow as an effectful computation using Yield
  2. Test it with Coroutine — deterministic, no processes
  3. Wrap it in a thin LiveView module via AsyncPageMachine
  4. LiveView sends user events as resume values; yields update the UI

Example: checkout flow

A checkout with two external effects and two user interaction steps. Inventory reservation and order placement are typed effectful calls; shipping collection and payment collection are user yields. Branching is plain if.

First, the boundary contracts and the pure state machine:

defmodule MyApp.Orders do
  use Skuld.Effects.Port.EffectfulFacade

  defcallback place(cart :: Cart.t(), shipping :: map(), payment :: map()) ::
              {:ok, Order.t()} | {:error, term()}
end

defmodule MyApp.Inventory do
  use Skuld.Effects.Port.EffectfulFacade

  defcallback reserve(cart :: Cart.t()) :: {:ok, term()} | {:error, term()}
end

defmodule MyApp.CheckoutFlow do
  use Skuld.Syntax

  alias Skuld.Effects.Yield

  defcomp flow(cart) do
    # Effect: reserve inventory (external API, can fail)
    {:ok, _} <- MyApp.Inventory.reserve(cart)

    # User interaction: collect shipping
    {:ok, shipping} <- Yield.yield(:shipping)

    # User interaction: collect payment
    {:ok, payment} <- Yield.yield(:payment)

    # Effect: place order (external API, can fail)
    {:ok, order} <- MyApp.Orders.place(cart, shipping, payment)
    {:ok, order}
  else
    {:error, :sold_out} -> {:error, :sold_out}
    {:error, reason} -> {:error, reason}
    :cancelled -> {:error, :cancelled}
  end
end

The LiveView module — a thin shell. handle_info is generated by use AsyncPageMachine; the module only needs mount, handle_event, and render:

defmodule MyApp.CheckoutLive do
  use MyAppWeb, :live_view
  use Skuld.AsyncCoroutine.AsyncPageMachine,
    tag: :checkout,
    on_yield: &handle_yield/2,
    on_complete: &handle_complete/2,
    on_error: &handle_error/2,
    on_cancel: &handle_cancel/2

  @impl true
  def mount(_params, _session, socket) do
    flow =
      MyApp.CheckoutFlow.flow(socket.assigns.cart)
      |> Port.with_handler(%{
        MyApp.Inventory => MyApp.Inventory.Service,
        MyApp.Orders => MyApp.Orders.Ecto
      })

    {:ok, runner} = AsyncPageMachine.run(flow, :checkout)
    {:ok, assign(socket, runner: runner, step: :loading)}
  end

  defp handle_yield(step, socket) do
    {:noreply, assign(socket, step: step)}
  end

  defp handle_complete({:ok, order}, socket) do
    {:noreply, assign(socket, order: order, step: :done)}
  end

  defp handle_error(:sold_out, socket) do
    {:noreply, put_flash(socket, :error, "Sorry, this item is no longer available")}
  end

  defp handle_error(reason, socket) do
    {:noreply, put_flash(socket, :error, "Checkout failed: #{inspect(reason)}")}
  end

  defp handle_cancel(reason, socket) do
    {:noreply, push_navigate(socket, to: ~p"/cart")}
  end

  def handle_event("submit_shipping", %{"address" => addr}, socket) do
    AsyncPageMachine.run(socket.assigns.runner, {:ok, %{address: addr}})
    {:noreply, socket}
  end

  def handle_event("submit_payment", %{"payment" => payment}, socket) do
    AsyncPageMachine.run(socket.assigns.runner, {:ok, payment})
    {:noreply, socket}
  end

  @impl true
  def render(assigns) do
    case assigns.step do
      :loading -> ~H|<.spinner />|
      :shipping -> ~H|<.shipping_form myself={@myself} />|
      :payment -> ~H|<.payment_form myself={@myself} />|
      :done -> ~H|<.order_summary order={@order} />|
    end
  end
end

Testing without LiveViewTest

The state machine module has no LiveView dependency. Test it directly with Coroutine — deterministic, no processes, no stubs:

defmodule MyApp.CheckoutFlowTest do
  use ExUnit.Case, async: true

  alias Skuld.Comp.Env
  alias Skuld.Coroutine
  alias Skuld.Effects.Port
  alias Skuld.Effects.Throw
  alias Skuld.Effects.Yield

  setup do
    comp =
      MyApp.CheckoutFlow.flow(%Cart{items: [...]})
      |> Port.with_handler(%{
        MyApp.Inventory => fn _, :reserve, [_] -> {:ok, :reserved} end,
        MyApp.Orders => fn _, :place, _, _ -> {:ok, %Order{}} end
      })
      |> Yield.with_handler()
      |> Throw.with_handler()

    {:ok, comp: comp}
  end

  test "normal checkout flow", %{comp: comp} do
    fiber = comp |> Coroutine.new(Env.new()) |> Coroutine.run()
    assert %Coroutine.ExternalSuspended{value: :shipping} = fiber

    fiber = Coroutine.run(fiber, {:ok, %{address: "123 Main"}})
    assert %Coroutine.ExternalSuspended{value: :payment} = fiber

    fiber = Coroutine.run(fiber, {:ok, %{card: "4242"}})
    assert %Coroutine.Completed{result: {:ok, %Order{}}} = fiber
  end

  test "inventory sold out returns error", %{comp: comp} do
    comp =
      MyApp.CheckoutFlow.flow(%Cart{items: [...]})
      |> Port.with_handler(%{
        MyApp.Inventory => fn _, :reserve, [_] -> {:error, :sold_out} end
      })
      |> Yield.with_handler()
      |> Throw.with_handler()

    fiber = comp |> Coroutine.new(Env.new()) |> Coroutine.run()
    # Inventory call fails immediately — never reaches shipping
    assert %Coroutine.Completed{result: {:error, :sold_out}} = fiber
  end

  test "cancellation at any step propagates", %{comp: comp} do
    fiber = comp |> Coroutine.new(Env.new()) |> Coroutine.run()
    assert %Coroutine.ExternalSuspended{value: :shipping} = fiber

    # Cancel instead of submitting
    %Coroutine.Cancelled{} = Coroutine.cancel(fiber, :cancelled)
  end
end

These tests run in microseconds. No browser, no LiveView process, no socket — just pure state transitions.

Why this works

The state machine knows nothing about LiveView. It doesn't import Phoenix.LiveView. It doesn't touch sockets, assigns, or DOM. It's a pure function: (state, event) -> new_state. Yield marks the points where the machine pauses for external input; if branches the flow.

This means:

  • Tests are fast: no LiveView process, no DOM rendering.
  • Tests are deterministic: same input → same path, every time.
  • Tests are property-testable: generate inputs, assert paths.
  • The LiveView module is thin: it only bridges events and renders based on the current step.

Cancellation and cleanup

Cancel on mount to prevent duplicate runners:

def mount(_params, _session, socket) do
  if connected?(socket) do
    socket.assigns[:runner] && AsyncPageMachine.cancel(socket.assigns.runner)
  end
  ...
end

With EffectLogger

Persist flow state for resumption after disconnects:

flow = MyApp.CheckoutFlow.flow(cart)
|> EffectLogger.with_logging()
|> Reader.with_handler(%{})

{:ok, runner} = AsyncPageMachine.run(flow, :checkout)
# On yield: ExternalSuspend.data carries decorations from scoped
# effects — EffectLogger attaches its log, State can attach current
# value via :suspend, etc.
# On reconnect: cold-resume from serialised log

Operation reference

OperationPurpose
AsyncPageMachine.run/2Start flow (async)
AsyncPageMachine.run/3Resume with user input
AsyncPageMachine.cancel/1Cancel flow

Comparison: traditional FSM approach

For contrast, here's the same checkout flow using Crank, a Moore-style pure state machine library. Effects are declared via wants: and executed by an external loop; failures are explicit state transitions.

defmodule MyApp.CheckoutCrank do
  @states ~w(idle reserving shipping payment placing done failed)a
  use Crank, states: @states

  # idle → reserving: declare the effect, advance the state
  def turn({:start, cart}, :idle, _memory) do
    {:next, :reserving, %{cart: cart},
     wants: {:reserve_inventory, cart}}
  end

  # Effect result arrives as an event — match both outcomes
  def turn({:reserve_inventory, {:ok, _}}, :reserving, memory) do
    {:next, :shipping, memory}
  end

  def turn({:reserve_inventory, {:error, :sold_out}}, :reserving, memory) do
    {:next, :failed, memory, reading: :sold_out}
  end

  # User interactions — external events (the caller feeds them in)
  def turn({:submit_shipping, address}, :shipping, memory) do
    {:next, :payment, Map.put(memory, :shipping, address)}
  end

  def turn({:submit_payment, method}, :payment, memory) do
    {:next, :placing, Map.put(memory, :payment, method),
     wants: {:place_order, memory.cart, memory.shipping, method}}
  end

  # Order placement — another effect with success/failure
  def turn({:place_order, {:ok, order}}, :placing, memory) do
    {:next, :done, %{memory | order: order},
     reading: {:ok, order}}
  end

  def turn({:place_order, {:error, reason}}, :placing, memory) do
    {:next, :failed, memory,
     reading: {:error, reason}}
  end
end

The key differences from AsyncPageMachine:

  • States are explicit atomsidle, reserving, shipping, etc. Each transition declares its source state. A typo in a state name is caught at compile time (using unknown state).
  • Effects are externalwants: {:reserve_inventory, cart} is a declaration, not an execution. The caller must run an effect loop: check wants, execute the effect, feed the result back as an event. AsyncPageMachine inlines effects as <- binds.
  • Failures are states{:error, :sold_out} goes to :failed. Each new failure mode requires a new state or at least a new transition clause carrying context via reading:. AsyncPageMachine uses the else clause — all failures flow through the same path.
  • User interactions are events — the LiveView calls Crank.turn with {:submit_shipping, address}. No difference in mechanism from effect results — both are events fed into the machine.

Which approach is better? Crank gives you compile-time state validation and a visibly enumerated state space — ideal when you want a formal state diagram. AsyncPageMachine gives you linear flow with inline effects and a single error path — ideal when the flow is complex and branching is natural. Both test fast without processes. Pick the one that reads most naturally for the problem at hand.

Sync LiveView with Coroutine.PageMachine

For flows where effects are fast (or absent), Coroutine.PageMachine runs in-process with no separate BEAM process. The callback functions are identical to the AsyncPageMachine example above — the only difference is how the page machine is started:

alias Skuld.Coroutine.PageMachine

defmodule MyApp.CheckoutLive do
  use MyAppWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    comp =
      MyApp.CheckoutFlow.flow(socket.assigns.cart)
      |> Port.with_handler(%{
        MyApp.Inventory => MyApp.Inventory.Service,
        MyApp.Orders => MyApp.Orders.Ecto
      })
      |> Yield.with_handler()
      |> Throw.with_handler()

    PageMachine.run(comp, socket,
      on_yield: &handle_yield/2,
      on_complete: &handle_complete/2,
      on_error: &handle_error/2,
      on_cancel: &handle_cancel/2
    )
  end

  defp handle_yield(step, socket) do
    {:noreply, assign(socket, step: step)}
  end

  defp handle_complete({:ok, order}, socket) do
    {:noreply, assign(socket, order: order, step: :done)}
  end

  defp handle_error(:sold_out, socket) do
    {:noreply, put_flash(socket, :error, "Sorry, this item is no longer available")}
  end

  defp handle_error(reason, socket) do
    {:noreply, put_flash(socket, :error, "Checkout failed: #{inspect(reason)}")}
  end

  defp handle_cancel(reason, socket) do
    {:noreply, push_navigate(socket, to: ~p"/cart")}
  end

  @impl true
  def handle_event("submit_shipping", %{"address" => a}, socket),
    do: PageMachine.run(socket.assigns.pm, {:ok, %{address: a}}, socket)

  def handle_event("submit_payment", %{"payment" => p}, socket),
    do: PageMachine.run(socket.assigns.pm, {:ok, p}, socket)

  @impl true
  def render(assigns) do
    case assigns.step do
      :shipping -> ~H|<.shipping_form myself={@myself} />|
      :payment -> ~H|<.payment_form myself={@myself} />|
      :done -> ~H|<.order_summary order={@order} />|
    end
  end
end

The handle_* functions are shared between both approaches. Switching from AsyncPageMachine to PageMachine is just a mechanical change to mount — the callbacks don't change.

For I/O-bound effects use AsyncPageMachine to keep the LiveView responsive. For fast effects this is the simplest possible integration.


< Handler Stacks | Up: Recipes | Index | Durable Computation >