< 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 concept | LiveView |
|---|---|
| State | socket.assigns |
| Transition | handle_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
- Write the page flow as an effectful computation using
Yield - Test it with
Coroutine— deterministic, no processes - Wrap it in a thin LiveView module via
PageMachine - 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
endThe 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
endTesting 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
endThese 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
...
endWith 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 logOperation reference
| Operation | Purpose |
|---|---|
PageMachine.run/2 | Start flow (async) |
PageMachine.run/3 | Resume with user input |
PageMachine.cancel/1 | Cancel flow |
< Handler Stacks | Up: Recipes | Index | Durable Computation >