LiveView Integration

Copy Markdown View Source

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

PageMachine 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 PageMachine + 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 PageMachine
  4. LiveView sends user events as resume values; yields update the UI

Example: checkout flow

A multi-step checkout: collect shipping address, collect payment method, submit the order. Each step is a Yield; branching is plain if.

First, the pure state machine and its boundary contract — no LiveView dependency:

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

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

defmodule MyApp.CheckoutFlow do
  use Skuld.Syntax

  alias Skuld.Effects.Yield

  defcomp flow(cart) do
    # Step 1: collect shipping
    {:ok, %{address: address, express: express?}} <- Yield.yield(:shipping)

    # Step 2: collect payment
    {:ok, payment} <- Yield.yield(:payment)

    # Step 3: optionally confirm express shipping
    {:ok, _} <-
      if express? do
        Yield.yield(:confirm_express)
      else
        {:ok, :skip}
      end

    # Step 4: submit — typed effectful call, no raw Port.request
    {:ok, order} <- MyApp.Orders.place(cart, address, payment)
    {:ok, order}
  else
    {:error, reason} -> {:error, reason}
    :cancelled -> {:error, :cancelled}
  end
end

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

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

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

    {:ok, runner} = PageMachine.run(flow, tag: :checkout)
    {:ok, assign(socket, runner: runner, step: nil)}
  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(reason, socket) do
    {:noreply, put_flash(socket, :error, "Checkout failed")}
  end

  # User events resume the state machine
  @impl true
  def handle_event("submit_shipping", params, socket) do
    value = {:ok, %{address: params["address"], express: params["express"] == "true"}}
    PageMachine.run(socket.assigns.runner, value)
    {:noreply, socket}
  end

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

  @impl true
  def render(assigns) do
    case assigns.step do
      :shipping -> ~H|<.shipping_form myself={@myself} />|
      :payment -> ~H|<.payment_form myself={@myself} />|
      :confirm_express -> ~H|<.confirm_express myself={@myself} />|
      :done -> ~H|<.order_summary order={@order} />|
      _ -> ~H|<.loading />|
    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.Throw
  alias Skuld.Effects.Yield

  setup do
    # Install handlers
    comp =
      MyApp.CheckoutFlow.flow(%Cart{items: [...]})
      |> Yield.with_handler()
      |> Throw.with_handler()

    {:ok, comp: comp}
  end

  test "normal checkout flow", %{comp: comp} do
    # Start — pauses at first yield
    fiber = comp |> Coroutine.new(Env.new()) |> Coroutine.run()
    assert %Coroutine.ExternalSuspended{value: :shipping} = fiber

    # Submit shipping with express
    fiber = Coroutine.run(fiber, {:ok, %{address: "123 Main", express: true}})
    assert %Coroutine.ExternalSuspended{value: :payment} = fiber

    # Submit payment
    fiber = Coroutine.run(fiber, {:ok, %{card: "4242"}})
    assert %Coroutine.ExternalSuspended{value: :confirm_express} = fiber

    # Express confirmation was skipped for non-express
    fiber = Coroutine.run(fiber, {:ok, :confirmed})
    assert %Coroutine.Completed{result: {:ok, %Order{}}} = fiber
  end

  test "standard shipping skips express confirmation", %{comp: comp} do
    fiber = comp |> Coroutine.new(Env.new()) |> Coroutine.run()
    assert %Coroutine.ExternalSuspended{value: :shipping} = fiber

    # Submit shipping without express
    fiber = Coroutine.run(fiber, {:ok, %{address: "123 Main", express: false}})
    assert %Coroutine.ExternalSuspended{value: :payment} = fiber

    # Submit payment — should complete (no confirm_express step)
    fiber = Coroutine.run(fiber, {:ok, %{card: "4242"}})
    assert %Coroutine.Completed{result: {:ok, %Order{}}} = fiber
  end

  test "all paths are deterministic and property-testable" do
    # The same input always produces the same path
    for _ <- 1..100 do
      fiber = comp |> Coroutine.new(Env.new()) |> Coroutine.run()
      fiber = Coroutine.run(fiber, {:ok, %{address: "X", express: false}})
      fiber = Coroutine.run(fiber, {:ok, %{card: "4242"}})
      assert %Coroutine.Completed{} = fiber
    end
  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] && PageMachine.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} = PageMachine.run(flow, tag: :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
PageMachine.run/2Start flow (async)
PageMachine.run/3Resume with user input
PageMachine.cancel/1Cancel flow

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