Arizona Architecture

View Source

Source modules

ModulePurpose
include/arizona_wire.hrlPublic wire header -- diff op codes (OP_TEXT ... OP_LIST_PATCH), mirrored by every client
src/arizona.hrlPrivate header -- ?EACH constant, #stream{} record
src/arizona_template.erlPure helpers -- binding access (get/2,3), dep tracking (track/1), descriptor constructors (stateful/2, stateless/2,3), each/2, to_bin/1, snapshot utilities
src/arizona_eval.erlTemplate evaluation -- dynamics to snapshots, stateful/stateless child eval, stream/each eval
src/arizona_render.erlRender orchestration (target-aware) -- render/1,2, SSR (render_to_iolist/1,2, render_view_to_iolist/2), zip/2, fingerprint_payload/1
src/arizona_utf8.erlUTF-8 output contract -- refuse/3 (names an encoder failure non_utf8_render), dev-mode render_view/2 / locate_live/3, format_error/2
src/arizona_renderer.erlRender-target backend behaviour -- byte emission + attr_command/2; HTML and native JSON backends below
src/arizona_html.erlHTML render backend -- emits HTML statics/attrs (the ?html target)
src/arizona_native.erlNative render backend -- emits a JSON widget tree (the ?native target); see docs/native.md
src/arizona_terminal.erlTerminal render backend -- emits text + ANSI escapes (the ?terminal target); vocabulary in "Terminal render target" below
src/arizona_terminal_session.erlTransport-agnostic orchestrator for ?terminal views -- mounts, dispatches key/push events, repaints; parameterized by a driver module + an output fun
src/arizona_terminal_driver.erlTerminal UX-policy behaviour -- init/1, keys/2, paint/3, setup/1, teardown/1 (all optional; defaults in arizona_terminal_default_driver)
src/arizona_terminal_default_driver.erlDefault arizona_terminal_driver -- idiomatic key events, Ctrl-D quits, cursor hide/show, non-flickering repaint, quit/set_title/bell effects; the session falls back to it per callback
src/arizona_terminal_io.erlLow-level terminal byte helpers -- keys/1 decodes a raw input read into idiomatic keys (the input complement to OTP's output-only io_ansi)
src/arizona_terminal_effect.erlFramework terminal effect builders -- quit/0, set_title/1, bell/0 (the ?terminal analog of arizona_js / arizona_android)
src/arizona_terminal_tty.erlLocal TTY transport -- raw-mode shell:start_interactive, a blocking input reader, io:put_chars as the output
src/arizona_terminal_ssh.erlSSH transport -- serves a ?terminal view over an ssh_server_channel (pty-req, window-change), one live view per connection
src/arizona_diff.erlDiff engine -- diff/2,3,4, stream/list diffing, LIS algorithm
src/arizona_effect.erlNeutral effect plumbing -- the {arizona_effect, [...]} tuple; encode/1 (HTML attr) + encode_json/1 (raw); op codes in include/arizona_effect.hrl
src/arizona_js.erlWeb/browser command/effect builders -- push_event, navigate, patch, fetch, toggle/show/hide, focus/blur/scroll_to, on_key, dispatch_event, set_title, reload
src/arizona_android.erlNative (?native) command builders -- the portable push_event/1,2 and navigate/1,2
src/arizona_os.erlNative-shell (OS) capability command builders -- typed sugars (set_title, focus, notify, ...) plus the command/2 escape hatch, all funnelled through the single ?EFFECT_OS op
src/arizona_stream.erlPure stream data structure -- create, insert, delete, update, move, sort, reset, clear_stream_pending/2, stream_keys/1, drain marks (drain_mark/1, undrained_ops/2)
src/arizona_stateful.erlBehaviour for all live handlers (route-page roots + embedded ?stateful) -- mount/render/handle_*/unmount callbacks, the call_* dispatchers, and format_error/2
src/arizona_req.erlOpaque request -- eager method/path, lazy bindings/params/cookies/headers/body/user_agent, redirect/halted_redirect
src/arizona_middleware.erlRequest-to-bindings middleware pipeline -- apply_middlewares/3 runner, built-in steps (extract/1, check_origin/2, ...) + the HTTP response transform constructors (etag/0, ...)
src/arizona_origin.erlCSRF Origin check -- check/3 (same-origin authority+scheme, or csrf_origins allowlist; missing Origin ok); default-on check_origin/2 step
src/arizona_crypto.erlSigned/encrypted value primitives -- sign/2,3+verify/2 (HMAC-SHA256) and encrypt/2,3+decrypt/2 (AES-256-GCM), domain-separated by Purpose, optional #{ttl}
src/arizona_flash.erlSigned, one-time flash cookie codec -- encode/1/decode/1 (signed JSON + baked-in TTL), resp_cookie/2; HTTP full-page-redirect path (a live navigate carries flash in-process)
src/arizona_session.erlEncrypted, durable session cookie codec -- encode/1/decode/1 (AES-256-GCM via arizona_crypto, TTL = max-age), durable resp_cookie/2; a read does not consume
src/arizona_session_store.erlServer-side session store behaviour -- get/put/delete (+ optional child_spec/0); opt-in via session_store, opaque signed-id cookie; mirrors Plug.Session.Store
src/arizona_session_store_ets.erlDefault in-memory arizona_session_store -- a supervised public-ETS owner + periodic sweep + lazy expiry; get/put/delete run off the request process
src/arizona_user_agent.erlUser-Agent classification for dual-serve views -- browser/1, os/1, mobile/1 (best-effort); pairs with arizona_req:user_agent/1
src/arizona_http.erlTransport-agnostic HTTP render pipeline -- render/3 runs middlewares and renders; reply/3 folds it into {Status, Headers, Body, Req}
src/arizona_controller.erlController routes -- dispatch/3 (pipeline + action + flush), reply_effects/1,2 / reply_redirect/1, the action context, reply kinds
src/arizona_static.erlOffline static-site generation -- generate/2,3 renders route handlers to HTML files under an out-dir; returns {Written, Failed}
src/arizona_ws.erlTransport-agnostic WS upgrade -- prepare/3 resolves + runs middlewares; upgrade/3 answers the request (reply or 101 lines); init/1 starts the socket
src/arizona_live.erlGen_server -- mount (stateful or view), handle_event, handle_info, views map, transport push
src/arizona_parse_transform.erlCompile-time transform -- ?html/?native, ?each DSL to #{s, d, f} maps, az-view auto-injection, az-nodiff, attribute compilation, cross-target nesting guard
src/arizona_socket.erlFramework-agnostic WebSocket protocol state machine -- JSON encode/decode, event dispatch, navigation, op scoping. Crash closes cleanly; client reconnects via backoff
src/arizona_reload_controller.erlDev hot-reload endpoint as a controller -- a loop reply that streams arizona_reloader broadcasts as Server-Sent Events
src/arizona_jsonrpc.erlJSON-RPC 2.0 codec -- decode/1, result/2, error/3, notification/2
src/arizona_mcp.erlMCP server behaviour an app implements (init/1, tools/1, handle_tool/4, optional resources/prompts/channels/terminate) plus the server-push API (broadcast/3, notify/3)
src/arizona_mcp_handler.erlController -- MCP Streamable HTTP transport (POST/GET/DELETE), the peer/Origin/auth gate, and the pure dispatch/3 / handle_method/4 method dispatch
src/arizona_mcp_session.erlPer-session gen_server -- holds the handler state, serializes a session's requests, owns the SSE channel and the resumability buffer
src/arizona_mcp_session_registry.erlETS registry mapping Mcp-Session-Id to a session pid; sweeps dead pids on lookup
src/arizona_mcp_sup.erlsimple_one_for_one supervisor owning the session processes (so a session outlives its connection) and the registry table
src/arizona_error.erlError-reporting helpers -- did_you_mean/2 (missing-key suggestions for format_error/2) and raise_or_propagate/6 (re-tag a failure at the user's callback, propagate anything else)
src/arizona_error_page.erlDev-mode error page renderer -- pretty-prints compile and runtime errors
src/arizona_config.erlApp-env reader with env-var resolution -- get_env/1,2 (drop-in for application:get_env) + resolve/1; expands {env, "VAR"} / {env, "VAR", Default} refs, coerced by default type
src/arizona_app.erlApplication callback -- starts arizona_sup and, when the server env is set, boots its listener through the adapter it names
src/arizona_server.erlBehaviour for an HTTP/WS server adapter -- start/2, stop/1, drain/2 (delivers {arizona_drain, Deadline}), recompile_routes/0
src/arizona_server_registry.erlpersistent_term set of adapters that ran a listener -- register/1, adapters/0, recompile_routes/0 (called by the dev reloader)
src/arizona_route.erlRoute kinds and the rules every adapter compiles from -- kind/1, methods/1, steps/1 (CSRF default), transforms/2, live/2, controller/1
src/arizona_route_table.erlThe routing-contract matcher for adapters whose router lacks it -- compile/3 (refuses unsupported kinds, shadowing), match/3, allow/2
src/arizona_http_transforms.erlReference HTTP response transforms over {Status, Headers, Body} -- etag, compress, cors (preflight/3), security headers (wrap/3)
src/arizona_cookie.erlSet-Cookie serialization independent of any server -- serialize/3, used by arizona_req:flush/1
src/arizona_watcher.erlBehaviour for a file-system watcher -- init/2, handle_info/2 (raw event to changed paths), optional terminate/1; no implementation in core
src/arizona_reloader_rule.erlOne reloader rule per process -- runs the configured arizona_watcher, filters by patterns, debounces, calls the callback, broadcasts via arizona_pubsub
src/arizona_reloader_sup.erlSupervisor of the rule processes -- a temporary child of arizona_sup, so a crash-looping watch stops hot reload, not the app
src/arizona_reloader.erlDev-mode hot reloader -- recompiles changed .erl files, broadcasts reload messages on the arizona_reloader pubsub topic
src/arizona_reloader_consistency.erlPost-reload call-consistency check -- check/1 reports callers whose beam imports chunk names a function a just-reloaded module no longer exports, plus loaded-vs-disk stale modules
src/arizona_dev_mcp.erlDev-time MCP server exposing the running node to a coding agent -- introspection tools plus eval/reload; localhost-only by default (allow_remote_access => false); see docs/mcp.md
src/arizona_dev_log.erlBounded log ring behind the dev MCP's get_logs tool -- a logger handler keeping the last 1024 formatted events; table owned by arizona_sup, handler installed on first MCP session
src/arizona_pubsub.erlPubSub -- thin pg wrapper for cross-view communication
src/arizona_sup.erlSupervisor -- always starts arizona_pubsub; additionally starts arizona_reloader_sup when the reloader has rules
src/az.erlUser-facing facade -- shared type aliases and short-form helpers re-exported from internal modules

API -- arizona_template.erl

Pure helpers for binding access, descriptor construction, and template composition.

  • get/2,3, get_lazy/3 -- binding access with dep tracking via process dictionary ($arizona_deps)
  • track/1 -- manually track a binding key as a dependency
  • with/2 -- tracked projection: track/1s each key then maps:with(Keys, Bindings); hands a bindings subset to a sub-context while declaring its deps
  • stateful/2 -- returns #{stateful => Handler, props => Props} descriptor for child views
  • stateless/2 -- returns #{callback => Callback, props => Props} descriptor (Callback is a fun/1)
  • stateless/3 -- returns #{callback => fun Handler:Fun/1, props => Props} descriptor
  • each/2 -- template composition: 1-arg fun(Item) for lists, 2-arg fun(Item, Key) for streams, 2-arg fun(Key, Value) for maps. Returns #{t, source, template} descriptor
  • to_bin/1 -- convert value to binary (binary/integer/float/atom/iolist). A float prints the shortest digits that read back as the same float: positional when 1.0e-7 =< |x| < 1.0e21 (the range JS prints positionally; 1500.0, 0.00001), exponent form outside it (1.0e21, 1.0e-8). An integral value keeps the Erlang .0 (1000.0), and -0.0 stays -0.0

API -- arizona_render.erl

HTML output and rendering.

  • render/1 -- evaluate dynamics, produce {HTML, Snapshot}
  • render/2 -- like render/1 but threads a views map for stateful children, collects deps: {HTML, Snapshot, Views}
  • render_snapshot/2 -- render/2 without the document: {Snapshot, Views}. Every live mount renders through this: a fingerprinted reconnect or navigate ships the fingerprint payload, and a first connect ships no page at all. Evaluating the snapshot (arizona_eval) is what observes the opaque command attributes the connect frame declares
  • snapshot_html/1 -- rebuild the document render/2 would have produced from a snapshot, for a root with no fingerprint
  • render_to_iolist/1 -- render a template to iolist (used for layout templates with diff => false)
  • render_to_iolist/2 -- stateful-component SSR (test-only): render_to_iolist(Handler, Opts) where Opts may contain layouts => [{Mod, Fun}] and bindings => map(). on_mount is not honored -- it's a route-level concept
  • render_view_to_iolist/2 -- SSR for a route-level view, page only (static generation): render_view_to_iolist(Handler, Opts). Mounts via arizona_stateful:call_mount/2, applies on_mount hooks, optionally wraps in a layout
  • render_view/2 -- the same, returning {ContentType, Page}: the outermost rendered template that records a backend names the document's media type (content_type/0); a backendless layout inherits the page's
  • render_view/3 -- render_view/2 with the page's template passed through a fun between its render/1 and the walk: the one mount sequence, which arizona_utf8:render_view/2 (dev mode) uses to check every value
  • resolve_id/1 -- resolves a binary id (passthrough) or a #{s, d} template (renders to binary)
  • zip/2 -- interleave statics and evaluated dynamics into iolist
  • fingerprint_payload/1 -- convert a snapshot to fingerprint wire format

Error location -- arizona_loc

Every compiled dynamic carries Loc = {Module, Line}, its template source line. Each evaluator of a located dynamic catches an exception raised while evaluating it and re-raises it as {arizona_loc, {Module, Line}, Reason}, keeping the class and stacktrace (arizona_eval:relocate/4). The evaluators are the SSR walk (arizona_render), the live mount and snapshot render (arizona_eval's eval_one_v/2 and eval_one/1), and the diff's re-evaluation of a changed slot, including the ?each-source and az-nodiff-az-nodiff element paths (eval_each_def/2, eval_unless_same_nodiff/3). So a crash names the same template line whether it happens at the first page render, at connect, or after an event changed a binding, and whether it sits in a top-level value, an attribute, an ?each item, a conditional branch or a ?stateless body.

An enclosing dynamic never re-wraps a tag a nested one already attached: the deepest location, closest to the failure, wins. The tag is the reason itself, so it reaches every consumer unchanged. The dev error page (arizona_error_page) titles the page with the location and formats the inner reason; the socket's crash log (Live process ~p crashed: ~p) prints the live process's exit reason, tag included. A crash in a ?stateful child's mount/1 or handle_update/3 is tagged with the line of the slot that holds the child, as at SSR. A 2-tuple dynamic (no Loc, built by hand) is evaluated without the catch.

The catch is one try per located dynamic on the path every slot evaluation already takes. It adds no reductions; only a crash pays, for the re-raise.

Output is UTF-8 -- non_utf8_render

Every render target writes UTF-8: an HTML response is served as text/html; charset=utf-8, and a WebSocket frame and a ?native document are JSON. So a rendered binary must be UTF-8. A list is chardata and needs nothing from the app: arizona_template:to_bin/1 decodes it as Unicode code points, producing UTF-8, and refuses one that does not decode (a list wrapping non-UTF-8 bytes raises {non_utf8_render, Byte, Offset}, any other non-chardata bad_template_value). A binary is emitted as the bytes it holds, unchecked, because checking every value on the render path was measured at +22% to +49% on non-ASCII SSR (see docs/performance.md, "What did not work").

The usual source of a non-UTF-8 binary is a bare <<"café">> literal: with no type spec, each character is stored as one byte, which is Latin-1. Fix it at the source:

  • write ~"café" or <<"café"/utf8>>;
  • convert Latin-1 data from outside (a file, a legacy database, a socket) with unicode:characters_to_binary(Bin, latin1).

The contract is enforced where it costs nothing:

  • Compile time. A template static is not the trap: the parse transform folds a binary literal in a content or attribute position as UTF-8 text (extract_bin_element/1), so {p, [{title, <<"é">>}], [<<"café">>]} renders café. Everywhere else in a module using the Arizona headers -- mount/1, handle_event/3, a helper, a ?get default, a conditional branch value, a hoisted variable, a ?raw payload, a ?stateless/?stateful prop -- a non-ASCII <<"...">> with no /utf8 gets the compile warning latin1_binary_literal (an error under warnings_as_errors). It is not re-encoded: such a literal may be data compared against other Latin-1 bytes or a protocol constant, and the transform cannot tell which. ~"...", <<"..."/utf8>> and ASCII-only literals never trigger it. There is no suppression option: bytes meant as bytes (a PNG signature, a protocol constant) are spelled so they do not look like text -- integer segments (<<16#89, "PNG\r\n", 16#1A, "\n">>, <<16#E9>>) or a sized string (<<"\351":8>>, <<"é":8>>), which compile to the same bytes. The warning text names both.
  • Failure paths. The JSON encoder validates UTF-8 as it escapes, so a frame or a ?native value holding other bytes is already refused -- as the encoder's {invalid_byte, _} or unexpected_end, which names neither the value nor the cause. arizona_utf8:refuse/3, run only after the encoder raised, renames it {non_utf8_render, Byte, Offset}, finding the bytes in a structured value too (a command array). ?native escapes inside the slot's located evaluation, so its error carries the template line; a socket frame failure is logged with the formatted reason and closes 4500 (4501 for the frame delivering a mount), as before.
  • Dev mode (the dev reloader is wired, the signal that also gates the rich error page). arizona_http renders each page through arizona_utf8:render_view/2 instead of arizona_render:render_view/2: the same mount, the same output bytes, but every dynamic is wrapped so its value is checked inside the slot's located try -- in the page, nested templates, ?each items, ?stateless children, and ?stateful children, whose handler a stand-in (arizona_utf8:mount/1, render/1) wraps so the SSR walk still mounts each one once and marks it a view boundary. A bad value raises {arizona_loc, {Module, Line}, {non_utf8_render, Byte, Offset}}, which reaches the dev error page; the finished document is then checked once (one BIF pass) for what no slot produced, a layout's values. Nothing is rendered twice, so mount/1 side effects run once, as in production. A refused WebSocket frame is located from the live process's current views (arizona_utf8:locate_live/3, via arizona_live:view_state/1): each view renders again from the bindings it holds, children skipped and checked from their own, so nothing is mounted, and with the live process's connection state (?connected, ?capabilities/?capability, ?reconnected) copied in for the render, so it takes the branches that produced the frame. Production pays one dev-flag read per HTTP response and nothing per frame.

format_error/2 (arizona_utf8) names the byte, its offset, the value's bytes (as integers -- ~p would print a Latin-1 binary as the very text it fails to be) and the fix. What Offset counts depends on where the bytes were refused:

  • A binary -- a JSON encoder's refusal (?native, an effect command, a socket frame; arizona_utf8:refuse/3) and the dev-mode per-value check: the offset into that binary, which the preview shows.
  • A list -- arizona_template:to_bin/1 decoding chardata: the offset into the decoded output, i.e. how many UTF-8 bytes the elements before the bad one produced. The raising call's argument is the list, so there is no preview.
  • The finished document -- the dev-mode check after a page renders (bytes no slot produced, such as a layout's): the offset into the document; the preview is the 32 bytes around it.

Handler effects are not template values; the socket encodes them, so a non-UTF-8 string in one is named on the same failure path.

Terminal render target -- ?terminal

?terminal(Elems) renders a view to ANSI text instead of HTML or JSON. The backend is arizona_terminal (an arizona_renderer), the sibling of arizona_html and arizona_native. Its transport repaints the whole frame on every update (arizona_live:render_current/1) rather than applying diff ops, so the backend writes no az/slot markers -- the diff engine's ops are computed but ignored.

The frame is printed from the live process's diffed snapshot (the root's, with each child view's current copy refreshed in), not from a fresh render of the template, so a repaint runs no mount/1 or handle_update/3 and shows exactly the state the process holds. It follows that a terminal sees what a web client sees: az-nodiff content renders once, and a slot whose reads are not tracked freezes after its first render. When the node can reload code (arizona_reloader:enabled/0) a hot reload remounts the view in place (arizona_terminal_session:handle_reload/1,2): the session joins the reloader topic at start, and on {arizona_reloader, reload} stops the live process, mounts a fresh one from the connection's bindings (an SSH channel's current geometry) and repaints, so the view's state resets as a browser page's does. A terminal client does not reconnect, so this is the terminal's counterpart of the WebSocket close described under "Exit reason to close code". Otherwise, edited template text appears once a slot depending on it changes.

Escaping is per-backend via the arizona_renderer:escape/1 callback, selected by the render target: HTML entity-escapes, terminal control-char sanitizes (strips C0 bytes except \n/\t, DEL, and the C1 controls, and replaces every byte outside well-formed UTF-8 with U+FFFD, so the output is always valid UTF-8; this assumes a UTF-8 terminal, since an 8-bit one reads UTF-8 continuation bytes 80-9F as C1 controls), native emits a JSON string. Native alone carries a value differently on the wire: its client JSON-encodes each d value itself, so the fingerprint payload takes the raw value from arizona_renderer:wire_escape/1 (wire_attr/2 for a prop) while the zipped document keeps the JSON string. So a dynamic value in a ?terminal view cannot inject ANSI/OSC escape sequences into another user's terminal (the terminal analog of XSS). The ?raw opt-out bypasses it -- never wrap untrusted data in ?raw in a ?terminal template.

Elements

TagEmits
lineits content, a style reset, then a newline -- one terminal row
col / rowits content, then a style reset -- a transparent container
text / spanits content, then a style reset -- inline, no newline
bra void element emitting a newline

Only line (newline) and br (void newline) affect layout; col, row, text, and span are behaviorally identical (open emits nothing, close emits a style reset) -- the names are documentary. So vertical layout is a col of lines, and horizontal is several text/span inside one line.

Styling

Set styles with bare-atom boolean attributes mapped to io_ansi escapes: bold, dim, italic, underline, inverse, and the colours black/red/green/yellow/blue/magenta/cyan/white plus their light_* variants. For anything the atoms don't cover, use a valued attribute: {sgr, Escape} (a raw escape, verbatim), {fg, Index} / {bg, Index} (256-colour palette by index). Styles do not inherit across containers (every element resets on close), so put the style on the line/text that needs it. An unknown style atom, an unknown valued attribute, a dynamic attribute value, or an event-command attribute is rejected at compile time, not silently dropped. So is an unknown element: the tag table above is the whole vocabulary, so a tag outside it is a typo rather than an extension point, and accepting it as a transparent container swallowed the author's intent silently ({'BR', [], []} emitted a style reset instead of a newline). Terminal tag names are case-sensitive -- the vocabulary is Arizona's own, not HTML's, so Line is not line. (This is the opposite of the ?html target, whose tag classification folds case because the browser's parser does.)

render(Bindings) ->
    ?terminal(
        {col, [], [
            {line, [bold, green], [~"== Title =="]},
            {line, [], [~"plain ", {span, [yellow], [~"highlighted"]}, ~" tail"]},
            {line, [{fg, ~"208"}], [~"256-colour orange"]}
        ]}
    ).

Conditional rows: a control-flow expression (case/if/begin/receive/try/ maybe) returning a bare element tuple from a tail in a content slot is compiled into a nested template (inheriting the enclosing target), just as a literal ?html/?terminal there would be -- no wrap needed:

{col, [], [
    case ?get(mode) of
        warn -> {line, [yellow], [~"check your input"]};
        _ -> <<>>
    end
]}

For 0-or-N rows, ?each over a list still applies:

?each(fun(Msg) -> {line, [yellow], [Msg]} end, status_rows(?get(mode)))

Running a ?terminal view

arizona_terminal_session is the transport-agnostic core: it mounts the view, turns input into arizona_live:handle_event/4 calls, and repaints. It is parameterized by a driver (arizona_terminal_driver -- the key map, paint model, and screen setup/teardown, with defaults in arizona_terminal_default_driver that the session falls back to per callback) and an output fun. Two transports drive it: arizona_terminal_tty (a local raw-mode TTY) and arizona_terminal_ssh (a view served over SSH). arizona_terminal_io:keys/1 decodes a raw input read into idiomatic keys (char codes, atoms like up/enter, {ctrl, _} / {alt, _}) for drivers to match on.

Every paint/3 decides continue or stop, the initial paint included: a driver that stops on the first frame gets its teardown emitted and the live view reaped, and arizona_terminal_session:start/5 answers quit instead of handing back a session. The SSH transport serves one view per channel -- a second shell request is refused rather than mounting a second view over the first, exec is refused (this shell is interactive only), and input or a resize arriving before the shell request is dropped or folded into the mount geometry rather than reaching a session that does not exist yet.

A view drives the terminal back with effects from arizona_terminal_effect (the ?terminal analog of arizona_js / arizona_android): quit/0 stops the session, set_title/1 sets the terminal title, bell/0 rings it. The default driver's paint/3 interprets these and repaints in place (no full-screen clear, so no flicker). Effects tied to a specific paint model (e.g. a scrolling log above a pinned block) stay the concern of a custom driver, like the demo's.

API -- arizona_diff.erl

Diff engine.

  • diff/2 -- compare new template against old snapshot: {Ops, NewSnapshot}
  • diff/3 -- like diff/2 with views map: {Ops, NewSnapshot, Views}
  • diff/4 -- like diff/3 but skips dynamics whose deps haven't changed (takes Changed map): {Ops, NewSnapshot, Views}

Dep tracking and per-dynamic isolation

Each dynamic owns a private dep set: #{key() => true} of the binding keys read while it was evaluated. The diff engine compares each dep set against the Changed map and skips re-evaluation when they don't intersect.

The mechanism is a single process-dictionary slot, '$arizona_deps', holding the open dependency scope:

  • eval_one_v (in arizona_eval) opens a scope around each dynamic (open_deps/0, which puts #{} and answers the enclosing scope) and closes it at exit (close_deps/1).
  • arizona_template:get/2,3, arizona_template:get_lazy/3, and arizona_template:track/1 write the read key into the open scope.
  • The dynamic's deps are read back when its scope closes and stored on the snapshot (parallel to d).

Scopes nest. Closing a scope opened inside another hands its reads to the enclosing one, so a dynamic evaluated while another's scope is open counts toward both. That is deliberate in exactly one place, an ?each's items (below); everywhere else the enclosing scope is suspended first.

Per-dynamic isolation invariant: every nested-template entry point runs inside a with_saved_deps wrapper (in arizona_eval), which suspends the enclosing scope (erases the slot) for its duration and restores it after. Without this, an inner template's ?get calls would land in the outer dynamic's deps. Bracketed entry points and what each covers:

  • eval_template -- the inner template's per-dynamic eval loop
  • eval_stateful -- the whole lifecycle: mount/1 / handle_update/3, render/1, and the inner per-dynamic eval loop
  • the stateless inline path -- the entire eval_val_v recursion for #{callback := _, props := _} descriptors, including the callback body and any subsequent unwrapping

Inner dynamics open their own scope, so per-inner-dynamic deps are independent of outer.

An ?each's items are the exception, and they feed the each slot. The items of an each render inside the each slot's scope (arizona_eval:eval_items/2, one scope around them all), so whatever the callback reads -- an outer binding (?each(fun(I) -> {li, [], [I, ?get(suffix)]} end, ?get(items))), at any depth, an inner each's items included -- becomes a dep of the each slot beside its source. A change to such a binding re-evaluates the each, and the per-item diff then patches only the items it changed. The deps are the reads that actually happened, so a key computed at runtime (?get(Key)), a read in one branch, and a read in a helper the callback calls all count, with no compile-time analysis. Each item dynamic keeps its own deps too, which is what per-item skipping reads. Two consequences of collecting at runtime:

  • Every tracked read in an item counts, including an item-field read with an explicit arizona_template:get(text, Item) (the only form stream per-item skipping can vouch for; see "A changed stream item reuses only what its deps can vouch for" below), so a view binding that happens to share that name re-evaluates the each too. That over-tracks; the item diff then emits nothing.
  • An az-nodiff item template renders once, so its items are evaluated with the scope suspended (eval_items/2) and their reads never reach the slot.

On the diff path the each slot's own closure is re-run without its items (eval_each_def/2), and the each diff adds the reads of the items it renders. A list or map diff renders every item, so the slot's deps come out exact. A stream diff renders only what its queued ops touch, so the stream snapshot keeps the items' reads beside it (item_deps), grown by each diff's renders. When a changed binding is among them, the queued ops alone would leave every other item stale, so the stream reconciles against its current source in one step instead of draining (diff_stream/5's Reconcile: a single {reset, #{}}, which re-renders the visible items and patches only those that differ). A stream nested inside an item is diffed from a value the walk already evaluated whole, so there the drain is checked against that render and the container reconciles against it (stream_relist/7) when the queue does not account for it (nested_drain/4). A nested stream whose render holds child views always reconciles that way, without draining: the drain renders the items its ops touch a second time, so each ?stateful among them would run mount/1 twice and the first instance would be dropped without unmount/1.

Content-slot conditional branches: a case/if/maybe in a content slot compiles each element branch into a nested template, so by this invariant the branch's ?get reads land in the nested template's slot, not the enclosing conditional dynamic's -- which freezes the branch when only a branch-read binding changes (the conditional's deps would otherwise hold just the scrutinee). The parse transform (branch_track_touches/1 in arizona_parse_transform) compensates by injecting arizona_template:track/1 for each literal key read in an element branch tail, recording them as deps of the conditional dynamic itself. track/1 records the key without reading the binding, so an absent non-taken-branch key is safe; value (scalar) branches are left alone (their reads already track when taken). See the "Bare element tuples in conditional tails" rule in .claude/rules/erlang.md.

On re-render the diff then fine-grains: make_ops/4 diffs a same-statics nested template's inner dynamics (each globally Az-addressed and marker-anchored) and patches only the changed inner slot(s) -- the same per-inner-dynamic diff the view_id child-view path uses (diff_child_dynamics/2), minus the [VId, ChildOps] wrapper, since a plain nested template is inline in the parent view. It recurses through nested-nested templates to the deepest slot; an inner attribute change is a precise ?OP_SET_ATTR. A wholesale ?OP_TEXT re-render is the fallback only when the statics differ (a different branch / structure change).

?OP_TEXT escaping -- text vs HTML. SSR escapes a dynamic value (render_dyn/1 -> escape_value/1); the live diff sends it raw (make_op/3 -> to_bin/1), with escaping completed at the client's output boundary -- the wire carries text as a bare string and HTML as an object, so the client knows which to do. A scalar ?get value is a bare binary; the JS client renders it with a text node, so a < is literal text (never parsed -- can't inject, matches SSR). An HTML fragment (a nested-template / plain-list-?each zip-map, whose inner values zip_or_fp already escaped) is a map; the client innerHTMLs it. The escape opt-out ?raw is the one scalar that must innerHTML, so make_op/3 tags it #{~"raw" => Html} (a map, not a bare string) -- the only thing distinguishing trusted markup from a ?get value that merely contains <. The worker sets op[3] = true for the HTML cases. Attribute values are escaped by the client's setAttribute. A ?raw attribute value is the exception again: SSR splices it inside name="...", where the parser decodes its entities, so make_op/3 tags it #{~"raw" => Markup} on ?OP_SET_ATTR too and the client decodes it in that same double-quoted context (a bare string would leave &amp; literal after the first update). An interpolated value with a literal ?raw part ({href, [~"/a?", ?raw(Q)]}) is markup as a whole: the transform wraps it in a raw_attr call, which splices the raw parts verbatim and escapes every other part, static or dynamic, so SSR and the tagged live update carry the same assembled markup. (?raw is an HTML-target feature; the ?native/?terminal backends don't HTML-escape, so ?raw there is unsupported -- a target-aware tag would be the follow-up if it ever needs to behave like a plain value.)

Usage convention: outer-scope ?get reads belong in the props expression of ?stateful/?stateless -- the parse transform places them in the outer dynamic's closure, where they track correctly. Eager ?get calls inside a ?stateless callback body, or inside a stateful's mount/1 / handle_update/3, are discarded by the wrap; this is intentional -- those reads belong to the inner scope. Idiomatic callbacks use a named fun reference (?stateless(fun bar/1, Props)), which cannot close over outer Bindings and therefore avoids the footgun entirely.

Binding-read inlining

Dep tracking requires a ?get to run inside its slot's '$arizona_deps' bracket. A read hoisted into the render function body --

render(Bindings) ->
    Name = arizona_template:get(name, Bindings),
    ?html({p, [], [Name]}).

-- would otherwise compile the slot to fun() -> Name end: a captured value with no ?get, so it records empty deps and the slot freezes after the first render. The parse transform (arizona_parse_transform) closes the gap by rewriting each interpolated variable back into its defining expression, so the ?get re-executes inside the per-slot bracket -- exactly as if it had been written inline. This is purely compile-time; the runtime/diff path is unchanged and idiomatic templates (reads written inside ?html) are unaffected.

  • collect_inline/1 builds a per-clause map of top-level Var = RHS matches whose RHS transitively reaches a get/get_lazy/track/with call (with tracks too, so a hoisted Sub = with(Keys, Bindings) must inline back into the slot bracket or its tracking runs outside any bracket and freezes). Rebound vars are dropped; a derivation with no read (e.g. Id = make_uuid()) is left captured: it runs once in the body and every slot sees that one value. A definition that does read runs in the body and again in each slot that uses it (see "Template code is a pure function of the bindings" below).
  • transform_scoped_tail/4 does the same per body along the tail of the clause's last expression (the case/if/begin/try/maybe/receive tails the live root accepts), so a read hoisted inside a branch (a -> N = ?get(n), ?html({p, [], [N]})) inlines into that branch's templates, on top of the enclosing map. A match on a name already bound -- by the clause head, an earlier statement or outside the body -- is an equality test: the match stays as written, so the test still runs and the earlier binding stays used, and its read is still inlined, so the slot stays tracked. The render function re-runs on every diff, so the test runs on every render.
  • inline_vars/2 substitutes those variables wherever they appear in a ?html/?each argument -- including inside a case scrutinee or an opaque call -- recursively and scope-aware (fun parameters and comprehension generators shadow; patterns and guards are never substituted, so a substitution can never form an illegal pattern or a guard ?get -- a pattern or guard that reads one is tracked by a touch instead, see below). A substitution under a fun parameter, named fun or generator that rebinds a name the definition reads is a compile error (hoisted_var_captured): the copy would read the inner binding.
  • normalize_tail_binds/1 lifts a statement-form case that binds one variable as the whole body of every branch (case ?get(m) of a -> X = ?get(p); _ -> X = ?get(q) end) into value form (X = case ?get(m) of a -> ?get(p); _ -> ?get(q) end) so the same machinery inlines it.
  • A now-template-only match is underscore-prefixed so warnings_as_errors (unused variable) stays satisfied.

The invariant: a slot tracks only the reads performed inside its own closure. The transform arranges that automatically when a ?get result reaches the slot through a bare variable -- it rewrites the body binding to _ = get(...) and inlines a fresh read into the closure, so the read re-runs inside the dependency bracket. It cannot when the value arrives any other way: bound through a non-bare-var pattern, or derived by intermediate computation (Side in #{side := Side} = f(?get(k)), which compiles to fun() -> Side end). Such a slot renders once at SSR and never updates, and nothing says so -- the view re-renders normally and its sibling slots move, so it reads as a diff bug rather than a missing dependency. When the slot's value is computed rather than read, put the read in the slot itself (f(?get(k))).

Reads that do not inline (left captured, slot stays static): a binding destructured in the function head (render(#{foo := Foo})) or through any non-bare-var pattern ({ok, V} = ?get(...)) -- a pattern match is an untracked read; read the whole value with ?get(foo) and destructure with plain Erlang. Also: a variable bound inside a case branch whose clause head itself binds a variable, and a rebound variable. Use ?get for the top-level binding and plain maps:get/2 for sub-structures (only the ?get records a dep, which is the correct grain). In these cases the read is left captured and the slot keeps its SSR value.

Template code is a pure function of the bindings. Everything a slot evaluates is evaluated lazily: the slot expression itself, the definition of a hoisted variable the template reads (re-inlined into every slot that uses it, and still run once in the body), and the arguments of an inlined local element helper (substituted at each use of the parameter, and not evaluated at all when the parameter is unused). A slot runs once on first render, then again only when its own dependencies change, while the render function's body runs on every diff cycle. So one source expression may run zero, one or several times per render, in no fixed order, and two slots that read the same variable each compute it themselves -- capturing the value once would not help, since the body and the slots run on different schedules. Keep that code deterministic and free of side effects. A value that must be generated once and shared -- a unique id pairing a label with its input, a timestamp, a random token -- belongs in state: create it in mount/1 or handle_event/3 and read it with ?get, or derive it from bindings (<<"field-", (?get(name))/binary>>).

The transform rejects the cases it can see with template_impure_expr, located at the offending call: a construct from a fixed always-impure set -- a send (!), receive, make_ref, erlang:unique_integer, the clock reads (erlang:now/timestamp/system_time/monotonic_time, os:system_time, calendar:local_time/universal_time), put/erase, spawn*, and any call into rand, os, ets, io or logger -- anywhere in a hoisted definition that is re-inlined (check_hoisted_pure/3, at substitution time in iv/2, so a definition no template uses runs once and is never checked), or in a helper argument whose parameter does not occur exactly once in the helper body (check_helper_args_pure/5, counted on the source body, patterns and guards included). A fun that is only constructed there is not entered, since its body runs where it is called. Calls into your own functions are not checked.

A tracked read used in a guard is auto-tracked, not frozen: a binding read in a guard (case Status of active when Confirming -> ..., an if) can't run inside the slot's dependency bracket on its own -- a guard cannot hold the get/2 call (Erlang rejects that as an illegal guard expression), so inline_vars leaves guards as bound variables. Left alone the read would run once in the function body, outside the bracket, and the slot would silently freeze on that binding. Instead, the guard-bearing iv node ({'case',...} / {'if',...} / {'receive',...} / {'try',...} / {'maybe',...}, and a nested fun) wraps itself in a block that first reads each tracked binding its clause guards reference -- begin _ = arizona_template:get(K, Bindings), ..., <expr> end -- for the track/1 side effect, recording those bindings as slot dependencies (wrap_touches/4, collecting via clause_tracked_vars/2 / fun_tracked_vars/2 with per-clause shadow handling). The guard keeps using the captured value, which each diff cycle rebuilds from current bindings, so a change to a guard binding re-renders the slot. Reusing the inline expansion as the touch (iv({var,L,V}, Inline)) handles get/get_lazy/with and transitively-derived vars uniformly. A non-tracked local or a pattern-bound variable in a guard needs no touch (no binding dependency).

Patterns get the same touch. A pattern cannot hold the call either, and an inlined variable a pattern reads would freeze the slot the same way: an equality test against a hoisted variable (case I of Sel -> ... -- the variable is already bound, so the clause tests it and its body's Sel is the same value, substituted as usual), a map-pattern key, a binary segment size, or a maybe ?=. Case-like clauses, match expressions, fun heads (keys and sizes only -- a fun parameter binds) and comprehension generators (keys and sizes only, the generator's own bindings shadowing later qualifiers) all collect them. A ?= must stay a direct expression of its maybe body, so its touches are placed ahead of it in that body rather than in a wrapping block. The one fun NOT wrapped is a top-level ?each callback (handled in inline_vars/2): compile_each needs a single-clause fun, and its guards are over the item param, not outer tracked vars.

Tracked accessors, the sub-map diagnostic, and with/2

get/2,3, get_lazy/3, and with/2 (and their az: aliases) are the dependency-tracking accessors: each calls track/1 regardless of which map it reads. That makes reading a nested map a footgun -- Foo = ?get(foo), ?get(bar, Foo) records bar (a key of Foo) as a top-level slot dep, which both over-tracks and frantically misfires. The parse transform rejects it at compile time (tracked_get_on_non_bindings_map): a tracked read whose map argument is a local provably holding a value read out of a map -- bound by a tracked accessor, maps:get/2,3 or map_get/2, bound to a value by a map pattern (#{user := U} in a match, a function or fun head, or a case clause), or aliased from such a local -- is an error. Any other local may be the bindings (a parameter, an alias, a with/2 projection, a value threaded through a helper or a case), and the transform cannot tell, so it is not flagged: a false rejection would block correct code, a missed one only over-tracks. Read sub-structures with plain maps:get/2. Non-variable map arguments (a literal map, a maps:get call) are never flagged.

Nested-template / passed-bindings freeze. A child template embedded via a raw function call (or an explicit nested ?html(...) returning a template) renders at SSR but never updates: the outer slot is fun() -> child(Bindings) end, and building child fires no ?get at the outer level (the reads live in child's own closures, isolated by with_saved_deps), so the outer slot captures empty deps and the diff engine skips it forever. Idiomatic composition is ?stateful/?stateless, whose props expressions read on the parent slot; an inline nested element is also fine (it flattens into the parent template). When you must hand a bindings subset to a sub-context, ?with([keys]) (arizona_template:with([keys], Bindings), or the az:with/2 alias when Bindings isn't in scope) is the explicit fix: it track/1s each key (so the outer slot re-renders when any listed key changes) and returns maps:with(Keys, Bindings) (so the sub-context cannot silently depend on an untracked key -- an omitted key raises missing_binding rather than freezing). No with_all: tracking every key makes the slot depend on everything, defeating fine-grained diffing.

Eager get/3 defaults over-track. ?get(a, ?get(b)) records b even when a is present, because Erlang evaluates the default argument eagerly and ?get(b) tracks unconditionally. ?get_lazy(a, fun() -> ?get(b) end) tracks the fallback key only when the default is actually taken.

A changed stream item reuses only what its deps can vouch for. The main per-dynamic diff path skips a slot whose deps are empty (the documented frozen residue above). A changed stream item (arizona_stream:update/3, or a kept item of a reset) cannot trust its deps that way: the item value is an input to every dynamic, and deps record only the reads that went through get. A plain maps:get(n, Item), a head-destructured field (fun(#{text := T}, _) -> ...), a value derived in the callback body, or a read inside a nested template's closure (a conditional branch, an inner ?each callback) never reaches them -- and a dynamic that also reads a view binding has non-empty deps that name none of the fields it used. So the parse transform decides which dynamics are provable: a per-item template carries reuse => [boolean()], one flag per dynamic, true only when the dynamic references no variable but the item key outside the map argument of a tracked read (arizona_template:get/2,3, get_lazy/3, with/2, or their az aliases) in its own closure. That map must be the item itself (the bare item parameter, or the variable side of a Pat = Var head) or the view bindings (bound outside the callback): a sub-map bound in the head or the body records its own keys, not the item field that holds it. A dynamic with no reads and no item reference (a static value, ?connected, the key attribute) is kept on an item update. arizona_eval:render_stream_item_skipping/6 keeps a dynamic's old {Az, Val, Deps} only when it is flagged and its deps miss the changed fields; everything else re-evaluates, and a template without the list (none qualified, a 1-arg callback, a hand-built template) re-renders the whole changed item. An item whose value did not change at all is still reused whole. The result: untracked item reads stay current inside stream items, and only a fully tracked dynamic gets skipped.

?each item bodies must be elements. Because each item compiles into a per-item template (#{s, d, f}), the callback body must be an element ({Tag, Attrs, Children}), a list of elements, or a static/mixed fragment. classify_body/1 in arizona_parse_transform buckets the body; the only non-template bucket, text_dynamic (a bare value, a runtime binary, a ?stateful/?stateless descriptor, or a case/if), compiles to one opaque value slot. A scalar value renders at SSR and diffs (the per-item key is arizona_template:to_bin/1 of its value), but gets no per-item template diffing -- pointless where a comprehension is the right tool; a template or descriptor value goes further and crashes on the first diff, when to_bin/1 hits the stored template/descriptor and raises bad_template_value. So compile_each rejects a text_dynamic body (each_body_not_element for a 1-arg/list callback, each_stream_body_not_element for a 2-arg/stream-or-map callback -- the two differ only in fix advice, since a list has a comprehension fallback and a stream/map, keyed per item, does not) at compile time. Over a stream, a keyed callback must also return exactly one element: the keyed ops (OP_INSERT/OP_REMOVE/OP_MOVE/OP_ITEM_PATCH) address an item by the single element carrying its az-key, and any other root of the item would be left behind on a remove and out of order on a move. The same 2-arg callback may iterate a map, which diffs through the list machinery (positionally for a single-root item, else a wholesale container OP_TEXT) and so takes a list of elements, a text+element fragment or bare text. The source is only known at runtime, so compile_each stamps such a body multi_root => true and arizona_template:each/2 raises stream_each_multi_root when it is paired with an arizona_stream -- on every evaluation, so the render and the diff alike -- unless the per-item template is nodiff (never addressed by a keyed op). A one-element list body is taken as its element. Over a stream that root must carry az_key (the client looks items up by az-key, so an unkeyed item is unreachable to every keyed op and the DOM drifts from the stream). A map diffs positionally and needs no key, so this is the same runtime pairing check: compile_each stamps a single-element body whose root has no literal az_key with keyless => true, and arizona_template:each/2 raises stream_each_keyless over a stream, again unless the item is nodiff, and only on a backend whose client applies keyed item ops (the keyed_item_ops/0 renderer callback; ?terminal redraws whole frames). A keyed callback whose body is a conditional raises each_stream_body_control_flow instead, because the fix is different: "wrap the value in an element" reads as advice to pick a tag, when the real constraint is that every item shares ONE compiled per-item template, so an item's outer tag is fixed at compile time. That shared template is exactly what makes per-item diffing cheap (statics once, dynamics per item). The branches themselves may be entirely different elements -- they go inside one stable item element carrying az_key, where a conditional child may return bare element tuples. Containers whose child tag is fixed anyway (ul/ol) lose nothing, and a div with display: contents keeps the wrapper out of layout elsewhere.

That wrapper is not legal in every container, and the two where it is not are the two where a varying sibling tag is most legitimate. Inside table/tr the parser foster-parents a wrapper out of the table entirely -- the th/td land in the row, the az-key ends up on a stray div before the <table>. Inside select, Firefox drops it while Chromium keeps it, so the idiom silently works in one browser and freezes the list in the other. Both verified in real browsers. ul/ol is the only container whose child tag genuinely was never free.

Supporting a varying item tag would mean a distinct template per item, giving up the shared statics and reducing a branch swap to a full item re-render.

A tag written as an expression rather than a literal atom reaches this from the other side, and earlier: extract_element/1 raises computed_element_tag (a literal of the wrong type, a binary say, still gets the form error invalid_element). It carries the same idiom, because it is the same constraint seen from the tag rather than from the callback body.

compile_each also rejects a bare list whose item is a template or descriptor ([?html(...)], [?stateless(...)]) -- the item lands in the same fragile value slot -- while keeping a list of elements or a static/dynamic-text fragment. Map plain values with a list comprehension / lists:map (no per-item diffing); put a conditional inside an element child ({li, [], [case ... end]}). The reverse is a compile error too: a comprehension in a content slot builds its list at render, as one value, so an element-shaped body ([row(X) || X <- Xs], [{li, [], [X]} || ...], [?html(...) || ...]) is element_in_comprehension (is_lc_element_body/2, checked where the helper-inlining walk reaches the comprehension). A comprehension handed to a function stays data and is untouched.

The callback may be an inline fun or a local single-clause function reference (fun row/1, or fun row/2 for a stream/map). compile_each looks the function up in the module's forms (collect_fun_defs/1) and inlines its clause through the same path, so the identical body rules apply (a non-element body still raises each_body_not_element/each_stream_body_not_element). Inlining orphans the named function, so parse_transform/2 injects -compile({nowarn_unused_function, ...}) and -ignore_xref(...) for the consumed pairs -- the function needn't be exported or otherwise used. A same-module explicit ref (fun ?MODULE:row/1, module literal = the current module) is rewritten to the bare local form and resolved identically. Rejected: a genuinely remote reference (each_remote_fun_ref -- another module's, or a variable module, body isn't visible to inline; an -imported function referenced as a bare fun row/1 falls through to each_named_fun_undefined since it isn't in FunDefs) and a multi-clause function (each_named_fun_multi_clause -- clauses would select different per-item structures, but there is only one shared per-item template); collapse multiple clauses into a case inside the returned element.

A whole-body backend wrapper -- ?html(...) (or ?native/?terminal matching the each's target) -- is accepted: compile_each unwraps it to the element it wraps and builds the same per-item template as the bare element (both go through compile_body_parts/4 + scope_az/4 with the same fingerprint, so the result is byte-identical). The two callback forms reach compile_each as different AST: a named ref resolves to its untransformed clause, so its body is a raw arizona_template:html(...) call, unwrapped to its argument; an inline fun's ?html was already compiled bottom-up into a template-map literal, taken directly (re-wrapping its d-list into the per-item fun). A wrapper as a list item ([?html(...)]) is not unwrapped -- only a whole-body wrapper -- so it stays rejected like a wrapped descriptor.

A per-item component -- a descriptor inside the item element, {li, [], [?stateless(...)]} -- compiles, renders, and diffs. A ?stateless child keeps the positional per-item path: an inner value change ships one OP_LIST_PATCH carrying an OP_ITEM_PATCH for the affected index, and an append ships an OP_INSERT. A ?stateful child also renders and diffs, but it costs that path: diff_each_items/6 patches positionally only when the render added no child view (map_size(NewLocal1) =:= map_size(NewLocal0)), because a child view must be re-mounted by a full re-render. A plain list bearing per-item ?stateful children therefore falls back to the wholesale marker OP_TEXT, re-rendering every item on any change. When you want self-diffing children, use a stream ?each: items are keyed by az_key, each ?stateful item is its own self-diffing live view (an entry in the root live process's views map, not a process of its own), and merge_stream_child_views/4 carries the child ids incrementally across diffs. A child lives exactly as long as its item is in the DOM: whichever way the item leaves -- delete/2, reset/2, a new stream bound over the slot, a drop eviction, or a sort or insert pushing it out of a limit window -- its child unmounts, and an item that comes back into the window mounts a fresh one. A key deleted and re-inserted within one update keeps its child (the new render reaches it through handle_update/3, not mount/1), while the client gets an OP_REMOVE and a fresh OP_INSERT, so state the browser holds inside it (?local values, hooks) starts over.

A keyed container reached through a ?stateful child stays per-item, but by a different route. diff_stream/4 derives its ops by draining the stream's pending op log, and arizona_eval clears that log before rendering a child (clear_stream_pending/2, which is what stops a prop-fed child accumulating one queue entry per root update for the life of the process). So a container under such a child has an empty log while its order HAS changed: draining alone emits nothing, and the container silently never updates. diff_dynamics/4 therefore takes the incremental path only when the log can account for the difference -- an unchanged order needs no ops, a changed order needs a non-empty log -- and otherwise reconciles the two key orders through stream_reset/8, which needs no log at all: dropped keys are removed, kept keys patched only where their dynamics differ, new keys inserted, and the minimal moves emitted from the LIS.

The reconciliation diffs against the state the enclosing walk already evaluated, so it renders nothing extra: dropped keys are removed, kept keys patched only where their dynamics differ, new keys inserted, and moves emitted from the LIS. An earlier version handed the source to stream_reset/8 instead, which rendered the whole list a SECOND time -- twice the work, and it re-ran every item child's mount/1 / handle_update/3, so a child that subscribes or arms a timer in mount did it twice per diff. Measured on a container under a ?stateful ancestor: 258 bytes flat at 100 items and at 400, and 1,2 ms per diff at 400 heavy items.

A map-source ?each patches positionally like a list. maybe_single_root_opt/4 stamps single_root by the item BODY (one top-level element) rather than the source kind, so the 2-arg each carries it too -- that one compiled template serves both a stream and a map, and a stream never reads the flag (it keys items by az-key and diffs through diff_stream/4, which has no positional walk to gate).

Positional patching is always correct -- position N is item N in both the DOM and the new render -- but not always the smaller patch, and that is what outgrows_re_render/3 decides. Insert or remove anywhere but the tail and every later item patches with its neighbour's content, so the ops grow with the list while the wholesale re-render stays one op. Erlang iterates a small map in term order, so a mid-container insert is the common case there, not the exotic one.

Counting how much of the container changed cannot decide it. The three per-op costs are structurally different: an ?OP_INSERT carries a whole rendered item, statics included; an ?OP_ITEM_PATCH carries values plus framing; an ?OP_REMOVE is a couple of integers. Wholesale ships the statics ONCE plus every item's values. A count therefore assumes a statics-to-value ratio, and the real crossover moves from a few percent to nearly all of the container across ordinary shapes. Two concrete failures of the count form: a constant tuned on inserts overcharges positional-on-remove by exactly one statics block, so an early remove was re-rendered wholesale when staying positional was 4.5x smaller; and a shrink whose surviving prefix is unchanged produces no item patches at all, so no threshold could ever trip it, leaving a 200-item container shipping 198 removes where one re-render was 2.2x cheaper.

Before any of that, the walk strips what did not change. diff_list_positional/5 removes the common prefix and, when the lengths differ, the common suffix, and diffs only the middle. Without it a head insert reads as "every position differs" and emits one item patch per item plus a tail append -- each patch carrying the value of an item that merely shifted along. With it the same change is a single ?OP_INSERT at the position the item actually goes, at any container size, and a single delete is one ?OP_REMOVE. Sub-op indices address the OLD positions, which is what the client resolves them against: applyListPatch snapshots the item roots before applying anything, so an insert at N lands before whatever was at N, and repeated inserts at one index keep their emitted order. The suffix strip is skipped at equal lengths, where it costs two reverses and cannot produce a pure insert or remove.

The same question is asked of a keyed stream, which had no size decision at all: every ?OP_INSERT carries a whole rendered item, so a bulk grow re-sends the item statics once per item where one container re-render sends them once in total. Measured on a 554-item stream, a grow from 31 items shipped 85.046 bytes against a 14.325-byte re-render. Two differences from the list case. The drain does not visit every item, so there is nothing free to accumulate and the wholesale side is estimated from the old items' average size against the new visible count. And a stream carrying child views is never collapsed: the incremental path keeps their bookkeeping (merge_stream_child_views/4) while the container re-render has none, so collapsing would re-mount every child and reset its state. That holds for the children the drain itself rendered too, even when the old items held none: the re-render would evaluate those items a second time and mount each child again, leaving the drain's instance never unmounted.

That mostly retires the fallback for insertions -- one op is never worth re-rendering a container for. What still reaches it is a long run of removes, which no amount of stripping collapses.

Both sides are then estimated in bytes. The wholesale side costs nothing extra to obtain: diff_list_positional/5 already visits every new item, so it accumulates their value bytes as it walks and returns them beside the ops. wire_bytes/1 approximates the JSON size of the ops without encoding them -- only the ratio matters.

The comparison is biased toward staying positional, because those ops also preserve the DOM: the container is never torn down, so focus, scroll position and ?local values inside it survive. Wholesale has to be clearly smaller (a 3:2 ratio) and to save something worth having in absolute terms (?RE_RENDER_MIN_SAVING). The absolute floor matters because a short container can be twice as cheap to re-render while the saving is a hundred-odd bytes, which is not a trade worth losing an in-progress selection over -- so a 10-item list keeps its per-item ops and a 200-item one does not.

API -- effect commands (arizona_js / arizona_android / arizona_os / arizona_effect)

Client effect commands, built per platform -- arizona_js (web), arizona_android (native), arizona_os (native-shell OS capabilities) -- all returning the neutral tuple {arizona_effect, [OpCode, ...Args]} (type arizona_effect:cmd(), encoded by arizona_effect). Used in two contexts:

Template attributes -- commands embedded in az-click, az-submit, etc.:

{'button', [{az_click, arizona_js:push_event(~"inc")}], [<<"+">>]}
{'button', [{az_click, [arizona_js:push_event(~"inc"), arizona_js:toggle(~"#modal")]}], [<<"Both">>]}

Auto-encoded to HTML-safe JSON by arizona_template:to_bin/1. No explicit encode call needed.

Handler effects -- returned from handle_event/3 and handle_info/2:

handle_event(~"inc", _P, B) ->
    {B#{count => Count + 1}, #{}, [arizona_js:set_title(~"Updated")]}.

Commands:

  • push_event/1,2 -- send event to server (template-only). Without explicit payload, auto-collects from element context (see below). Explicit payload is merged on top of auto-collected (explicit wins).
  • toggle/1, show/1, hide/1 -- element visibility via hidden attribute
  • add_class/2, remove_class/2, toggle_class/2 -- CSS class manipulation
  • set_attr/3, remove_attr/2, toggle_attr/2,4 -- attribute manipulation (toggle_attr/2 presence toggle: add if absent, remove if present; toggle_attr/4 flips between two values)
  • dispatch_event/2 -- dispatch CustomEvent on document
  • navigate/1,2 -- SPA navigation, replaces the root view (opts: #{replace => true})
  • patch/1,2 -- in-place SPA navigation, keeps the root view and re-renders it via handle_update/3
  • fetch/2 -- HTTP request via the browser fetch(), no page reload (opts: method, body, headers, credentials, on_error, keep_alive). The only mode that can set a real Set-Cookie (HttpOnly honored) without navigating, so it suits flows that rotate a session cookie while keeping form fields and showing inline validation. Hits a controller route (e.g. {post, ...}) that returns the {"e": [...]} effects payload via arizona_controller:reply_effects/1; those effects are applied (against the trigger element) whenever the body parses, even on a 4xx (so the controller can use a real status and still drive inline validation). To re-render the submitting view, return an arizona_js:push_event in the response -- the client relays it over the existing WebSocket and the view re-renders via handle_event (no pubsub); broadcast over arizona_pubsub only to reach other views. on_error (plus an arizona:fetch-error DOM event) runs only when there is no usable effects body -- a non-JSON page, an empty non-2xx, or a network failure. A redirect is an arizona_js:navigate effect (arizona_controller:reply_redirect/1), not an HTTP 3xx. keep_alive (default false) maps to the browser's fetch(url, { keepalive: true }) so a request fired just before a navigation completes instead of being cancelled (browser inflight-body cap ~64KB).
  • transition/1,2 -- wrap a command (or list) so its DOM change animates in a view transition (opts: #{types => [binary()]}); see "View transitions" below
  • focus/1, blur/1 -- focus management
  • reset_form/1 -- reset (clear typed fields on) every form matching the selector, imperatively (unlike az-form-reset, which fires only on a successful submit/fetch); a non-form match is a safe no-op
  • select/1 -- select (highlight) the first matching <input>/<textarea>'s text (HTMLInputElement.select); a non-input match is a safe no-op
  • copy_to_clipboard/1 -- copy the first match's value (form control) or textContent to the clipboard (navigator.clipboard.writeText); needs a secure context + user gesture, so it is an event command only (not a handler effect); a missing/blocked clipboard is a safe no-op
  • show_modal/1, close_modal/1 -- open the first matching <dialog> as a true modal (showModal, top layer + ::backdrop + ESC-to-close, unlike the open attribute) / close it (close); a non-dialog match is a safe no-op
  • scroll_to/1,2 -- scroll element into view, smoothly by default; scrollIntoView opts merge onto that default (#{block => <<"center">>} stays smooth, #{behavior => <<"auto">>} jumps)
  • set_title/1 -- set document title
  • reload/0 -- reload page
  • encode/1 -- encode single cmd or list of cmds to HTML-safe JSON binary (called automatically by to_bin)

Native-shell (OS) commands (arizona_os). When the app runs inside a native shell (Tauri/Electron/...), arizona_os builds OS-capability commands -- all through one generic op ?EFFECT_OS carrying a capability name plus args (the shell owns the vocabulary; new capabilities are new names, not new op codes): set_title/1, focus/0, minimize/0, maximize/0,1, fullscreen/1, notify/1,2, capture_protection/1, command/1,2. Negotiated via the _az_caps connect param and read server-side with ?capability(Name); a safe no-op in a plain browser. See os.md.

Selector targeting: the broadcast commands (toggle/show/hide, the *_class and *_attr ops, reset_form) act on all elements matching the selector; focus/blur/scroll_to/select/copy_to_clipboard/show_modal/close_modal act on the first match.

Payload auto-collection (push_event): When push_event fires on an element, the client auto-collects a base payload from the element and event context, then merges explicit payload on top:

  • Drop events → {data_transfer: "dragged-key", drop_index: N} -- drag data from dataTransfer and the position, among the az-drop container's [az-key] children, of the child the drop landed in; -1 when it landed on the container itself (an empty column, the gap after the last item). The whole container is the drop zone, so an empty one accepts a drop. Only an item drag is claimed -- one whose dragstart began on an [az-key] element in the page: any other drag (text or a file dropped onto an input in the container's header, onto its blank space, or anywhere outside a container) is the browser's own, and dragover/drop are left uncancelled
  • Forms → new FormData(form, submitter) as a map -- all form field values keyed by name; when the form was submitted by a named submit button, that button's name/value is included too (the native FormData submitter arg), so a form with two submit buttons reports which one fired. A non-submit trigger (a plain az-click button) has no submitter, so it never drags the enclosing form's fields along -- gathering is a property of submitting the form (the Other case below stays {}). A repeated name is a list. Three kinds of field are lists by their markup, whatever is selected -- ["a"] for one value and [] for none, never a bare string or a missing key, so a handler matches one shape: a <select multiple>, a checkbox group (more than one checkbox sharing a name), and any field whose name ends in []. The [] suffix is the one to use for a group whose rendered size comes from data: a group that can render a single box has no second box to mark it as a group, so name="tags" would send a string with one tag and a list with two, while name="tags[]" always sends a list. The key keeps its brackets ("tags[]"), exactly as a native form POST or a fetch body sends it (the server's query-string parser keeps keys verbatim). A lone checkbox without the suffix stays a single field (its value when checked, absent when not), and a disabled field is left out as the browser leaves it out of a native submit. Behaviour change: a checkbox group or multiple select with one value used to arrive as a bare string, and with none as a missing key; a handler matching those shapes must now match a list
  • Inputs/selects/textareas → {value: el.value} -- current input value; a checkbox/radio adds checked, and a <select multiple> sends value as the list of selected values
  • Other → {} -- empty

This means az-keydown or az-focusout on an input automatically includes {value: "typed text"}, and az-drop automatically includes {data_transfer, drop_index}. No special attributes needed.

Op codes defined in include/arizona_effect.hrl -- integer constants shared with the client JS runtime. Same codes for both template commands and server effects.

Key filtering via on_key/2 -- wraps a command so it only executes when the pressed key matches:

%% Atom -- literal match
{az_keydown, arizona_js:on_key(enter, arizona_js:push_event(~"submit"))}
%% List -- match any
{az_keydown, arizona_js:on_key([enter, escape], arizona_js:push_event(~"close"))}
%% Binary -- regex pattern
{az_keydown, arizona_js:on_key(~"^[a-z0-9]$", arizona_js:push_event(~"type"))}

View transitions

A view transition wraps any DOM change in document.startViewTransition; it is not tied to navigation. The API is mostly CSS -- the framework only starts the transition. Requested per-trigger (no global switch) by arizona_js:transition(Cmd[, Opts]) (wraps the command whose DOM change should animate, like on_key/2; Cmd is one command or a list) or the az_transition attribute on any triggering element (link or az_click/...; bare = cross-fade, {az_transition, ~"slide back"} = space-separated types).

The client picks sync vs async from the wrapped command:

  • Sync effect (toggle/add_class/...): wrapped in place immediately.
  • navigate: the page swap is a future OP_REPLACE (op 8); the intent is held in _pendingTransition (kind: 'replace') and the worker message handler wraps that batch (a stray text/attr tick is ignored).
  • push_event: the resulting diff is a future message; _pendingTransition (kind: 'any') wraps the first response batch and is then consumed either way (a no-diff event can't dangle onto a later one). A concurrent server push (e.g. a timer) could race; navigation and sync effects are race-free. (Same-message server diffs still can't be wrapped -- the client applies ops before effects -- so a handler-returned transition pairs with navigate/push_event.)

Wrapping a mix of sync and async commands animates the async result; sync siblings apply immediately. The wrap is applied at the worker message handler, so a message's ops and effects animate together, in order. By default the whole root cross-fades; a view-transition-name on an element scopes/morphs just it (and must be unique among rendered elements or the browser skips it).

Guards: no-op (instant swap) when startViewTransition is absent or prefers-reduced-motion; types use the object-form call only when :active-view-transition-type() is supported. Back/forward is per edge: a transitioned nav stamps both ends of the edge it creates (the outgoing entry's _azTransitionNext, the new entry's _azTransitionPrev), every SPA entry records its position (_azIdx), and popstate replays the transition of the edge it crossed -- so traversing that edge animates in either direction, and Back over a plain edge into an entry that was reached by a transition does not. Cross-document (real <a href>) navigations animate via the user's @view-transition { navigation: auto; } CSS with no framework code. All styling (view-transition-name, ::view-transition-*, :active-view-transition-type) is user CSS. See .claude/rules/js.md for the client surface.

Client-owned slots -- ?local

A ?local(Key, Init) slot is static to the server, dynamic to the client: the server renders Init once at SSR and marks the slot diff => false so it is never patched; the browser owns the value (keyed by Key) and mutates it locally with no WebSocket round-trip. For UI-only state (dialog open/close, tabs, toggles) the server doesn't need.

Authoring (?local = arizona_template:local/2, also az:local/2):

{'span', [], [?local(~"title", ~"Hello")]}                 %% content
{'dialog', [{open, ?local(~"modal_open", false)}], [...]}  %% boolean attribute
{'div', [{'data-active', ?local(~"tab", ~"home")}], [...]} %% valued attribute (CSS-driven tabs)
{'p', [], [~"Name: ", ?local(~"first", ~"Ada"), ~" ", ?local(~"last", ~"Lovelace")]} %% many content slots + static text
{'a', [{href, [~"/u/", ?local(~"id", ~"1"), ~"/edit"]}], [...]} %% interpolated attribute (static + one local)

Compile-time constraints: Key must be a literal binary or atom (an atom normalizes to a binary); a key can't bind both content and an attribute on one element; not allowed in ?native templates. Under az-nodiff only the element holding a content ?local gets an az and that slot's marker; a layout's local sits outside every view, so set_all reaches it. A content ?local does not have to be the sole child -- an element can hold several content slots, freely mixed with static text and other dynamic children. An attribute value may also interpolate one ?local with static text around it (the statics become a prefix/suffix); two ?local in one attribute, or a ?local mixed with a server-owned dynamic there, are compile errors (local_attr_multiple / local_attr_mixed). Interpolation is for string-valued attributes (class, style, href, data-*, value); a boolean attribute must use a whole-value ?local ({disabled, ?local(~"d", false)}, which renders false → absent / true → bare) -- an interpolated value always renders name="value", so an interpolated boolean would be disabled="false", i.e. still present.

Mechanism: arizona_template:local/2 returns #{diff => false, az_local => Key, v => Init} (attributes add target => {attr, Name}). eval preserves it; arizona_diff skips it via its per-dynamic az_local clauses (never re-evaluated, never patched); arizona_render unwraps it. Each content slot is an individually comment-marked text node (<!--az:X-->Init<!--/az-->), exactly like any dynamic text child. The parse transform bakes a self-describing az-local descriptor attribute (escaped JSON {c: {slotIdx: key}, a: {attrName: key}, ap: {attrName: [prefix, suffix]}}) onto the element -- c maps each content slot's dynamic-slot index to its key, a maps each attribute name to its key, and ap (present only for interpolated attributes) carries the static prefix/suffix. The client scans [az-local] live (no persistent index): for a content slot it reconstructs the marker az from the element's runtime az + the slot index (mirroring arizona_html:text_az/2); for an interpolated attribute it recomposes prefix ++ value ++ suffix on set and strips them on read. An interpolated attribute's composed initial value is baked into the bind-map's v ([Prefix, to_bin(Init), Suffix]), so SSR render and diff-skip reuse the whole-value attribute path unchanged.

Updating (event attributes / handler effects via arizona_js; never sent to the server -- op ?EFFECT_SET_LOCAL):

BuilderScope
arizona_js:set(Key, Value)closest view of the trigger (markup-only)
arizona_js:set(ViewId, Key, Value)a named view
arizona_js:set_all(Key, Value)every view (document-wide)

As a handler effect (returned from handle_event/handle_info), use set/3 or set_all/2: the closest-view set/2 resolves against the trigger element, which a handler effect runs without (applyEffects dispatches against <html>), so it is a no-op there.

Client JS API: arizona.set(viewId, key, value), arizona.setAll(key, value), arizona.get(key) / arizona.get(viewId, key). Content writes are text-only (a < in a value renders as text, never HTML -- no injection); attribute writes reuse setAttribute/removeAttribute (with value/checked property sync): boolean true => present, false => absent.

Caveats (by design):

  • Wholesale re-render resets it. A ?local value survives normal per-slot diffs, but an enclosing subtree re-rendered as a unit (an OP_TEXT replacing a whole slot, OP_REPLACE, an ?each item swap, a conditional template switch) recreates the slot at its SSR initial -- unless it sits inside an az-nodiff element that is kept.
  • Inside ?each, items share the slot key. ?local keys are compile-time literals (you can't build ?local(<<"open_", Id/binary>>, ...) -- that errors local_key_not_literal), so every item rendered from the same template carries the same key and set/set_all updates all of them at once. ?local therefore can't hold per-item independent client state in a list/stream -- use server state for that. (An item reorder/remove is also a wholesale re-render that resets that item's slot.)
  • Forced reconnect resets it. A non-1000 socket close fresh-mounts the view.
  • Server never reads the value. To use it server-side, read it client-side (arizona.get) and include it in a push_event payload -- auto-collection ignores [az-local].
  • No JS = frozen at initial; get returns strings (no type preservation).
  • az-hook updated() fires on a client set. A bound element carrying an az-hook sees updated() for changes the server never observed -- treat that hook's state as client-local too.
  • Accessibility is the author's job. Driving a presentational attribute (open, data-active) does not touch ARIA. A native <dialog open> is announced for free, but the CSS-tabs pattern above sets no aria-selected -- and since ?local writes a literal value (no expressions), per-element ARIA that depends on the slot needs one boolean slot per element or server-tracked state.
  • Dangerous attributes are the author's responsibility. Content writes are always text (never HTML), but binding href/style/an on* handler to author-supplied data is on you -- only the attribute name is fixed at compile time, the value is not escaped.

API -- arizona_roadrunner_router.erl

Roadrunner route compilation. A {reload, ...} entry adds the dev-mode SSE endpoint, and a build-opts variant supports hot-reload-safe rebuilds.

  • compile_routes/3 -- build the map-shape roadrunner dispatch from route specs and store it under the listener-scoped {arizona_roadrunner_dispatch, Name} persistent term (the key resolve_route/3 reads); threads build-time opts (e.g. compress => false) for replay across hot reloads
  • routes/1,2 -- expand specs into roadrunner's map-shape route entries without compiling (used by the listener boot path)

Route types:

  • {live, Path, Handler, Opts} -- live route, GET/HEAD-only (Opts may contain layouts => [{Mod, Fun}] and bindings => map())
  • {ws, Path, Opts} -- WebSocket endpoint
  • {asset, Path, {priv_dir, App, SubDir}} -- static asset from priv (served via zero-copy sendfile by roadrunner's built-in roadrunner_static)
  • {asset, Path, {dir, Dir}} -- static asset from absolute directory
  • Either asset form is GET/HEAD-only (roadrunner_static serves the file whatever the method, so any other verb gets 405 + Allow from the router); a cors/1 transform on it also lets OPTIONS through so the preflight reaches the transform.
  • Either asset form compiles to Path/*path, so the catch-all is generated and never appears in the route list. Matching is first-match-wins in list order, so a shorter asset path listed ahead of a longer one it prefixes swallows it -- order the longer prefix first.
  • {Verb, Path, Handler, Opts} (Verb = get/post/put/patch/delete/head/options) -- single-verb controller; {match, Spec, Path, Handler, Opts} covers multi/custom/any-method (Spec = a verb, a list of verbs, a custom uppercase binary like ~"PROPFIND", or '*'). Behind the Arizona middleware pipeline (CSRF check_origin on by default); Opts is controller_opts() carrying state/action/middlewares/check_origin. Dispatches through arizona_controller:dispatch/3, which calls Handler:Action/2 (the action option, default handle). A wrong-verb request gets 405 + Allow from the adapter's router before any middleware, and two routes may share a path with disjoint verbs for REST-style dispatch. (Declare mutations non-GET: a state-changing GET is CSRF-able regardless -- a cross-site GET nav carries a SameSite=Lax cookie but no Origin.)
  • {reload, Path, Opts} -- dev SSE reload endpoint (arizona_reload_controller, served as a controller loop reply by any adapter that supports the reload capability)

API -- arizona_stream.erl

Pure stream data structure -- create, mutate, and query streams. The #stream{} record is defined in src/arizona.hrl. All rendering/diffing of streams stays in arizona_eval.erl/arizona_diff.erl.

  • new/1,2,3 -- create a stream with key function, optional initial items, and optional opts (#{limit => pos_integer() | infinity, on_limit => halt | drop}; see "Limit modes" below)
  • insert/2,3 -- insert item (append or at position)
  • delete/2 -- delete item by key
  • update/3 -- update item by key
  • move/3 -- move item to new position (emits {move, Key, AfterKey} pending op)
  • sort/2 -- sort by comparator function (emits reorder pending op)
  • reset/1,2 -- clear stream or replace with new items (emits reset pending op)
  • to_list/1 -- return items in order
  • get/2 -- look up item by key (crashes if key not found)
  • get/3 -- look up item by key with default value
  • get_lazy/3 -- look up item by key, calls Fun() only if key is missing
  • clear_stream_pending/2 -- clear pending ops for stream keys in bindings
  • stream_keys/1 -- scan bindings for stream keys
  • pending_ops/1 -- the queued ops, oldest first (stamps stripped)
  • drain_mark/1 -- the mark a drain records in its snapshot: the stamp of the last queued op, or none when the queue is empty
  • undrained_ops/2 -- the ops a drain holding that mark has not consumed yet

Keys must be unique once rendered

An item is addressed on the wire and in the DOM by its key's text (arizona_template:to_bin/1): the key field of OP_INSERT/OP_REMOVE/OP_MOVE/OP_ITEM_PATCH and the item's az-key. So keys that are distinct terms but render alike -- 1 and ~"1", a and ~"a" -- are two items to the stream and one address to the client, and an op meant for one patches the other. Give a stream keys of one type.

Drain marks

Each queued op carries a globally unique, monotonic stamp ({Seq, Op} inside pending). A drain records drain_mark/1 on its snapshot (drained key) and the next drain of the same slot calls undrained_ops/2 to resume just past it, so a queue that is re-drained emits only the new ops instead of replaying its history.

This matters because the queue is drained by the differ but cleared by the live process, which only reaches streams held as top-level bindings. A stream nested inside another value -- a field of a parent stream's item, or a stream inside a map binding -- is never cleared, so every re-eval of the enclosing slot re-drains it.

The resume locates the stamp rather than counting positions off the head. A stream is an immutable value and can have several divergent successors (a view that keeps a pristine stream and re-derives the rendered one per event), and position counting cannot tell such siblings apart -- it would drop a sibling's genuine ops. A stamp the queue does not contain (a fork, reset/1,2, clear_stream_pending/2, a rebuilt or rolled-back stream) falls back to a full drain, which never skips. The search runs backwards from the newest op and stops at the mark, or the moment it drops below it, so it costs O(ops added this cycle) rather than O(queue length).

That guard sits at one call site, so the other two ways into diff_stream/4 -- the dep-aware walk (diff_each/9, which diff/4 uses, i.e. production) and the stream-inside-a-stream-item walk (diff_item_dynamics_v/3) -- reached the drain straight and dropped the change: on identical input diff/3 emitted the removals and diff/4 emitted nothing. diff_stream/4 now reconciles an empty drain itself (drainable_ops/3 substitutes a {reset, #{}}), so every entry point agrees. The two mechanisms are complementary rather than redundant: stream_relist/7 diffs against the state the enclosing walk already evaluated, which diff_each/9 does not have.

What a full drain assumes -- and the genesis reset that keeps it true. A drain replays a log against what the client actually holds, so a full drain is correct only when the queue's own starting point IS that state. It is for the flow the fallback was written for: the live process clears a top-level binding's queue right after each drain, so the queue restarts exactly where the client is. A constructed stream is the case where it is not -- new/1,2,3 starts from an empty collection, so queueing its items as pending INSERTS described the delta from empty. Bind such a stream over a slot that already holds one (rather than mutating the stream that is there) and those inserts were all the differ had: keys the new stream dropped were never mentioned, and keys present in both were skipped as replays, so a shrink, a reorder and an in-place content change each diffed to NOTHING and the stale list stayed on screen with no self-heal. The constructors therefore open the queue with a genesis reset (reset) -- the op that already means "reconcile the client to my current state" -- which makes the log truthful for any consumer. It costs the incremental path nothing: a resume that finds its mark skips past it, and replaying it in a re-drain is idempotent.

A log that reorders drains as one reconcile. The drain replays ops one at a time against what the client holds, and the one thing it cannot always follow op by op is order: a reorder (sort/2) carries no order of its own, so the only order the drain can see is the final one, not the one the client has reached by that point. A positional op after it was placed against an order the client did not hold yet, and a second sort then found the client "already" in the final order and moved nothing. So a drained log holding a reorder or a reset is replaced by one reset (drainable_ops/3): remove what left, patch what stayed, move the stayers into order, then insert each arrival at its final index. A log of sorts alone needs no item walk and drains as one reorder from the client's order to the final window instead. A move and a positional insert are different: each is placed by where it sits in the source when it is queued (move/3 records the key it lands after, insert/3 an index), so on an unlimited stream, whose client holds the whole source in order at every step, they replay op by op exactly. On a limited stream hidden keys count toward that placement but are not on the page, so there they take the reset too. An unlimited stream takes it for the same reason when a positional insert or a move was queued while a key the event inserts and deletes again was in the source: the drain never sends that key, so an index that counted it lands one slot off and a move anchored on it lands at the tail. An append, or a positional op queued after the delete, counts no such key and stays op by op. A log of appends, deletes and updates keeps the op-by-op drain, and a move/3 to the position a key already holds queues nothing. The invariant, checked by a randomized model test in arizona_stream_SUITE: after a frame's ops the client shows exactly an SSR of the stream's final state.

A reset measures kept items against the snapshot, not against the pre-reset source. The reset op carries no items. The drain re-renders a kept item only where its fields differ from the item the client's DOM was rendered from, which is the source the stream's snapshot keeps (every stream snapshot does, the post-drain one included). The source as it stood just before reset/1,2 is not that item whenever the same event touched it first: reset/2 rebuilds the queue, so an earlier update/3 is gone by the drain, and measuring from the updated item found nothing to ship.

Each key renders once per drain, from its final item. One event can queue several ops for a key -- an insert then an update, two updates, an insert, a delete and an insert again -- and rendering the item once per op ran every ?stateful child in it once per op as well, each time against the views from before the diff: a second render mounted a second instance (the first was never unmounted) or ran handle_update/3 again from the pre-event state. So the op-by-op drain renders an item from the stream's final source (which is already the post-op state) the first time an op needs it, and a later op for that key reuses that render: an update of a key the drain already rendered has nothing left to patch, and a re-insert ships the same render again. The client still receives every op the log implies, in log order, so positional ops placed against the intermediate order keep their meaning. An update re-renders against the item the client's DOM was rendered from (the snapshot's source, as for a reset), so the skipped dynamics are those that every update of the key in the event left alone; the queued {update, Key, Item} op therefore carries no changed-field set of its own.

The queue of an unclearable nested stream still grows, one retained op term per mutation. Keep a stream that mutates over a long-lived session as a top-level binding.

Limit modes -- limit + on_limit

limit is a visible window, not a hard cap: items past it stay in the source, invisible. on_limit decides only what happens to the source when a full stream is appended to:

  • infinity (the default) -- no cap, every item visible.
  • {Limit, halt} (on_limit => halt, the default when limit is set) -- once Limit items are present, further items are kept in items but stay outside the window.
  • {Limit, drop} -- a bounded tail -f buffer. An append (insert/2, or the update/3 upsert of a missing key) to a full stream evicts the oldest (front-of-order) item from the source, so the window slides onto the new tail. Only appends evict: "oldest" is well-defined only for the append flow, so a positional insert/3 (a window position, not an age) and pre-population (new/3, reset/2, which set the whole order at once) keep halt semantics. An append to an overfull drop stream evicts down to the limit, so it converges to size =< Limit. Each eviction queues a {delete, Key}, so a key evicted and re-appended in one event reaches the client as a removal and a fresh insert rather than as a replay of a key it already shows.

Window reconciliation is shared by both modes (apply_limit/6 in arizona_diff). By the time the diff runs, order already reflects the halt/drop retention decision, so reconciliation is purely: remove the DOM items that fell out of the window, and insert the newly-visible ones at their ordered position. Positional back-fill is what lets a delete slide the next hidden item into the freed slot and a sort bring a hidden item into view at the right spot (appending at -1 only happened to be right when the new item was last in the window). window_unchanged/4 is the allocation-light fast path: when the new window equals the pre-frame one element-wise and the snapshot holds exactly those keys, the whole remove/back-fill pass is skipped -- the common case of a content update to an already-visible item.

API -- arizona_watcher.erl

Behaviour for the file-system watcher behind the dev reloader. Core ships no implementation, so the file-system dependency stays out of every build that does not reload code; arizona_watcher_fs (a separate package on the fs library) is the reference one, added to a dev-only rebar3 profile and named in the reloader env:

%% rebar.config
{profiles, [{dev, [{deps, [arizona_watcher_fs]}]}]}.

%% config/dev.config
{arizona, [
    {reloader, #{
        enabled => true,
        watcher => arizona_watcher_fs,
        rules => [
            #{directory => "src", patterns => [".*\\.erl$"],
              callback => fun arizona_reloader:reload_erl/1},
            #{directory => "priv/static", patterns => [".*\\.css$"],
              callback => fun arizona_reloader:reload_css/1}
        ]
    }}
]}.

watcher is a module or {Module, Opts} (an Opts map handed to init/2). An implementation is a callback module run inside the rule process, not a process of its own:

  • init(Dir, Opts) -> {ok, State} | {error, Reason} -- start watching the absolute Dir, delivering events to the calling process

  • handle_info(Info, State) -> {ok, Paths, State} | {stop, Reason, State} -- translate one message into the paths whose content changed (created, modified, deleted, renamed into the directory); [] for anything else, including a directory rename or a file moved out. stop reports a dead watch: the rule stops with Reason and is restarted

  • terminate(State) -> ok (optional) -- release what the watch holds; runs on every stop, supervisor shutdown included (the rule traps exits), within the 5 s shutdown timeout

The rule traps exits, so a process the implementation links to it that exits abnormally stops the rule with that reason. Rules with no watcher, a watcher not on the code path (its package is not a dependency of the running profile), or a rule without directory fail the boot with a formatted error naming the fix. An enabled env with no rules needs no watcher (code still reloads through the dev MCP reload tool).

API -- arizona_reloader_rule.erl

One process per reloader rule, from child_specs/1, under arizona_reloader_sup. That supervisor is a temporary child of arizona_sup, so a watch that keeps crashing exhausts the reloader's restart intensity (5 in 10 s) and stops hot reload, not the application.

  • start_link(Dir, Opts) -- starts a linked gen_server that runs Opts' watcher on Dir. Options: watcher (required), patterns (list of regex strings, default [".*"]), callback (fun receiving the list of changed file paths), debounce (ms, default 100). On debounce fire: calls the callback, then broadcasts {arizona_watcher, Dir, Files} (Dir the rule's absolute directory) via arizona_pubsub on channel arizona_watcher
  • child_specs(Reloader) -- the rule processes for a reloader env map, validating its watcher

API -- arizona_pubsub.erl

PubSub for cross-view communication. Thin wrapper around pg with scope arizona_pubsub. Channels are arbitrary terms. Messages are raw data (no wrapper tuple).

  • subscribe(Channel, Pid) -- subscribe Pid to Channel; ok, or {error, already_joined} when Pid is already subscribed. The membership check and the join are not atomic, so concurrent subscribes of the same pid from different processes can both return ok and join it twice (see the moduledoc); ?subscribe(Topic) subscribes the calling live process itself, which cannot race itself, so through the macro a topic is joined once. Every view in a live process shares its pid and therefore its membership: a second view subscribing the same topic gets {error, already_joined} (so do not match the result against ok), and one view's ?unsubscribe removes the subscription for all of them
  • unsubscribe(Channel, Pid) -- remove Pid from Channel; ok, or {error, not_joined} when Pid was not subscribed
  • broadcast(Channel, Data) -- send Data to all subscribers via Pid ! Data
  • broadcast_from(From, Channel, Data) -- same but excludes From pid
  • subscribers(Channel) -- return list of subscriber pids

API -- arizona_live.erl

Simplified gen_server wrapper:

  • start_link/4 -- start with handler module, initial bindings, transport PID, and on_mount hook list. The transport PID receives {arizona_push, ...} updates; pass undefined for a standalone (non-pushing) process. The live process is transport-agnostic and takes no request
  • start_link/5 -- as /4 plus the connection context the transport knows, #{capabilities => map(), reconnect => boolean(), push_barrier => boolean()} -- the first two are what capability/1, capabilities/0, and reconnected/0 read. /5 is the browser WebSocket path (arizona_socket:init/4 is its only caller in src/, always with push_barrier => true); /4 is the terminal transport (arizona_terminal_session). SSR starts no live process at all, so it calls neither
  • mount/1 -- calls Handler:mount(Bindings). Extracts ViewId = maps:get(id, Bindings), calls Handler:render(B1) via arizona_stateful:call_render/2, builds a snapshot via arizona_render:render_snapshot/2 (no document: the connect frame ships none, and evaluating the snapshot observes its command attributes). Returns {ok, ViewId} (no HTML -- SSR is handled separately by arizona_roadrunner_http). It does not check a value's renderability: SSR rendered the same template moments before, and a value only a connected render produces is refused by the first render that ships it
  • handle_event/4 -- unified event dispatch: handle_event(Pid, ViewId, Event, Payload). Checks views map -- if ViewId is a known child, dispatches to the child handler; if it is the root's id, to the root handler. Returns {ok, Ops, Effects}. An id that is neither is dropped ({ok, [], []}) rather than falling through to the root, so a crafted frame cannot address the root via a bogus id -- and logged as a warning, because the drop is otherwise invisible from both ends (no frame back, no view run). Legitimate causes: an in-flight frame for a view a diff just removed, an az-target naming a view that does not exist, or an az-view marker for a view that was never registered. The client never has to guess: it sends null for "the root view" and arizona_socket's event_target/2 resolves it against the root id the socket tracks, so a push_event handler effect, a hook outside any view, or arizona.pushEvent/2 is always recoverable -- a DOM-derived guess would not be
  • handle_info/2 -- gen_server callback for Erlang messages (Pid ! Msg, erlang:send_after, etc.). If handler exports handle_info/2, calls it, diffs, and pushes {arizona_push, RootViewId, Ops, Effects} to transport_pid. Pre-mount messages and handlers without handle_info/2 are silently dropped. Empty ops+effects are not pushed
  • navigate/3,4 -- navigate(Pid, NewHandler, InitBindings [, OnMount]). Tears the old page down in this order: cancel timers first (cancel_pending_timers/0 erases the whole $arizona_timers registry, cancels every ref, then drains already-delivered {arizona_view, _, _} messages from the mailbox), then unmount -- embedded child views, then the root, the same children-first order a diff removal and terminate/2 use, so every child's unmount/1 runs while the ROOT is still mounted. That guarantee is against the root only: views is a flat map walked with maps:foreach, so among nested views the order is the map's key order -- a nested parent may unmount before its own child. Then mounts the new handler (applying any OnMount hooks), resets gen_server state (handler, snapshot, views), preserves transport_pid and sent_fps, and returns {ok, NewViewId, PageContent}. The previous root handler's final bindings are carried forward as the floor for the new handler's mount input, minus arizona_eval:restricted_keys/0 -- those are route-bound, and do_mount requires the new handler's mount to keep them verbatim, so carrying them would force the new route to pretend it is the old one. InitBindings (route static config + middleware enrichments) overrides on key overlap. Keys the new handler omits from its mount return value are dropped on the next navigate. Stateful children's state (in views) is wiped: a child that was alive on the old page is gone unless the new page re-embeds it
  • patch/2 -- patch(Pid, Params). In-place navigation: keeps the current root mounted, runs its handle_update/3 with Params, and re-renders through the diff (no unmount, no remount, no timer cancel, no OP_REPLACE). handler, view id, and child views all survive. Returns {ok, Ops, Effects} -- the diff ops plus the reaction's effects (handle_update's, folded with any child's). Mirrors handle_root_event, but the reaction is handle_update instead of handle_event
  • dev_info/1(Pid) -- a live process's current view state for dev-time introspection (root handler/bindings plus each child view's), read via sys:get_state/1 so a wedged view still answers and nothing is added to the process's own protocol. Backs the dev MCP's view_state.
  • apply_on_mount/2 -- folds the on_mount hook chain: apply_on_mount(OnMount, Bindings). Each hook is fun((Bindings) -> Bindings) or {Module, Function}

Internal state: #state{handler, bindings, snapshot, views, on_mount, transport_pid, sent_fps} where views :: #{ViewId => #{handler, bindings, snapshot}} and sent_fps tracks fingerprints already shipped to the client for deduplication.

compute_changed/2 builds the Changed map by comparing old and new bindings key-by-key.

push/4 sends {arizona_push, RootViewId, Ops, Effects} to the transport PID -- RootViewId names the emitting page's root view. No-ops when PID is undefined or ops and effects are both empty.

Per-view messages and the timer registry

send/2 is self() ! {arizona_view, ViewId, Msg} -- messages are view-id tagged, so handle_info/2 routes a tagged message to the matching root or child view (an unknown id raises {unknown_view, ...}). An untagged message still reaches the root through the catch-all clause, but it can never address an embedded child, so ?send/?send_after are the API.

send_after/3 additionally registers its ref in the $arizona_timers process-dictionary map, #{ViewId => {Count, Limit, [reference()]}}. A fired ref is pruned when its message is delivered. A timer the app cancels itself with erlang:cancel_timer/1 (a debounce, an idle watchdog) delivers nothing, so send_after/3 also drops the view's dead refs when an arm would take the list past Limit -- twice the live count the previous prune found, with a small floor. Without that the registry would grow by one ref per re-arm for the page's lifetime; pruning on every arm instead would cost O(live timers) per arm, quadratic for a view arming many concurrent timers. The doubling keeps the list within 2x the live timers at O(1) amortized per arm. A removed child view's timers are cancelled synchronously (and its queued view messages flushed) on unmount; and navigate/3,4 cancels them all. A render that keeps a child's id but switches its handler (case ?get(k) of a -> ?stateful(a, #{id => ~"c"}); b -> ?stateful(b, #{id => ~"c"}) end) is a removal of the old instance too: its timers and queued messages are dropped before its unmount/1, and before the new handler mounts, so they never reach the new handler's handle_info/2 while a timer the new handler arms at mount survives.

What the navigate sweep does and does not guarantee. The sweep runs before the unmount callbacks, so it covers every timer armed during the old page's normal lifetime -- mount/1, handle_event/3, handle_info/2 -- and none of those can fire into the new page. It does not cover a timer armed inside unmount/1: cancel_pending_timers/0 has already erased the registry by then, send_after/3 re-creates it, and nothing sweeps it again before the new mount. Such a ref survives, and when it fires the new page's root id and views map do not know the old view id, so handle_info/2 raises {unknown_view, OldViewId, Msg} and the live process dies (or, if the new page happens to reuse the same view id, the stale message is delivered to a different handler). So do not arm timers in unmount/1 -- it is a teardown callback, and the ordering is chosen so the cancel cannot race a timer the old page armed while it was still running.

The same goes for a plain ?send from unmount/1, on a diff removal as well as on navigate: the removed view's queued view messages are flushed before its unmount/1 runs, so a message the callback sends to its own id lands after the view is gone and raises {unknown_view, ViewId, Msg} once the live process reads it. On navigate there is one exception, and it is worse: when the new page reuses the id (the same root id, or a child id it renders again), the message is delivered to the new handler's handle_info/2 instead. On terminate/2 the process is exiting, so the message simply dies with it. Release resources in unmount/1; never message the view being torn down.

Process-dictionary keys

The live process's dictionary holds the keys below, all read through accessor functions rather than directly. Some are arizona_live's own connection and scheduling state, the rest are set by the render/diff, which runs in the same process -- the "Set by" column is authoritative:

KeySet byRead by
$arizona_connectedset true while a transport is attachedconnected/0 (the ?connected macro) -- SSR vs live
$arizona_reconnectedset true when this connection is a reconnection; erased by navigate/3,4 (the new page's mount is its first), kept by patch/2 (same handler only; a cross-handler patch is a navigate)reconnected/0 (the ?reconnected macro) -- gate one-shot commands with ?connected andalso not ?reconnected
$arizona_capabilitiesthe _az_caps map the client advertised at connectcapabilities/0 / capability/1 (?capabilities / ?capability). A UI/effect hint, never authorization
$arizona_timerssend_after/3unmount and navigate/3,4, to cancel
$arizona_unmountedunmount_view/3, for every child view a pass unmounts while the state still lists it (a swapped-out handler, a diff removal, the outgoing page's children on navigate), and navigate for the outgoing root (under the key root); erased once the pass has unmounted the last of them (root and child diffs, do_mount/4, navigate/3,4)terminate/2, so a pass that raised after unmounting a view or the root (its own unmount/1 included) does not unmount it a second time
$arizona_mountednote_mounted_view/3, from arizona_eval after each child mount/1 of a pass the live process began (root and child diffs, do_mount/4); erased with $arizona_unmountedterminate/2, which unmounts the children a pass that raised had mounted, since no state it can see lists them
$arizona_depsthe per-dynamic eval bracketarizona_eval, arizona_template:track/1 -- the dependency capture set
$arizona_update_effectsarizona_eval:set_update_effects/1, seeded with the originating callback's effects before a diffcurrent_update_effects/0 while child handle_update/3s fold onto it; drain_update_effects/0 erases and returns the final list. Threading it here is what lets a child's effects join the root's with no concatenation at the call site
{'$arizona_scoped_statics', Prefix, Fp}scoped_statics/4 in arizona_templateitself -- a memo for the per-slot static prefixing (scope_static/3 on the render backend, per element), keyed by scope prefix + template fingerprint

Only $arizona_deps, $arizona_update_effects, $arizona_timers, $arizona_unmounted and $arizona_mounted are ever erased; the rest are set once and left. What makes the scoped-statics memo different is that it is the one entry that grows unboundedly rather than holding a fixed-size value. That is deliberate and safe -- prefixing a static list is pure and deterministic in (Prefix, statics), so a stale entry cannot be wrong, and the payoff is that a re-render or diff of the same slot (and every level of a deep stateless nest, whose inner statics would otherwise be re-walked once per ancestor) reuses the cached result. It is bounded by the number of distinct (slot prefix, fingerprint) pairs the process ever renders, and dies with the process; note it when reasoning about a long-lived live process's memory, and do not expect a navigate to clear it.

API -- arizona_socket.erl

Framework-agnostic WebSocket protocol state machine. The transport handler (arizona_roadrunner_ws) feeds frames in and ships return tuples out, so the wire protocol lives here independent of the server.

  • init/4 -- init(Handler, Bindings, Req, Opts). Traps exits, starts arizona_live:start_link/4 (passing self() as transport PID and any on_mount hooks), mounts. On reconnect (#{reconnect => true}), also renders and returns OP_REPLACE frame -- unless the client flagged fps_follow (the _az_fps_follow upgrade param): then the mount+render is DEFERRED until the client's cached_fps frame arrives, so the resync payload dedups statics against the announced fingerprints (a deploy-drain reconnect storm re-ships roughly the dynamics instead of every page's full statics). Any other first frame (or a ?RESYNC_TIMEOUT_MS backstop) flushes the resync undeduped first, the frame's own reply following it on the wire (reply_many); a client without the flag keeps the immediate resync. Opts: reconnect (boolean), fps_follow (boolean), on_mount (list of arizona_live:on_mount_hook/0). The route adapter for SPA navigate is recovered from Req via arizona_req:adapter/1
  • handle_in/2 -- decode incoming text frame: ping/pong, ["cached_fps", FpList], ["navigate", #{~"path" := Path, ~"qs" := Qs}] (replace), ["patch", #{~"path", ~"qs"}] (in-place, same-handler -> patch/2, else falls back to navigate), [target, event, payload]
  • handle_info/2 -- handle {arizona_push, ViewId, Ops, Effects} from arizona_live (ops tagged with the view id the push names), the arizona_resync_timeout backstop, and EXIT signals from the live process. On a synchronous event/patch/navigate reply, every push already queued in the socket mailbox up to the live process's barrier is taken, whatever view id it names, and shipped first (causal wire order), in the reply frame ahead of its own ops. For a navigate their ops ride ahead of the OP_REPLACE: one may carry the first statics of a fingerprint the replace then references bare, and the replace supersedes them anyway. Their effects are dropped and logged by op code (a client applies a frame's effects after its ops, so they would run against the new page), with a warning for a dropped navigate/patch that carried a flash, at any wrapper depth (transition, on_key, a fetch's on_error). Only this socket navigates its live process: arizona_live:navigate/3,4 are transport-facing (apps navigate through the arizona_js:navigate/patch effects)

Exit reason to close code -- four cases, plus the mount-failure close, and the difference matters because the JS worker treats any non-1000 code as reconnectable:

Exit reasonClose codeClient behaviour
{shutdown, drain}?CLOSE_GOING_AWAY (1001)Reconnects -- the deliberate drain path (RFC 6455 §7.4 "going away"); the main thread preserves form state
normal1000No reconnect
shutdown / {shutdown, _} (any other reason)1000No reconnect -- opt into the reconnect path by exiting {shutdown, drain} explicitly
anything else, after the view mounted (a crash)?CLOSE_CRASH (4500)Reconnects with no page reload: the worker's backoff reconnect remounts the view in place and the main thread restores typed form fields. A crash within 10 s of the view's last mount (the main thread tells the worker: a first connect's frame, a reconnect's resync, a navigate's OP_REPLACE) does not reset the backoff, so a view that crashes right after every remount keeps backing off; the native clients apply the same rule from their OP_REPLACE. Crash remount is deliberately not attempted server-side
any failure to mount the view?CLOSE_MOUNT_FAILED (4501)Reconnects, and reloads the page after a random 5-10 s delay (the page may be stale, e.g. after a deploy); a reconnect that mounts first cancels the reload. Consecutive reloads of one path are counted per tab, in one sessionStorage key holding {path, count} (a reload of another path starts over), so a failing path, which may carry a secret, never outlives the tab; only a successful mount removes it (pagehide and disconnect() just cancel the pending reload); from the 10th on the delay is 30 s, logged once per page load, and retrying never stops. Form fields saved on the close are lost by the reload, unless a reconnect mounts first and restores them. Never resets the backoff

An application stop takes the drain path: arizona_app:prep_stop/1 drains the arizona_http listener (its adapter's drain/2) instead of stopping it, so a restart or deploy closes every live socket 1001 and its clients reconnect -- to the new node once it is up. The listen socket closes first, each view gets handle_drain/2, and the stop waits up to the server config's drain_timeout (milliseconds, default 5000) for the sessions before killing what is left.

A dev reload closes the same way. While the node can broadcast a code reload (arizona_reloader:enabled/0: the reloader app env holds enabled => true and a rules list, or an arizona_dev_mcp route is mounted, whose reload tool swaps code without the watchers -- the router marks that at route compile, not at the first MCP session, so a view connected before the agent is covered too), each WebSocket session process joins the arizona_reloader topic at connect and closes 1001 on {arizona_reloader, reload}, so its client reconnects and remounts on fresh code. Without it a live view outlives every reload, and a fun held in its state (a stream's key fun, a fun prop, a closure the app stored) points into code the module's second reload purges: the next callback that calls it raises {badfun, _} and the socket closes 4500. The transport process joins rather than the live process, so the membership never meets an app's own ?subscribe, and the session exits on the first close, so a burst of broadcasts closes it once. A reload_css broadcast changes no code and closes nothing.

Browser pages close too. A page whose layout runs the SSE reload script (arizona-reloader.js, connected to a {reload, ...} route) reloads 50 ms after the event, well before the worker's first reconnect (backoff(0), about 1 s), so the close only costs that page a brief az-disconnected (a reload slower than that lets the worker reconnect first, one extra mount on fresh code that the reload then discards); a page without the script, which the socket cannot tell apart from a native client, reconnects and remounts instead of keeping the stale view. A production node (neither a reloader env nor a dev MCP route) reads one persistent term and the app env once per connection and joins nothing.

The mount sites that close 4501 on any raise: the connect's mount in init/4 (a first connect, and an unflagged reconnect's mount + render), the deferred reconnect resync (flush_resync/1, whichever leg settles it), and a navigate (including a patch that degrades to one) -- a crash exit under its call, whether in the new page's mount or the old page's unmount. Each covers encoding the frame that delivers the view, since until it arrives the client has no mounted view. A resync or navigate mount that crashes takes the live process down under the call, so it follows the dropped-frame rule below: the socket notes the failed call was a mount, and the queued crash exit then closes 4501 instead of 4500 (a live process already gone before the call closes per its own exit). A navigate/patch whose middleware halts without a redirect is not a mount failure but a deliberate refusal: the socket replies with a full navigation to the requested URL (arizona_js:navigate(Url, #{full => true})), so the browser loads it over HTTP and shows the middleware's own response at once -- a reconnect's upgrade, refused by the same middleware, never would. A native client takes a full navigate as a fresh connection to that path and query.

A raise inside the socket itself after the mount closes the same way as a crash: handle_in/2 and handle_info/2 each catch anything their handling raises -- a handler crash, or a frame that cannot be JSON-encoded, such as a value holding invalid UTF-8 -- and turn it into a logged 4500. The transport has no catch of its own, so without that the session would end with no close frame at all (a bare 1006). One raise is not a socket crash: a call into the live process that fails with the process no longer alive -- gone before the call, or exited under it (a drain landing while an event, navigate, patch or resync frame is in flight). The frame is dropped and the close is left to the queued 'EXIT', which arrives behind every push the process sent before exiting -- so a drain's handle_drain/2 effects still ship -- and then closes per the mapping above: 1001 for {shutdown, drain}, 1000 for a deliberate end, a logged 4500 for a crash (4501 when the failed call was a mount). A failed call to a live process that is still alive has no exit to wait for and is a crash, as is a noproc from any other process a middleware or route adapter calls.

Return type (result()): {ok, Socket} | {reply, iodata(), Socket} | {reply_many, [iodata()], Socket} | {close, Code, Reason, Socket}. reply_many exists for the deferred-resync flush: a non-cached_fps first frame produces the resync frame and its own reply, in that order, on one return.

The #socket{} record carries pid, view_id, handler, req, pending_flash, pending_resync -- the post-mount state needed to dispatch events and navigate. handler is the current root handler (so a patch frame can tell same-handler in-place patch from a full navigate); pending_flash holds a one-shot flash carried in-process across an SPA navigate/patch; pending_resync holds the backstop timer ref while a flagged reconnect waits for its cached_fps frame (undefined once flushed, or when the connection never deferred); layouts holds the connect route's layouts, so either frame kind can tell an in-place swap from one needing the whole page rebuilt; flash_replay holds the path whose middlewares produced pending_flash, when they did. Flash over a WS navigate requires a destination that stays on the socket: a target degrading to a full-page navigation destroys it (and a WS frame has no Set-Cookie leg), so a middleware-set flash is replayed by navigating to the stashed requested path instead of the target -- the halt re-runs over HTTP and redirects with a real flash cookie. flash_replay holds the redirect target beside that path so the reroute fires only for the redirect it belongs to, never hijacking an unrelated navigation. A handler-set flash, which nothing reproduces, is dropped with a logged warning. A gate on the navigation a flash was carried into treats it the way HTTP treats the signed cookie on a 3xx. A gate that only redirects puts it back on the socket for the redirect; a replayable one keeps its original path so a chain of gates replays from the first, but only when the gated navigation is the redirect it was armed for (a navigation that beat the follow-up frame is never rerouted through that gate). A gate that flashes replaces it, so the live path and the replay agree. A fetch_flash step that read it before the gate halted spends it (arizona_req records that read), as that read clears the cookie on the 3xx. A halt without a redirect is a full load like any other, replayed or dropped loudly. A handler effect's flash is captured from a bare navigate/patch and from one a transition wraps (both run when the frame lands); one an on_key or a fetch's on_error wraps runs later, if ever, so its flash is stripped with a warning rather than stashed for whatever navigation comes next. A flash opt that never passes through the socket -- an event attribute, a controller response -- has no carrier at all, so arizona_effect's encoders refuse it (flash_outside_live_effect) instead of shipping it to a client that would drop it. The route adapter is recovered from req on demand. mount_failed marks a dropped frame whose failed live call was a mount, so the queued crash exit closes 4501.

Op scoping happens at JSON-encode time, in two steps. flatten_ops/2 walks the nested [[ChildViewId, ChildOps] | ...] shape arizona_live produces and returns a flat list of {ViewId, RawOp} tuples, each tagged with its owning view -- it does no string surgery. op_encoder/2, the custom encoder passed to json:encode/2, then emits each op's target inline as "<ViewId>:<Az>". Doing the scoping in the encoder means the target string is built once, straight into the output iodata, instead of allocating a rewritten op list first. Inner ops of an OP_ITEM_PATCH stay item-relative and are not scoped.

Other internal functions: encode_reply/3 (build the #{<<"o">> => Ops, <<"e">> => Effects} JSON), flush_resync/1 + resync_then/2 (the deferred-resync flush), close_crash/1 and close_mount_failed/1 (close tuples), dispatch_event/4, handle_navigate/3 (drives SPA navigate by invoking the adapter's resolve_route/3).

SPA navigate / patch route resolution

There are two SPA navigation modes, picked per-link by the client:

  • navigate (az-navigate / arizona_js:navigate) -- replaces the root view: the live process unmounts the old root, mounts the new handler, and ships one OP_REPLACE of the whole view element.
  • patch (az-patch / arizona_js:patch) -- keeps the root view: the live process delivers the new route's params to the current root's handle_update/3 and re-renders through the diff, shipping only the changed slots. The process, view id, child views, and DOM (chrome, scroll, open menus) all survive. arizona_socket decides per frame: it tracks the current root handler on the #socket{}, and a patch whose path resolves to the same handler is applied in place (arizona_live:patch/2); a patch to a different handler can't keep the view, so it degrades to a full navigate/replace, and so does one whose bindings -- the route's, after its middlewares -- name an id other than the live view's (two routes on one handler with different ids are two views; the replace reuses those bindings, so no middleware runs twice). Persistent live chrome is therefore an ordinary app pattern (a view with a swappable ?stateful(?get(page), ...) child), not a framework feature -- demo fixture arizona_patch_demo (/patch-demo/:section).

A patch runs no mount step. mount/1 and on_mount run whenever the view (re)mounts -- initial GET, reconnect, navigate -- and not on a patch, which by design keeps the view alive. This is deliberate: on_mount is a mount input (its output feeds mount/1), so re-running it on a patch -- where there is no mount/1 -- would dump a mount-time transform straight onto the live bindings and clobber the state the patch preserves. Per-arrival request-shaped derivation (session, path params) belongs in a middleware, which does run on a patch (do_patch) and whose output arrives as Params; handler-specific per-navigation logic goes in handle_update/3. So: mount-time setup -> mount/1/on_mount; every-arrival setup -> middleware; per-navigation reaction -> handle_update/3.

Both modes resolve the target route the same way. arizona_socket invokes the optional resolve_route/3 callback declared on the arizona_req behaviour:

-callback resolve_route(Path :: binary(), Qs :: binary(), Raw :: term()) ->
    {ok, module(), arizona_live:route_opts(), arizona_req:request()} | error.

-optional_callbacks([resolve_route/3]).

On a live route the callback returns {ok, Handler, RouteOpts, NavReq} -- a handler module, the route's static options (arizona_live:route_opts/0 -- bindings, on_mount, layouts, middlewares), and a navigate-scoped arizona_req carrying the new URL. Cookies/headers on the returned Request are inherited from the original upgrade Req (rebuilt from its raw value), except the framework cookies the upgrade itself changed (a route middleware, flushed onto the 101): arizona_socket replays them onto the rebuilt request (internally, in arizona_req). A written or cleared session is replayed as the browser now holds it, so a navigate's middlewares see it -- in store mode the signed id, resolved through the store on read, so an in-place store write or a revocation is seen as well. A flash the upgrade consumed or newly set is dropped from the rebuilt request: the consumed one is gone from the browser, and the new one belongs to its next HTTP request, which would show it a second time (nothing over the socket can clear the cookie the browser holds). The replay is read-only (nothing re-emits or re-commits it), and it is the cookie as of connect: one the browser receives later (a controller fetch that rewrites it) is invisible to the socket until the next reconnect. path, bindings (path bindings from the router), and params reflect the new path/qs. In particular path is the resolved route path (the _az_path value), not the /ws transport path the WebSocket rode in on, so a middleware reading arizona_req:path/1 on a navigate/upgrade sees the same logical route as a plain HTTP GET.

The Path is client-supplied (the _az_path upgrade param, or a navigate/patch frame), so the callback returns error -- never crashes -- when it does not resolve to a live route: no matching route, a non-live route (controller/asset/ws), or a method mismatch. A WS upgrade to such a path is rejected with 404; an in-session navigate/patch degrades to a full-page navigation (arizona_js:navigate(Path, #{full => true})) rather than tearing down the live session.

A target that does resolve degrades the same way when its layouts differ from the ones already on screen. Layouts render once, at SSR: ?OP_REPLACE swaps only the view inside them and a patch keeps the shell outright, so no frame can re-render one, and serving such a target in place would drop the new page into the old page's shell. arizona_socket tracks the connect route's layouts (threaded arizona_ws:prepare/3 -> arizona_socket:init/4) and compares by whole-list term equality -- apply_layouts/4 nests the list, so an inner layer wraps the replaced view exactly as the outer one does and a difference at any depth disqualifies an in-place swap. The check sits on both frame handlers, which also covers the middleware halt-redirect path: that halt emits a navigate effect the client replays as an ordinary navigate frame.

Shipped implementation: arizona_roadrunner_req exports the optional resolve_route/3 and runs roadrunner_router:match/3 against the compiled dispatch stored by arizona_roadrunner_router.

API -- arizona_req.erl

Opaque request abstraction backed by the transport adapter. Constructed via new/3(Adapter, Raw, #{method := ..., path := ...}); other fields are lazy-loaded via the adapter's behaviour callbacks on first access and cached in the returned request.

Accessors (eager):

  • method/1 -- HTTP method (~"GET", ~"POST", ...)
  • path/1 -- the logical route path, no query string. On a WebSocket SPA-navigate or upgrade this is the resolved route path (from _az_path), not the /ws transport path -- so it matches a plain HTTP GET to the same route on both transports
  • scheme/1 -- the connection's own scheme (https only on TLS); what check_origin/2 compares the Origin against, after folding in X-Forwarded-Proto
  • request_id/1 -- the transport's request id, for log correlation
  • adapter/1 -- the arizona_req adapter module backing this request
  • raw/1 -- the native transport value the adapter wraps

Accessors (lazy, return {Value, Req1}):

  • bindings/1 -- route-pattern path bindings (map of atoms)
  • params/1 -- parsed query string as a proplist
  • cookies/1 -- parsed cookies as a proplist
  • headers/1 -- request headers as a map
  • user_agent/1 -- the User-Agent header (pairs with arizona_user_agent:browser/1 etc.)
  • body/1 -- the request body

Response stash (a live view and a middleware cannot reply directly, so they stash):

  • put_resp_header/3, put_resp_cookie/4, put_resp_status/2 -- accumulate onto the request
  • resp_headers/1, resp_cookies/1, resp_status/1 -- pure getters the transport reads at flush; resp_cookies/1 only builds the cookie list, it performs no side effect
  • commit_session/1 -- called at response flush (by flush/1). In server-side store mode this is the write that persists the session, so the persist is commit-on-success: a handler that crashes after put_session/3 leaves the store untouched
  • flush/1 -- what a server adapter calls once per response: commits the session and returns the header lines to add (stashed headers, then one set-cookie per cookie, serialized by arizona_cookie:serialize/3), whatever the response's shape
  • halt_reply/1 -- the {Status, Headers} of a middleware halt (redirect, else stashed status, else 403), flush/1's lines included

Every response a transport builds flushes the stash, the WebSocket upgrade included: a cookie, a header or a session write a route middleware stashes during arizona_ws:prepare/3 rides the 101 Switching Protocols (and, in store mode, commits to the store) exactly as it would ride a page response. A navigate/patch frame has no HTTP response, so no cookie or header stashed there is flushed (a flash has its own in-process carry instead).

Other:

  • set_raw/2 -- swap the native raw value, clear all lazy caches
  • call_resolve_route/4 -- invoke the adapter's optional resolve_route/3 (SPA navigate)
  • redirect/2(Req, Location) / redirect/3(Req, Status, Location) -- stash a 3xx redirect intent in the request. Transports pick it up on halt and translate uniformly (HTTP 3xx reply, or arizona_js:navigate effect on WS navigate)
  • halted_redirect/1 -- returns {Status, Location} if redirect/2,3 was called, else undefined
  • location_kind/1 -- reads a Location as a browser will (leading C0/space trimmed, tab/LF/CR removed): local for a path on this origin (one leading /), the only form a SPA navigation follows; external for an http/https URL, a scheme-relative //host or /\host, or a relative path (resolved against the current URL, as an HTTP Location is), which the transports send as a full load; unsafe for any other scheme, which never reaches the client
  • flash (one-request): put_flash/3 (set), flash/1 (read) -- signed messages cleared on read; survive a full-page HTTP redirect (signed az_flash cookie) and a WebSocket SPA navigate (arizona_socket in-process carry, exactly-once, no cookie). The WS carry needs a destination that stays on the socket, so a navigate degrading to a full-page navigation splits by origin: a flash a halting middleware set is reproducible, and the socket sends the full navigation to the gated path instead of the target so the halt replays over HTTP and redirects with a real flash cookie; a flash an in-view handler set has no request-side generator and is dropped with a logged warning. flash_out/1, consume_flash/1, seed_flash/2 are the transport-side plumbing
  • session (durable): put_session/3, delete_session/2, clear_session/1, session/1, get_session/2,3, read_session/1 -- encrypted state; a read does not consume, the response re-emits the cookie only on a write. session_id/1 returns the opaque id in server-side store mode (undefined in cookie mode or before there is a session), so an app can record it to revoke later; session_error/1 reports a store read failure, kept distinct from a genuinely absent session

Both cookies are keyed off the secret_key app env (arizona_crypto warns below 32 random bytes). Flash is signed (arizona_flash, HMAC-SHA256 + a baked-in TTL, so a cookie replayed past its Max-Age is rejected rather than merely soft-expired); the session is encrypted (arizona_session, AES-256-GCM) with an absolute expiry stamped inside, so a client can neither read, forge, nor outlive it.

App envDefaultEffect
secret_key_previous[]Rotated-out keys verify/decrypt still accept (signing uses secret_key only). Each must be a non-empty binary like the primary, else every sign, encrypt, verify and decrypt raises secret_key_previous_invalid: an empty key is a public constant. From an env var, write {env, "OLD_SECRET_KEY", []} (unset or empty resolves to []), not a ~"" default, which resolves to [<<>>]
session_max_age604800 (7 days)Session cookie Max-Age and the expiry baked into the ciphertext (or, in store mode, into the signed id)
session_max_bytes4096Cap on the serialized Set-Cookie line -- name + value + attributes, what browsers actually count. arizona_session:encode/1 errors {session_too_large, ...} past it instead of letting the browser silently drop the cookie
session_securefalseThe session cookie's Secure flag
flash_securefalseThe flash cookie's Secure flag
session_storeunset (cookie mode)An arizona_session_store module; must be set before the app boots (arizona_sup reads it at init)
session_store_sweep_ms60000arizona_session_store_ets periodic sweep interval (plus lazy expiry)

session_secure/flash_secure default false because a Secure cookie is silently dropped over plain-HTTP dev and a TLS-terminating proxy cannot be auto-detected -- set them true in production. Enabling one also switches that cookie to its __Host- prefixed name (__Host-az_session / __Host-az_flash), whose browser-enforced Secure + Path=/ + Domain-less rules close sibling-subdomain cookie tossing.

API -- arizona_controller.erl

Reply helpers for controller routes -- plain HTTP handlers (a verb tag like {post, ...}, or {match, ...}) that the browser reaches through arizona_js:fetch/2. Because a controller answers a real HTTP request, its response can carry a Set-Cookie (HttpOnly honored) that the WebSocket transport cannot, which is what makes it the right place to rotate a session cookie while the page stays put. The adapter dispatches the route through dispatch/2, which runs the middleware pipeline (Origin check default-on) and then calls Handler:Action/2 -- the action option, default handle -- with the post-middleware arizona_req:request() and a context, #{state := State, bindings := Bindings} (the route's state option and the bindings the pipeline produced, such as session after fetch_session). The action returns {Reply, Request}, and what the returned request carries (session, flash, cookies) is flushed ahead of the reply's headers.

A reply is one of four kinds. A buffered {Status, Headers, Body} works on every adapter; a {stream, Status, Headers, Fun}, a {sendfile, Status, Headers, {Path, Offset, Length}} and a {loop, Status, Headers, State} (messages reach the controller's handle_info(Msg, Push, State), a client gone away as {arizona_disconnect, Reason}) are capabilities the adapter declares (arizona_server capabilities/0): dispatch/3 refuses one it lacks with {unsupported_reply, Kind, Adapter} before the session commits, rather than degrade the reply. Roadrunner and Cowboy have all four.

  • reply_effects/1(Effects) -- a 200 whose body is the {"e": [...]} effects wire payload, the same shape the WebSocket sends. A fetch form auto-resets on this leg (az-form-reset fires only on a 2xx)
  • reply_effects/2(Status, Effects) -- the same with an explicit status. The fetch command applies response effects on any status, so an error leg returns a real 422: the effects still run (inline validation) while the form's typed fields survive
  • reply_redirect/1(Location) -- sugar for reply_effects([arizona_js:navigate(Location)]). A fetch-followed HTTP 3xx cannot drive a SPA navigation (redirect: 'manual' yields an opaque-redirect whose Location is unreadable), so a redirect is delivered as an effect. A Location that is not a path on this origin (arizona_req:location_kind/1) goes out with full => true, since pushState refuses another origin; a relative one loads resolved against the current URL, as over HTTP. An unsafe scheme (javascript:, data:) raises {unsafe_redirect, Location} rather than reach the client

The response is deliberately effects-only, no ops: a stateless controller holds no diff snapshot to make ops from (hence the accept: application/json request header). To re-render the submitting view, return an arizona_js:push_event -- the client relays it over the existing WebSocket and the view re-renders through its own handle_event/3. To reach other views or users, broadcast over arizona_pubsub with a topic scoped by user/session. Either way the view renders from its own state; the response effects are for request-local imperative UI only.

The behaviour expects adapters to implement parse_bindings/1, parse_params/1, parse_cookies/1, parse_headers/1, read_body/1, scheme/1 against their native request type. scheme/1 reports the connection's own scheme (https only on TLS), which check_origin/2 compares the Origin against after folding in X-Forwarded-Proto.

API -- arizona_middleware.erl

The request-to-bindings middleware pipeline. A middleware reduces a request into the plain bindings a mount/1 handler consumes, keeping the live process and handlers free of any request/transport coupling.

  • apply_middlewares/3(Middlewares, Req, Bindings) -- threads a request and bindings map through a list of middleware() left-to-right, returning {cont, Req1, Bindings1} or {halt, HaltReq}; stops on the first halt. Run by the HTTP and WS transports before mounting a view.
  • extract/1(Keys) -- builds a middleware copying selected request data into bindings so a handler reads it with ?get(Key). Keys: path_bindings, params, headers, cookies, body, method, user_agent. Being a call rather than a term, it is writable only where routes are declared in Erlang; a route declared in config/sys.config is parsed by file:consult/1 and must name a 2-arity wrapper ({my_app_mw, path_bindings}) instead.
  • put_request/2 -- escape-hatch middleware exposing the whole request under the request binding for lazy access; prefer extract/1 for specific data.
  • fetch_flash/2 / fetch_session/2 -- read the flash (consuming) / session (non-consuming) cookie into the flash / session binding. Opt-in; both run on the GET render and the WS upgrade, so a live view is seeded at mount.
  • check_origin/2 -- CSRF Origin check (see arizona_origin); the router prepends it to live/controller routes by default.

API -- arizona_http.erl

Shared HTTP render pipeline that serves an Arizona view as the initial page response. Wraps the native request in an arizona_req:request(), runs any route middlewares, and calls arizona_render:render_view/2 (render_view_to_iolist/2 paired with the document's media type). The transport handler translates the returned result into its native reply shape.

  • render/3(Handler, Req, Opts) -- returns one of (each threads the request back so the transport can flush any middleware-stashed response headers/cookies):
    • {halt, Request} -- middleware halted; the transport decodes the stashed redirect (arizona_req:halted_redirect/1) or status off the request via arizona_req:halt_reply/1, which answers a halt carrying neither with 403 (one default for pages, controllers, and the WS upgrade alike)
    • {ok, resp_status(), ContentType, iolist(), Request} -- rendered page body; the status defaults to 200, but a view or middleware may stash a non-200 (e.g. 401). ContentType is the document's media type, answered by its render backend's content_type/0 (HTML for an ?html view, application/json for a ?native one)
    • {error, 500, ContentType, iolist(), Request} -- rendered error page body (crash or stashed hot-reload error), under the error page's own media type

arizona_roadrunner_http consumes this helper and maps the three result shapes into roadrunner's reply tuple.

API -- arizona_ws.erl

Transport-agnostic upgrade bootstrap for WebSocket handlers.

  • prepare/3(QS, Adapter, AdapterState) -- accepts the pre-parsed upgrade query string ([{binary(), binary() | true}]), reads the _az_path, _az_reconnect, _az_fps_follow, and _az_caps framework keys (_az_caps is a JSON object of native-shell capabilities; an undecodable or non-object value is treated as "no capabilities" rather than crashing the upgrade), strips them to compute the user-visible query string, resolves the target route via the adapter's resolve_route/3 callback, runs middlewares, and returns:
    • {halt, az:request()} -- middleware blocked the upgrade; caller extracts the native raw via arizona_req:raw/1 to emit its transport response
    • not_found -- the client-supplied _az_path did not resolve to a live route; the caller rejects the upgrade with 404 (never crashes on the attacker-controllable path)
    • {cont, State} -- State is a map carrying handler, bindings, on_mount, req, reconnect, fps_follow, capabilities that the caller threads into arizona_socket:init/4

arizona_roadrunner_ws collapses to a few lines that call parse_qs, invoke arizona_ws:prepare/3, and wire the result into roadrunner's callback contract.

API -- arizona_static.erl

Offline static-site generation -- renders route handlers to HTML files with no server, live process, or WebSocket (built on arizona_render:render_view_to_iolist/2).

  • generate/2,3(OutDir, Specs[, DefaultOpts]) -- renders each spec under OutDir, writes the HTML files, and returns {Written, Failed} (the paths plus {Spec, Reason} for any spec that failed -- a mount/render crash, a non-map Opts, or a write error; one failure does not stop the batch). A spec is {Handler, Outfile} or {Handler, Outfile, Opts}; Outfile is joined onto OutDir (an absolute Outfile bypasses it and .. segments are honoured: it is the caller's own path, not untrusted input). Opts (and the optional DefaultOpts, which each spec's Opts override) is the offline subset of arizona_live:route_opts/0 (bindings/on_mount/layouts; no middlewares). The client <script> lives in the layout: a pure-static page loads arizona-static.min.js and calls start() instead of connect('/ws'), and browser-side commands (?local, toggles, dialogs, PiP) run with no server. Each page with event attributes embeds their proven names as <script type="application/json" id="arizona-attrs"> before its last </body> -- what the socket's connect frame carries on a live page -- and start() delegates exactly those.

Roadrunner handlers

  • arizona_roadrunner_http.erl -- thin HTTP handler. Delegates the pipeline to arizona_http:render/3 and translates its result into roadrunner's {Response, Req} reply shape
  • arizona_roadrunner_ws.erl -- thin WebSocket handler. Delegates the upgrade to arizona_ws:prepare/3, flushes the middlewares' stash onto the 101, and forwards frames to arizona_socket. Translates arizona_socket:result() to roadrunner return tuples

Data flow

SSR (HTTP): arizona_roadrunner_http:handle/1 delegates to arizona_http:render/3, which wraps the native req in an arizona_req:request(), runs any route middlewares (arizona_middleware:apply_middlewares/3), then calls arizona_render:render_view(Handler, Opts) where Opts may contain layouts => [{Mod, Fun}], bindings => map(), and on_mount => [...]. render_view/2 mounts the page via arizona_stateful:call_mount/2, applies on_mount hooks, renders to page HTML, then optionally injects the rendered page into mount bindings as inner_content -- as an opaque nested template, not iodata (see Slots below) -- and passes the bindings to the layout's render/1. A layout is a stateless HTML shell (DOCTYPE, head, body, scripts) with no markers or az attributes: ?inner_content is itself what marks the whole layout az-nodiff, so an explicit az_nodiff attribute is redundant and no layout fixture in the repo writes one. A ?stateful inside a layout is a render-time error (stateful_in_layout): layouts render on the request-free SSR path, which has no views accumulator, so the child is mounted and discarded while its az-view marker -- baked into the module's compiled statics, so it cannot be stripped here -- still reaches the DOM naming a view the server never registered. The check is at render rather than compile time because the disqualifying property is the path, not the template: a ?stateful under a user az-nodiff renders on the live path, is registered, and works. Use ?stateless for layout chrome; chrome that must stay live across navigation belongs in a view the routes share, linked with az_patch. A layout must share the page's render target (cross_target_layout otherwise): the page reaches it as a backendless template, which the nested-template target check waves through. A ?native document, like a ?native view, must have a single root node (multi_root_layout otherwise): a native document is one JSON value, and several top-level nodes would be served comma-joined. Only the final document root counts, so a multi-root layout wrapped by a single-root one renders; a layer that adds no node of its own (?inner_content returned bare, ?native([?inner_content])) passes the wrapped root through. The parse transform stamps the shape (multi_root, a literal list of several nodes at any depth of nested lists) when the backend's single_root_document/0 asks for it, and the render threads the document root through the layers, since only the route config says which functions are layouts. Roots known only at runtime are not judged, nor is a layout with no node (?native([]), an empty document). ?html and ?terminal layouts may have several roots. layouts is always a list, applied outermost-first ([Root, Section] produces Root(Section(Page))); an empty list renders the page directly, with no wrapper. Route config provides bindings, on_mount, layouts, middlewares, and check_origin -- the single canonical arizona_live:route_opts/0. URL data (path bindings, query params) does NOT flat-merge into Bindings -- a route opts into arizona_middleware:extract/1 middlewares (or arizona_middleware:put_request/2) to project what its handler needs. Middleware halts that call arizona_req:redirect/2,3 surface as a {redirect, Status, Location} result that the transport emits as a 3xx reply.

WebSocket mount: arizona_roadrunner_ws:handle/1 delegates to arizona_ws:prepare/3, which reads the framework query keys -- _az_path, _az_reconnect, _az_fps_follow, _az_caps -- resolves the route, runs middlewares (so the Origin check gates the upgrade too), and returns the state for the transport to hand to arizona_socket:init/4. The socket calls arizona_live:start_link/5 (passing self() as transport PID, the route on_mount hooks, and the connection context #{capabilities, reconnect}) then arizona_live:mount/1. Mount establishes the server-side snapshot (matching SSR). Returns {ok, ViewId} where ViewId comes from maps:get(id, MountBindings).

A flagged reconnect (_az_fps_follow=1, which the worker sets on every reconnect open) defers that whole mount+render until the client's promised cached_fps frame arrives, so the resync OP_REPLACE dedups its statics against the announced fingerprints -- see arizona_socket:init/4 above for the full handshake and its 1 s backstop.

Handlers detect the connected context via the ?connected macro (delegates to arizona_live:connected()), which reads a process-dictionary flag set in arizona_live:init/1; ?reconnected (arizona_live:reconnected()) additionally distinguishes a re-opened socket from a first connect, so a one-shot command (notify, focus) can be gated with ?connected andalso not ?reconnected while a declarative one (set_title, fullscreen) is re-asserted on every connect. The flag is per live process and describes the current page's mount: a navigate after a reconnect clears it, since the page it mounts is new to this connection, and a same-handler patch keeps it with the view (a patch to a different handler degrades to a navigate and clears it). For post-connection effects, handlers use ?send(arizona_connected) in mount and handle it in handle_info/2.

Server push (handle_info) -- per-view routing: Messages sent via ?send(Msg) / arizona_live:send(ViewId, Msg) are tagged as {arizona_view, ViewId, Msg} and routed to the correct handler's handle_info/2 -- root or child. The root view ID is matched from #state.bindings, children from the views map. Unknown view IDs crash with {unknown_view, ViewId, Msg}. Plain untagged messages (Pid ! Msg) route to the root handler for backward compatibility. Delayed sends use ?send_after(Time, Msg) / arizona_live:send_after(ViewId, Time, Msg). PubSub subscriptions use ?subscribe(Topic) / ?unsubscribe(Topic). After handle_info/2 returns, the template is re-rendered and diffed, and ops+effects are sent as {arizona_push, RootViewId, Ops, Effects} to the transport PID.

Events: Client sends [target, eventName, payload] over WebSocket. arizona_roadrunner_ws dispatches to arizona_live:handle_event/4. The gen_server checks the views map -- if target is a known child view, dispatches to child; otherwise dispatches to root handler. Returns {ok, Ops, Effects}. Ops are scoped with "viewId:target" format. The response is sent as a JSON envelope {"o": scopedOps, "e": effects}.

SPA navigation + unmount: Client clicks [az-navigate] link -> JS intercepts, calls history.pushState, sends ["navigate", {path, qs}] over WebSocket. arizona_socket's navigate handler resolves the new route via the adapter, then runs the new route's middlewares (lifecycle parity with HTTP init and WS upgrade). If middleware halts with arizona_req:redirect/2,3, the socket emits [arizona_js:navigate(Location, #{replace => true})] as a client effect -- no arizona_live:navigate call happens; the browser replaces the gated URL it already pushed (an HTTP 3xx never leaves it in history either, so Back does not bounce off the gate) and the follow-up navigate frame runs middleware again on the redirect target. That needs a Location that is a path on this origin (arizona_req:location_kind/1); any other -- an identity provider -- cannot be SPA-navigated, so the socket instead has the browser load the gated path over HTTP, where the same halt is a real 3xx that also carries any cookie the middleware set before redirecting (the gate's middlewares run twice, the cost the flash replay already pays). An unsafe-scheme Location takes the same road, so it never reaches the client. Every full load the socket degrades a navigate/patch frame to (no live route, a layout change, these halts, the flash replay) carries replace => true and runs as location.replace: it finishes a navigation the client already recorded. On cont, arizona_live:navigate/4 is called. Before mounting the new handler it cancels every pending send_after timer (cancel_pending_timers/0, over the whole $arizona_timers registry) and then unmounts the outgoing page children first, then the root (unmount_removed_views/1 over the old views map, then arizona_stateful:call_unmount/2 on the old root). Discarding the page is a removal, so it unmounts exactly like a diff removal does, and terminate/2 uses the same order for any exit reason -- every child's unmount/1 runs while the root is still mounted, and no handler has to propagate unmount by hand. Note the guarantee is against the root: views is flat and walked with maps:foreach, so nested views unmount in key order, not parent-before-child. Then the new handler is mounted and gen_server state is reset. The mount runs in a handle_continue/2 from a state whose snapshot and views are cleared, which is what terminate/2 reads as "nothing mounted", so a new mount that raises exits the process without unmounting the old page a second time. Returns {ok, NewViewId, PageHTML, Observed}. The WS handler sends one frame: the old page's pushes queued ahead of the live process's barrier, their ops first, then [OP_REPLACE, OldViewId, PageHTML] (their effects are dropped with a log line, see handle_info/2 above). Browser back/forward also triggers navigate via popstate.

The page-swap window is closed by the client. The server replaces the root while handling the navigate frame, but the old DOM stays on screen until the OP_REPLACE arrives, and an event sent from it in between is dispatched by view id alone -- to the new root, which often shares the id (page), where it either crashes on an event it does not handle or runs the old page's intent. The server cannot protect itself without a wire change: frames are handled in order, so by the time the stale event is read the replace has already happened, and the event carries nothing that tells it from one sent after the client applied the swap (dropping the root id for "a while" would eat legitimate events from the new page). So the web client drops every view event raised between posting a navigate and its reply -- the OP_REPLACE, a reconnect's resync OP_REPLACE, or a full-page navigate effect (the server kept the old view) -- exactly as a full page load would; a redirect reply is itself a navigate and re-arms. The window counts navigates in flight, so back-to-back navigates keep it open until the last reply (the server answers each navigate frame once, in order); an old-page push carrying a navigate effect ahead of the reply is indistinguishable from a reply and settles one early, as is the OP_REPLACE of a patch the server degrades to a replace while a navigate is in flight. A dropped event is logged by name. A patch does not arm the window: it usually keeps the view, and the client cannot know ahead that the server will degrade a cross-handler patch to a replace, so that case keeps the window. The native clients (Android/iOS) do not gate yet.

SPA patch (in-place): Client clicks an [az-patch] link (or runs arizona_js:patch) -> JS sends ["patch", {path, qs}]. arizona_socket resolves the route + runs middlewares as for navigate, then compares the resolved handler against the tracked root handler: same handler -> arizona_live:patch/2 (the root's handle_update/3 runs, then a diff-in-place, returning {ok, Ops, Effects} -- no unmount/remount/OP_REPLACE); different handler -> falls back to the navigate/replace path above, and so does a post-middleware id binding other than the live view's (mounted with the bindings already produced). The client tags the history entry (_azNav) and stamps the outgoing entry so back/forward replays the patch, keeping live chrome across the edge. See "SPA navigate / patch route resolution".

Stateful children: arizona_template:stateful(Handler, Props) returns a descriptor map. During eval_val_v/2, the engine checks if the child is already in the views map -- if so, calls handle_update/3 (or maps:merge if not exported); if not, calls Handler:mount(Props). Child templates are recursively rendered/diffed with their own snapshots. The views map uses a {OldViews, NewViews} tuple during eval -- OldViews is read-only for child lookup, NewViews accumulates only children rendered this cycle. Children removed from the template (conditional rendering) are pruned from the views map and their unmount/1 callback is called if exported. Every instance's unmount/1 runs exactly once, on removal or on the way out: one that raises stops neither its siblings' nor the root's (each still runs, and the first error is re-raised after the last), terminate/2 skips a view (or a navigate's outgoing root) the failed pass already unmounted, and it unmounts a child the failed pass had mounted before raising.

The views map is keyed by id, and events, timers and messages are routed by it, so a child's id must be unique among the views on the page -- the root view's own id included. A live render or diff (armed by arizona_live around each; see arizona_eval:claim_view_ids/1) refuses a second claim of an id in one pass with {duplicate_view_id, Id, FirstHandler, SecondHandler}: every ?stateful it evaluates claims its id, and every view it keeps without evaluating -- a dep-skipped slot's subtree, the children of the stream items a drain does not touch -- claims its id as it is kept. A child's own event renders only that child's subtree, so there the views mounted elsewhere on the page count as claimed too. Without the check the two instances collapsed into one entry: one was never unmounted, the other's slot was updated from its state, and every event reached only one. Re-rendering the same view in a later pass, keeping it, or swapping its handler under the same id are one claim each. SSR keeps no views map and does not check.

Slots: Slots are implemented via stateless children and bindings. A layout places the page it wraps with the ?inner_content macro; the binding key is fixed (apply_layouts/4 injects inner_content into the layout's bindings), not configurable. Stateless components receive props with arbitrary content:

%% Layout slot -- ?inner_content places the wrapped page AND marks the layout az-nodiff
render(Bindings) ->
    ?html({body, [], [?inner_content]}).

%% Component slot -- via stateless child props
?stateless(render_card, #{label => ~"Hello", content => SomeTemplate})

Use the macro, not maps:get(inner_content, Bindings): the parse transform recognizes ?inner_content as a block, which is what marks the whole layout az-nodiff (a layout renders once at SSR and is never diffed, so it needs no az targets). A raw maps:get is invisible to it, so the layout keeps diff markers and the page lands in an ordinary escaping value slot.

az-nodiff -- explicit or a layout's implicit one -- inherits downward through the compile unit: conditional-branch templates, nested ?html, ?each item templates (at any depth), hoisted constructors and inlined local helpers all compile nodiff and emit no az attributes or slot markers, so the HTML is plain. az_nodiff on an ?each item's root element, or on a bare element in a conditional branch, reaches the templates nested in it the same way. Inheritance stops where what is built can land in a live view's diffed slot (a nodiff value there never updates in place): a ?stateful child's props, and any fun (anonymous or named) other than an ?each callback, since a fun prop is called later by whichever view receives it. A ?stateless callback is a separate compile unit and keeps its own markers. A ?local under a nodiff template keeps the az and slot marker the client resolves it by, and nothing else does.

A slot whose current value is a nodiff template still diffs as a slot: the server decides whether the az-nodiff element exists and which one it is, the client owns what is inside. See Embedding client-side components.

The page arrives as an opaque nested template (#{s := [Page], d := []}), not iodata and not a binary. That is what lets a content slot splice it verbatim rather than copying and UTF-8-re-decoding the whole page once per layout layer. Consequences: it belongs in a content slot whole -- fine in a case tail or handed to a child as a prop, but iolist_size/1 on it raises badarg, and in an attribute value it raises bad_template_value carrying the whole page in the error term. az:inner_content/1 documents the full set of accepted and rejected placements; arizona_render_SUITE's ssr_inner_content_* cases pin them.

Embedding client-side components

An az-nodiff element is rendered by the server once, then owned by the client. That is the seam for a component another library draws -- a chart, a map, a React or Svelte island: mount it from an az_hook on the az-nodiff element's root, and the server never touches its DOM again.

render(Bindings) ->
    ?html({main, [], [
        case ?get(view) of
            chart ->
                {'div', ['az-nodiff', {az_hook, ~"Chart"}, {data_series, ?get(series_json)},
                         {az_key, ?get(dataset)}], [~"Loading chart..."]};
            table ->
                {'div', ['az-nodiff', {az_hook, ~"Grid"}], []};
            none ->
                <<>>
        end
    ]}).
hooks.Chart = {
    mounted() {
        this.chart = new ChartLib(this.el, JSON.parse(this.el.dataset.series));
        this.onSeries = (e) => this.chart.update(e.detail);
        document.addEventListener('chart:series', this.onSeries);
    },
    destroyed() {
        document.removeEventListener('chart:series', this.onSeries);
        this.chart.destroy();
    },
};

The rules.

  1. Nothing inside an az-nodiff element is ever patched: no op targets it, and its dynamics are not evaluated once it is on screen.
  2. The server still decides whether the az-nodiff element exists. The slot holding it diffs like any other: it can hide the az-nodiff element, show it, or switch it to another one. A switch replaces the slot through its markers, so the old widget's destroyed() runs while its element is still attached and the new one's mounted() runs after.
  3. The same az-nodiff element chosen again keeps the existing DOM untouched, even when the values it was rendered from changed. It is never re-rendered.

What "the same az-nodiff element" means. An az-nodiff element is identified by its template's fingerprint, the conditional branch (clause) that produced it, and an optional az_key on its root. Two clauses that produce identical markup -- or call the same helper, chart(~"line") and chart(~"bar") -- are two az-nodiff elements, so moving from one to the other remounts. The values an az-nodiff element was rendered from are deliberately not part of its identity: that would remount the widget, and destroy its state, on every change. When a change should remount (a different dataset), put that value in az_key: a new key value replaces the az-nodiff element, and the key's reads are dependencies of the slot. A child view whose own root is az-nodiff is identified by its id; a new id replaces it.

The branch is taken from the conditional in the az-nodiff element's own slot, not from any conditional above it. When a switch higher up lands on a wrapper with the same statics, the diff descends into the wrapper and the az-nodiff element's slot still chooses the same branch, so the az-nodiff element is kept:

case ?get(tab) of
    sales -> {section, [{class, ~"tab"}], [chart(?get(series))]};
    costs -> {section, [{class, ~"tab"}], [chart(?get(series))]}
end
%% chart/1 returns {'div', ['az-nodiff', {az_hook, ~"Chart"}, ...], [...]}

Moving from sales to costs keeps the chart. To remount it, give its root an az_key that names what changed ({az_key, ?get(tab)}).

Props. A widget reads its initial props from attributes at mount (data-*, parsed in mounted()); the server-rendered children are a placeholder for the first paint. Later updates go to the widget, not through the diff: return arizona_js:dispatch_event(~"chart:series", Payload) from a handler and let the hook listen for it on document, or remount with a new az_key. The widget talks back with this.pushEvent(name, payload). Reads inside an az-nodiff element are not tracked on its slot -- they can never patch -- so only the reads that choose the az-nodiff element (and its az_key) re-run the slot. When that slot re-runs and lands on the same az-nodiff element, the check runs before the az-nodiff element is evaluated, so it costs O(1) whatever the az-nodiff element's size. Paths that re-evaluate a whole enclosing template -- a plain-list ?each item, a wrapper the diff descends into, diff/3 -- evaluate the az-nodiff element and then compare it, so they cost the az-nodiff element's size; the result is the same (no op, child views kept).

Child views inside an az-nodiff element take their props at mount only. An az-nodiff element is never re-rendered, so a parent-driven handle_update/3 would move the child's state past a DOM no op will ever touch; the same handler at the same id keeps its view on every path the az-nodiff element is reached through (a branch, a ?stateless body, a list item). That holds when a switch replaces the az-nodiff element too: a child with the same handler at the same id keeps its state, and the new props are ignored. A child still handles its own events.

Wholesale re-renders keep the az-nodiff element. Some changes re-render the markup around an az-nodiff element whole instead of patching it: a plain-list ?each that falls back to its container ?OP_TEXT (always, when its items are themselves az-nodiff elements), a stream's container render, and the ?OP_REPLACE that resyncs a view after a reconnect. The new markup holds a fresh copy of every az-nodiff element in it, so the client carries the old ones over: each az-nodiff element's root keeps its directive in the HTML with its identity as the value, az-nodiff="TOKEN" (the fingerprint, branch and az_key above, spelled by arizona_template:nodiff_token/3), and an az-nodiff element whose old and new copies sit at the same place under the same name keeps its existing DOM -- the old element is moved into the new markup in place of the new copy, and its hook sees neither destroyed() nor mounted(). "The same place" is the slot that holds it plus, on the way up, its own and each enclosing element's az-key and view id, so an az-nodiff element in a keyed item follows its key and a child view whose root is an az-nodiff element is kept only under the same id. An az-nodiff element the new render does not name there is replaced as before.

List items are kept only when keyed. Unkeyed items share one template, so nothing tells which old item a new one is -- that is exactly why such a list re-renders wholesale. An unkeyed ?each item whose root is an az-nodiff element therefore keeps a bare az-nodiff -- client-owned, never carried over, and nothing inside it is either -- and when two az-nodiff elements inside unkeyed items share a name and place, neither is kept: both remount for their own item. Give the item an az_key ({li, ['az-nodiff', {az_key, Id}], [...]}) and its widget follows it through adds, drops, reorders and a reconnect.

A reconnect mounts the view afresh, so what survives it is decided by what the fresh mount renders: an az-nodiff element its initial state chooses again keeps its widget and client state; one chosen by state the old process had built up (another branch, a later az_key) is replaced by the az-nodiff element the fresh state names. A navigate is a new page and keeps nothing.

Caveats.

  • A kept az-nodiff element is moved, not re-created, so its element leaves the document for an instant: focus inside it is lost, and so is the state of anything the platform resets on removal (an <iframe> reloads, media restarts). Its JavaScript state, form values and ?local values stay.
  • An az-nodiff element holding a child view is replaced by a reconnect anyway: the reconnect mounted that view afresh, and the DOM it left behind no longer matches its state.
  • A switch above the az-nodiff element's slot to a wrapper with different statics moves the az-nodiff element into a different slot, so it is remounted; a wrapper with the same statics keeps it (see "What the same az-nodiff element means"), and az_key remounts it.
  • Only a template whose single root element carries az-nodiff is named. A root of a multi-element nodiff fragment keeps a bare az-nodiff, and the ?native and ?terminal targets (whose clients keep no az-nodiff elements) emit none; all are re-created by a wholesale re-render.
  • The directive is written bare. Its value in the HTML is the compiler's, so an authored one ({'az-nodiff', V}, {az_nodiff, true}) is a compile error.
  • The first paint is the server's placeholder. Mount the component from the hook's mounted() rather than hydrating the server markup; the server never rendered the component itself.
  • az-* attributes inside the widget's own DOM are markup Arizona did not render: the client delegates only the event names the compiled templates declared, and mounts hooks only in markup it inserted. Call the exported mountHooks(root) on the widget's root after rendering such markup; it scans for both.

Connection events

The client announces connection changes as two CustomEvents on document, in the same style as arizona:fetch-error. They are for client code the server cannot reach while the socket is down -- a hooked widget, an embedded component, a status badge:

  • arizona:disconnected, detail {code, reloadInMs} -- the connection was lost. Fired once per outage, and again when a page reload gets scheduled during it: the first close of a live (or not yet live) page announces the outage, and the worker's reconnect attempts that fail while the server is still down report closes of their own, which announce nothing until a mount has ended the outage -- unless one is a 4501 that schedules the reload, the moment a widget has to save its state. Write listeners to be idempotent. code is the close code the browser reports: the server's (1001 drain, 4500 crash, 4501 mount failure) or, for a connection that ended without a close frame -- a network drop, a failed handshake, a first connect that never opened -- 1006. reloadInMs is the delay of the page reload this 4501 scheduled, null otherwise (including a 4501 while a reload is already pending). A 1000 close (a deliberate end, which the client does not reconnect after), disconnect(), and pagehide fire nothing: the last two terminate the worker, so no close reaches the page.
  • arizona:connected, detail {reconnect} -- a view mounted on a new socket, fired once the ops of the frame that proves it (the first frame of a first connect, the resync of a reconnect) have been applied -- after a view transition's deferred update when one wraps that frame, and not at all if the socket closes before then. reconnect means this page had a mounted view before: false for the page load's first mount (even one reached through a reconnect after a first connect that never opened), true for every later one, including a bfcache restore, which has no arizona:disconnected before it. A navigate's OP_REPLACE reply on a live socket is not a connection event and fires nothing.

So a widget can pause while the server is unreachable and refresh what it may have missed once the view is back:

hooks.Ticker = {
    mounted() {
        this.off = new AbortController();
        const { signal } = this.off;
        document.addEventListener('arizona:disconnected', () => this.pause(), { signal });
        document.addEventListener(
            'arizona:connected',
            (e) => e.detail.reconnect && this.refresh(),
            { signal },
        );
    },
    destroyed() {
        this.off.abort();
    },
    pause() { /* stop polling, grey out */ },
    refresh() { /* refetch, resume */ },
};

The az-connected/az-disconnected classes on <html> remain the CSS-side view of the same state; they flip on the socket's open and close, before the mount the event waits for.

Handler callbacks

Two handler kinds, each with its own header:

  • Live handlers (arizona_stateful) include arizona_stateful.hrl (sets -behaviour(arizona_stateful), parse transform, template macros). Mount is mount/1. The same behaviour serves route-level pages -- spawned as a live-process root, with request data arriving as bindings via arizona_middleware:extract/1 middlewares on the route -- and embedded components, instantiated from a parent template via ?stateful(Handler, Props).
  • Pure stateless templates include arizona_stateless.hrl (parse transform only, no behaviour). Invoked via ?stateless(Callback, Props) or ?stateless(Mod, Fun, Props).
%% Route-level page
-module(my_page).
-include("arizona_stateful.hrl").
-export([mount/1, render/1, handle_event/3]).

%% Construct a fresh map -- never `maps:merge(Defaults, Bindings)` or `Bindings#{...}`.
%% A navigate carries arbitrary keys forward from previously visited pages, so
%% merging lets a foreign key collide with a handler-owned default. Pull each
%% accepted override out explicitly.
mount(Bindings) ->
    {#{
        id => ~"page",
        count => maps:get(count, Bindings, 0)
    }, #{}}.

render(Bindings) ->
    ?html({'div', [], [?get(count, 0)]}).

handle_event(~"inc", _Payload, Bindings) ->
    {Bindings#{count => maps:get(count, Bindings, 0) + 1}, #{}, []}.

Callback signatures (see src/arizona_stateful.erl for authoritative types):

mount(Bindings) -> {NewBindings, Resets}.                                      %% Required
render(Bindings) -> #{s => [...], d => [...]}.                                 %% Required
handle_event(Event, Payload, Bindings) -> {NewBindings, Resets, Effects}.      %% Optional
handle_info(Info, Bindings) -> {NewBindings, Resets, Effects}.                 %% Optional
handle_update(Props, Bindings, Effects) -> {NewBindings, Resets, Effects}.     %% Optional (embedded child OR patched root)
handle_drain(Deadline, Bindings) -> ok | {stop, Bindings, Effects}.           %% Optional
unmount(Bindings) -> term().                                                   %% Optional

Resets is a map of binding values reapplied on top of NewBindings after the callback returns -- typically #{}, or a subset of keys you want cleared (form fields, transient flags). Effects is a list of arizona_js:cmd() values to run client-side after the diff is applied.

Mount must produce Bindings containing an id key -- this becomes the ViewId used for event routing and navigate targeting. The parse transform auto-injects az-view as a boolean attribute on the root element of render/1 when the module declares the arizona_stateful behaviour. Using az_view manually outside this context raises a compile error.

The injection (and the {id, ?get(id)} enforcement below) happens only when the parse transform sees a literal template call (?html/?native) as render/1's tail expression. A route-root render/1 that delegates instead -- render(B) -> page(B). -- falls through both: the root carries no az-view marker and skips the id check, so events resolve to the enclosing view with no diagnostic. This is not caught at compile time (a non-template tail is legal for embeddable components), so it is a documented constraint: a route-root view's render/1 must inline its ?html(...), composing sub-views as ?stateful/?stateless children within it rather than delegating the whole body.

id attribute restriction: The root element's id MUST use {id, ?get(id)} (or the equivalent arizona_template:get(id, Bindings) / az:get(id, Bindings)). Static binaries and composed values are not allowed. This is a compile-time check.

The id serves three roles simultaneously: DOM id (document.getElementById), views map key (server-side state tracking), and wire protocol target (op scoping). All three must be the same value. Using ?get(id) ensures the template renders the same value the parent passed in Props and the server uses for tracking. id is a restricted key -- the handler's mount callback cannot override the value passed by the parent in Props, and no later callback that keeps the view running (handle_update/3 or the default patch merge, handle_event/3, handle_info/2, a handle_drain/2 that does not stop) may change or drop it: each raises restricted_key_modified.

Dispatcher error tagging. Each arizona_stateful:call_* wrapper catches error:undef and error:function_clause only when the failing stack frame is the user's exact callback, and re-raises with a structured reason carrying an error_info annotation: {missing_callback, Mod, Name, Arity}, {unhandled_event, Mod, Event, Bindings}, {unhandled_info, Mod, Info, Bindings}, {unhandled_update, Mod, Props, Bindings}, {unhandled_unmount, Mod, Bindings}, or {render_no_clause, Mod, Bindings}. arizona_stateful:format_error/2 turns each into a sentence that names the offending module/event and (when known) the view id. Errors raised from inside a callback's body propagate untagged.

handle_event/3 effects: a list of arizona_js:cmd() tuples, built using functions in arizona_js.erl. Same commands used in template attributes. Common effects:

  • arizona_js:dispatch_event(Name, Payload) -- dispatches a CustomEvent on the client's document
  • arizona_js:set_title(Title) -- sets the browser document title
  • arizona_js:reload() -- reloads the page
  • Any other arizona_js command (toggle, show, hide, etc.) also works as an effect

handle_info/2 is invoked for any Erlang term landing in the gen_server mailbox (?send, ?send_after, pubsub, raw Pid ! Msg). If not exported, messages are silently dropped (checked via erlang:function_exported/3).

handle_update/3 reacts to new props arriving from an enclosing context: a parent re-rendering an embedded child with new Props, or a patch navigation delivering the new route's params to a route root (navigation as the root's prop source). If not exported, the framework merges Props into Bindings directly (call_handle_update/4). Effects is the accumulator of effects already queued for this update cycle; return it extended with this callback's own effects (the default returns it verbatim) -- so a patch reaction can set the document title or redirect, threaded without any caller-side concatenation. See "SPA navigate / patch route resolution".

For an embedded child, "new props" means "the diff re-evaluated this child's ?stateful descriptor while the view already existed under the same id and handler". That happens when the slot holding the descriptor re-evaluates, and the props may then be term-equal to the previous ones: a read outside them (case ?get(tab) of _ -> ?stateful(c, #{x => ?get(x)}) end, an event changing only tab) or a prop derived from a changed binding whose value did not (x => ?get(n) div 10). The framework does not filter equal props out: a view entry keeps the child's bindings, which the callback may have transformed, and not the props it was last given, so there is nothing to compare against without storing a second copy per view. Keep handle_update/3 idempotent for equal props, and compare against the child's own bindings when a side effect (subscribe, timer) must run only on an actual change.

The descriptor is not re-evaluated, so handle_update/3 is not called, when:

  • the slot is dep-skipped (none of its reads changed);
  • the child sits in a stream ?each item the diff did not touch -- an insert evaluates only the inserted and changed items, not the existing ones;
  • the child's own root is az-nodiff, or it sits inside an az-nodiff element: it takes its props at mount only, so changed props never reach it either.

And it is mount/1, not handle_update/3, that runs on the child's first appearance and when a render keeps the id but switches the handler (the old instance is unmounted first).

unmount/1 runs when an instance is removed -- either the parent stopped rendering it (conditional, a list or stream item leaving the DOM, or navigate) or the live process is shutting down. Use it to release resources (timers, external subscriptions). Return value is discarded. Do not ?send or ?send_after to the view from it: on a removal or a navigate the view is gone by the time the message arrives, so the live process raises {unknown_view, ...}, or delivers it to a new handler that reuses the id (see "Per-view messages and the timer registry").

Dynamic eval return values

  • iodata() -- rendered value
  • #{s => ..., d => ...} -- nested template (recursively rendered/diffed)
  • #{stateful => Handler, props => Props} -- stateful child descriptor
  • #{callback => Fun, props => Props} -- stateless child descriptor
  • remove -- sentinel that triggers OP_REMOVE_NODE

Change tracking

arizona_template:get/2,3 tracks which binding keys each dynamic reads via the process dictionary key '$arizona_deps'. The structure is a map of keys, e.g. #{count => true, title => true}, or undefined when inactive.

Lifecycle per dynamic (in eval_one_v):

  1. erlang:put('$arizona_deps', #{}) -- start tracking
  2. The dynamic's fun is called -- each arizona_template:get(Key, Bindings) adds Key => true
  3. erlang:erase('$arizona_deps') -- harvest the dep map
  4. Deps are stored in the snapshot as deps => [#{key() => true}], parallel to the d list

Nesting: Stateful children and nested templates save/restore '$arizona_deps' (SavedDeps = erlang:get(...) before, erlang:put(..., SavedDeps) after) to prevent child get calls from polluting the parent's dep list.

Inactive paths: render/1, diff/2, and SSR never set '$arizona_deps', so arizona_template:track/1 sees undefined and is a no-op.

Consumption: Only diff/4 uses deps. deps_changed/2 checks whether any key in a dynamic's dep map appears in the Changed map (via maps:intersect). If none do, the dynamic is skipped entirely -- its fun is never called.

Op codes

Defined in the public header include/arizona_wire.hrl. Every client mirrors them by hand (assets/js/arizona.js, its worker, e2e/utils/native_client.js, the Android Wire.kt, the iOS Wire.swift), and arizona_wire_SUITE checks each copy against the header. The web client must declare the whole set; a native client must declare every op its backend can emit (all but OP_LIST_PATCH, which arizona_native:supports_list_patch/0 opts out of).

CodeNameArgsDescription
0OP_TEXT[target, value]Replace marker content (text, nested tmpl, plain each)
1OP_SET_ATTR[target, attr, value]Set attr (command: its array; native bool: JSON bool)
2OP_REM_ATTR[target, attr]Remove attribute
4OP_REMOVE_NODE[target]Remove element
5OP_INSERT[target, key, pos, html]Stream insert (pos=-1 -> append, otherwise index)
6OP_REMOVE[target, key]Stream remove
7OP_ITEM_PATCH[target, key, innerOps]Stream item patch
8OP_REPLACE[target, html]Element swap (navigate); target is the OLD view id
9OP_MOVE[target, key, afterKey]Stream move (afterKey=null -> prepend)
10OP_LIST_PATCH[target, subOps]Single-root plain-list ?each positional item patch

Code 3 is unassigned. It was OP_UPDATE ([target, html], an innerHTML write on the resolved element), removed once every container full render moved to the marker-aware OP_TEXT -- a content slot's az is not reliably carried by an element, so innerHTML on whatever the client resolves destroys the slot's static siblings (the whole view when that element is the view root). Nothing emits it and no client claims it. It is free to reuse for a genuinely new op: codes 0..9 emit as a single byte (arizona_socket's op-code encoder) and 10+ cost an extra one per op, so the cheap range is worth reclaiming -- but a reuse must land on the server and all four clients together, since a code means whatever both ends agree it means.

A content-slot dynamic -- a value, a nested template, or a plain-list ?each -- is anchored by <!--az:X-->...<!--/az--> comment markers in SSR (no wrapper element carries the slot az), so its diff patch replaces only the span between the markers, leaving the slot's static siblings and the enclosing element intact. A single-root plain-list ?each patches in place with OP_LIST_PATCH: its subOps address items by DOM-order position between the markers ([OP_ITEM_PATCH, idx, innerOps] patches item idx, [OP_REMOVE, idx] removes it, and [OP_INSERT, idx, html] inserts), so an item change never touches the container's childList. The wholesale marker-aware OP_TEXT re-render is the fallback -- used when positional patching is unsound (the old slot was not a list, the item is not a single root element, or the list rendered a per-item child view), and also when it would be sound but larger (the container shifted far enough that per-item patches outgrow one re-render -- see outgrows_re_render/3 above). A scalar value or a nested template still patches via that OP_TEXT.

That fallback re-renders the whole container, so list_changed/3 decides whether it is warranted by whether a slot RENDERS differently (item_changed/2), not by term inequality. A value can differ as a term yet render the same bytes: an integer against its binary (1 and ~"1", both "1") is invisible. Answering on term inequality tore the container down to rebuild byte-identical markup, losing focus, scroll position and every ?local inside it for nothing.

The same question is asked of EVERY slot, not only a container's, by maybe_make_ops/5 -- one decision point shared by the plain and the dep-aware walk. A value that changed as a term has not necessarily changed on screen, and the write is not free: an ?OP_SET_ATTR is a style-recalc trigger, and the client's applySetAttrOp additionally assigns the live property for value, which can move a caret or drop a selection in an input the user is working in. An attribute is compared like any other value with one exception -- a boolean is not a value there. true renders as a bare attribute and false removes it outright, so neither may be called equal to a string that happens to print the same: true against ~"true" really is a change, from a bare attribute to name="true".

Nothing is rendered to answer it in the common case. collapses_to_same_bytes/2 runs only once the terms already differ, and two same-type scalars are settled by their types alone: to_bin/1 is the identity on binaries, and integer_to_binary/atom_to_binary and the shortest round-trip float format are injective, so two distinct ones cannot print the same. Only the pairs that genuinely can collapse are rendered -- a value against a different type. An escape marker is deliberately not comparable: to_bin/1 unwraps it, so {arizona_esc, ~"<b>"} and a bare ~"<b>" look identical to it, while the wholesale re-render escapes the wrapped one and not the bare one -- treating them as equal would drop a visible change and in one direction leave unescaped markup on screen. A pair SHARING the marker is unwrapped and compared, which is what makes the comparison reach real templates at all, since the parse transform marker-wraps every content slot value. A map (nested template, each snapshot, child view) or a descriptor tuple is never rendered either: it counts as changed, the conservative answer. The snapshot is still advanced to the new values when the re-render is suppressed, so nothing drifts.

Context -- inside <svg> a <title> is not raw text. The classification takes the content context (raw_text_kind/2, second argument html | foreign), threaded through the transform by content_context/2: <svg> opens foreign content and <foreignObject> switches back. In foreign content an element is ordinary parsed markup -- comments really are comments -- so title is classified none there and its slot keeps markers and stays diffable. It is the only tag that differs: script/style stay raw in both, and textarea cannot appear in foreign content. This matters because an SVG <title> is a graphic's accessible name, so a bound one would otherwise freeze after SSR with no symptom. MathML is deliberately not tracked -- it would need its own integration points to be correct, and leaving it in html keeps existing behaviour rather than half-handling it.

The context reaches a construct compiled elsewhere through the top-down marking pass. A local element helper is spliced into the caller's element AST before that element compiles, so it carries the context directly. A ?each is compiled bottom-up, before the enclosing element, so the pass appends the backend and the content context it sits in to the call as two bare atom arguments (each(Fun, Source, Backend, Context)), and the per-item template compiles in that context. Because the marking happens at the call site, a fun row/1 callback defined elsewhere in the module inherits it too.

An ?each is marked once, at the place it appears in the template that renders it. A hoisted Items = ?each(...) is bound outside every template, where the pass can only give it the default target and context, so that marking is provisional: the hoisted value has it stripped (prepare_inline/2) and is marked again where Items is spliced in (splice_vars/4, at the root of the ?html/?native/?terminal that uses it, or the target and context of the ?each whose callback or source uses it). It therefore compiles exactly as the same ?each written at the use site -- native nodes under ?native, foreign content inside <svg> -- and the splice is checked like written code, so a hoisted ?html used inside ?native is a cross_target_nesting compile error rather than a render-time cross_target_child. A local element helper does not re-mark what it is handed: its body is marked, but an argument -- a written ?each already compiled bottom-up, or a spliced hoisted one already marked -- keeps the context of the call site. So wrap(?each(...)) with wrap(X) -> {svg, [], [X]} called from HTML content compiles its items as HTML; write the ?each inside the <svg> element for SVG treatment.

A ?stateless/?stateful child is the one construct that cannot: it is a separate template with no call site at compile time, so a <title> there is classified html and its slot comes out markerless. That much is not fixable -- the same child may be used in both contexts, and nothing in its own source says which.

The update still lands, though, because the child's own slot in the caller does have markers. make_ops/5 checks whether a markerless slot inside a nested template changed value, and escalates to a whole re-render of that template (markerless_changed/2) rather than dropping the change the way diff_dynamics/4 does at a template's top level. The distinction is capability, not policy: a top-level markerless slot has no enclosing target to patch against, a nested one does.

Backends name their own markers, so the transform holds no target vocabulary: a backend with one content context returns the same marker for every context, which makes the renaming inert for it by its own answer rather than by a target check in the pass.

A local element helper is still the better factoring for SVG chrome. That is already the documented split -- a helper factors markup, ?stateless is for a component with its own props -- and the helper keeps the fine-grained patch (one slot, one value) where the child pays a re-render of its whole template for the same change. The child is correct either way now, just coarser. None of this is fixable by classifying title as none everywhere and special-casing <head>: that inverts which side fails silently, and the other side fails worse. Comment markers inside a real HTML <title> are literal text, so a layout's page title would render <!--az:0-->Home<!--/az--> in the browser tab -- visible corruption traded for an invisible frozen accessible name. A compile warning is no better: the only available signal is "a <title> holding a dynamic", which is exactly what a legitimate <head> title looks like.

Raw text is an HTML-only classification. All four tags are raw text in HTML, but inside <svg> the parser is in foreign content where script and style content is ordinary parsed markup: a comment there is a real comment node and a bare < starts an element. They are therefore none in foreign, which makes the slot both escaped and diffable -- the parser decodes the references straight back, so a &gt; b reaches CSS as a > b unchanged. Classifying them raw there had forced the author to mark the value ?raw, splicing it verbatim into a context that parses markup, where the </script>/</style> neutralization behind that opt-out is not the breakout available.

Exception -- raw-text elements (script/style/textarea/title). The browser does not parse HTML comments inside these, so a comment marker becomes literal content and corrupts it (an inline module script's <!-- is even a SyntaxError). A dynamic content slot inside a raw-text element is therefore markerless: it renders at SSR with Az = undefined, and the diff engine skips any undefined-Az dynamic (diff_dynamics/3 and diff_dynamics_v/5).

The full set follows the HTML tree builder, not just the four common tags: <xmp>, <iframe>, <noembed> and <noframes> are RAWTEXT exactly like <style> (the list lives in arizona_html), so they classify raw too and their slots need the same ?raw opt-out. A value there cannot close the element: the neutralizer checks only each element's own end tag there (</xmp becomes <\/xmp; </script and </style are plain text in an <xmp> and are left alone). Nothing decodes in RAWTEXT, so no rewrite is invisible: the backslash shows in an <xmp>, which displays its content, but only in text that would otherwise have closed it, and <iframe>/<noembed>/<noframes> content is never displayed. Two relatives are deliberately left out. <noscript> is RAWTEXT only where scripting is enabled, and there it is never displayed; it is displayed only where scripting is off, where it parses as ordinary HTML and no live view ever connects, so it stays an ordinary element whose values are escaped for that audience. Do not put sanitized ?raw HTML inside a <noscript>: a scripting-enabled browser reads its content as RAWTEXT, so markup a sanitizer passes as harmless (<p title="</noscript><img src=x onerror=...>">) closes the element there and runs. <plaintext> has no end tag at all -- everything after it, the rest of the page and the client script included, would read as text -- so the HTML backend refuses it at compile time.

The address goes on the element instead. Markers cannot live inside a raw-text element, but an attribute on it is fine, so compile_element/5 gives such an element its own az and compile_element_children/7 folds its whole content -- statics and dynamics both -- into one nested template held there. Any inner change re-renders that element's content, shipped as a single ?OP_TEXT against its az by make_ops/5's markerless escalation. Without this the value silently never updated, in the same template where a sibling <p> patched normally, and a <textarea> or an SVG <title> inside a ?stateless/?stateful child froze at its SSR value with no diagnostic. The escalation's granularity is what makes it safe: one raw-text element, not the enclosing template, so a <textarea> is untouched when a sibling <title> changes.

Escaping is preserved structurally rather than reimplemented -- the children compile through the ordinary compile_children/3 walk, so each dynamic keeps its own policy (raw verbatim through raw_text/4 neutralization, escapable HTML-escaped via esc_spec).

An executable <script> is the one case where updating is questionable, and az-nodiff is the answer. A script element carries an "already started" flag, so mutating its text after it has run does not re-execute it: the element's source would read one thing while the code in effect says another. That is a browser rule, not a framework choice. The update is still right for the sanctioned use -- a ?raw(json:encode(Data)) data island, whose consumer reads textContent later -- so the fold applies by default and the fold guard honours az-nodiff, which keeps an executable script markerless and render-once exactly as before. The backend classifies the tag via the arizona_renderer raw_text_kind/2 callback: raw (script, style and the other RAWTEXT elements) renders the value verbatim (the browser decodes no character references there, so HTML-escaping would corrupt it -- this is what makes a ?raw JSON-LD blob or a computed inline boot-script URL correct); escapable (textarea/title) HTML-escapes a scalar (references are decoded there) but is still markerless. Because a raw slot is spliced byte for byte and escaping is impossible there, the parse transform requires the value to carry a literal ?raw(...) at the slot (dynamic_in_raw_text): an unmarked ?get in a <script> would otherwise be the one content position where user data reaches the output unescaped, free to close the JavaScript string it sits in. Literal script/CSS text is static, not a slot, so it is unaffected; escapable elements escape and so are not gated. The opt-out marks the value trusted for HTML escaping, not for the raw-text tokenizer -- a trusted JSON blob's own string data can still spell an element breakout -- so arizona_html:raw_text/4 unwraps the ?raw and neutralizes the payload anyway. It takes the tag, because which sequences break out is a property of that element's tokenizer state rather than of raw text at large. </script/</style becomes <\/script in both elements -- strictly only the appropriate end tag (the one whose name matches the element being parsed) closes a raw-text element, so </style> inside a <script> is ordinary text, but both names are neutralized in both as a harmless superset that keeps this half of the rule tag-independent. <!-- and <script (which together reach script-data-double-escaped, where the element's own </script> stops closing it and the rest of the document is swallowed) have their < rewritten as \u003c, but only inside <script>: <style> content is RAWTEXT, which has no escaped state, so rewriting them there would defend nothing while corrupting the stylesheet (\u003c is a JS/JSON escape a CSS parser reads as u003c, and <!--/--> are legitimate CSS CDO/CDC tokens). A tag name counts only where the tokenizer reads one -- followed by whitespace, / or >, or by the end of the value, which a neighbour may complete -- so i<scripts.length is left alone. It also takes the slot's static neighbours (the template text right before and after it in the element), because the tokenizer reads static and value as one stream: ~"var s = '<" followed by a value /script>... closes the element though the value holds no <. When the < is the static's, the value's first byte is escaped instead (\u002f in a script, a space-terminated CSS hex escape \2f in a style), and a value ending in </scr is judged against a static ipt> after it. Every rewrite therefore decodes back to the original in the context it is applied to. It covers every shape to_bin/1 can turn into attacker-chosen bytes: a binary, an atom (rendered with atom_to_binary, so it carries bytes exactly like a binary), chardata -- the documented remedy ?raw(json:encode(Data)) hands it an iolist, not a binary, so a binary-only guard would wave a breakout through on the very form the compile error recommends -- and a ?raw wrapping any of those. An integer, a float and an effect command (whose JSON arizona_effect:encode/1 already HTML-escapes) cannot spell a sequence, so they pass through.

Where the known text ends, a partial sequence counts. A slot sees at most 8 bytes of each static neighbour; past them -- another slot, whose bytes exist only at render time, or the element's end -- the next bytes are unknown, so a value ending in a strict prefix of a sequence (<, </scr, <!, <!-, <scr) is neutralized as if the rest followed. Every slot therefore defuses the partial it holds, whatever its neighbours are: a constant ?raw(~"x='<") before an attacker's /script>..., two values split by a static (~"</scr", ~"i", ~"pt>"), and three slots reaching the double escape (~"<!", ~"--<scr", ~"ipt>") all stay inside the element. The one thing not checked is the template's own static text, which is the author's markup: statics spelling a close tag around an empty value (~"</scr", empty, ~"ipt>") are the author's own, and an empty value has no byte to escape. A dynamic attribute on a raw-text element stays fully diffable -- only the content slot is markerless. Limitation: the slot will not update after the initial render, and ?local is unsupported inside a raw-text element (no marker to address); a live ?get there silently freezes at its first value. Addressing a marker-anchored slot with an op that writes the resolved element whole is a bug: no element carries the slot az, so the client resolves the slot through its comment marker to the enclosing element, where such a write clobbers its siblings -- and when the enclosing element is the view root, the whole view. That is why every content-slot ?each container render is the marker-aware OP_TEXT: both the plain-list clause and the order-keyed stream clause of arizona_diff's make_op/3 emit it, as do full_update/5 and diff_stream/4's no-order (type-switch) clause. It is also why the innerHTML op (code 3) was removed outright rather than kept for the cases that happened to resolve correctly. A stream's incremental ops keep their own op codes: they carry the same container az as the target (the item is named by key in a later field) but mutate one keyed child instead of the container's whole content, so the full-render op code does not govern them -- their own limitation is placement, below. The client refuses OP_REPLACE/OP_REMOVE_NODE on a marker-resolved target outright, so a stray one warns and skips instead of destroying a view.

The stream -> list type switch is consistent with this: a binding that was an arizona_stream and becomes a plain list routes through diff_list/4 -> full_update/5 -> OP_TEXT, which replaces only the surviving marker span -- so the list renders as real HTML and the slot's siblings survive. The stream's keyed az-key items are element children, and stream ops (insert/remove/move/patch look items up among the slot span's keyed children) never delete the comment markers, so the marker is still present at switch time.

The list -> stream switch and every other stream container full render now use the same OP_TEXT, so a stream ?each sharing a content slot with static siblings re-renders in place instead of clobbering them.

Stream incremental ops are anchored to the slot span. insertItemEl and moveItemEl resolve slotBounds(el, az) and place relative to the slot's markers when the container has them -- a tail insert goes before the closing <!--/az-->, a move-to-head after the opening one, and keyedChildren restricts positional indexing to the span. So an each sharing its container with static siblings no longer strands items after a footer or before a header.

The marker-only container (no element carries the slot az at all -- a stream among a live root's static siblings, or in a content-slot list fragment) works the same way: resolveOpTarget resolves it through its opening marker to the enclosing element, and the markers are that element's direct children, so slotBounds finds the span and OP_INSERT/OP_MOVE place within it.

The same holds inside a stream item. An item's innerOps resolve through the same three arms scoped to the item (element, compound base, marker), so a nested stream whose marker sits in a deeper element of the item -- behind a conditional fragment, say -- is placed within its own span. An inner az that resolves to nothing is skipped with a warning; it is never retargeted at the item itself, which would let an OP_TEXT rewrite the whole item.

Key lookup is scoped to the span too. Two streams can share one parent element -- a second ?each among siblings takes the compound az <El>:1, whose base is the same element -- and their keys can overlap. Lookups by key therefore read only the keyed children between the stream's own markers, so an OP_REMOVE or OP_ITEM_PATCH for one stream never lands on the other's item.

Target scoping

Patch targets on the wire are "viewId:relativeTarget". Root view <<"page">>: "page:0", "page:1". Child view <<"counter">>: "counter:0", "counter:1". Bare targets (no :) resolve to the view root via document.getElementById(target) -- used by OP_REPLACE during navigate.

arizona_live emits ops with relative targets, nesting a child view's ops as [ChildViewId, ChildOps]. arizona_socket applies the view id in two steps, both at JSON-encode time: flatten_ops/2 walks that nesting and returns a flat list of {ViewId, RawOp} tuples, each tagged with its owning view (no string surgery); the custom encoder op_encoder/2, passed to json:encode/2, then writes each target inline as "<ViewId>:<Az>". Building the scoped target straight into the output iodata avoids allocating a rewritten op list first. Inner ops of an OP_ITEM_PATCH are item-relative and are deliberately not scoped.

The socket's op flattening unwraps the [ChildViewId, ChildOps] nesting only at the top level of an ops list, so a ?stateful child inside a stream ?each item ships the wrapper still nested inside its OP_ITEM_PATCH innerOps. The client tells the two apart by the head: an op code is a number, a wrapper's head is the child view id, whose ops it resolves against getElementById(ChildViewId) and applies with the same item-op dispatcher (so a grandchild wrapper recurses).

Repeated stateless components -- per-instance az namespacing

A stateless component's inner az ids derive only from its own template fingerprint (<Fp>-<n>, baked at compile time by scope_az/4), and it has no view_id boundary of its own. So the same render function rendered more than once in a view (?stateless(card, #{...}) twice) would emit byte-identical az targets; on the client every diff op resolves with querySelector (first match) and lands on the first DOM instance. To keep each instance an independent diff target, arizona_template:scope_slot/2 namespaces a slot value's snapshot -- its fingerprint, its statics' baked az/marker ids, and every inner dynamic az (recursing through inline nested templates) -- by the enclosing slot az at runtime, where the slot is in scope. A stateless child evaluates to a plain snapshot map (the #{callback, props} clause of arizona_eval:eval_val/eval_val_v and arizona_render:render_ssr_val returns it directly, with no wrapper tag), and the per-dynamic caller (eval_one/eval_one_v/render_ssr_one, which knows the slot az) applies scope_slot/2. So instance A at slot <<"p-0">> becomes p-0-<Fp>-<n> and instance B at slot <<"p-0:1">> becomes p-0-1-<Fp>-<n>; nested stateless children compose the full slot path. The prefix is made colon-free (: -> -) because the client treats : as the content-slot-index separator (a base element az never contains one) and recovers a base element by stripping at the first colon -- an internal colon in the prefix would misroute multi-slot and ?each-among-siblings ops, so no client change is needed.

scope_slot/2 discriminates purely by structure (it delegates to the same scope_val/2 used when recursing inside a scoped snapshot): a stateless child and an inline nested template (a conditional-tail branch, or an explicit nested ?html) are byte-identical #{s, d} snapshot maps, so both are namespaced by the slot az -- there is no tag telling them apart, and none is needed. Uniform scoping is also strictly more correct than scoping only stateless children: two structurally identical conditional branches in different slots share a fingerprint, so without the slot prefix their inner ids would collide on the client exactly as repeated stateless children do. A stateful child (its own view_id boundary), an ?each container (items addressed relative to the container, not by global az), and a client-owned ?local slot are left untouched -- only the container/element az in the enclosing statics is prefixed. A stateful child is skipped by its snapshot's view_id: the live path sets it via make_child_snap, and the SSR path (render_ssr_val) sets it too, so the SSR HTML (which the client reuses on connect) and the live snapshot produce identical ids -- without it, a stateful child nested in a stateless parent would be scoped only at SSR and its diff ops would miss the DOM.

Prefixing a statics list is a binary:replace per element -- pure and deterministic in (prefix, statics) -- so scope_snapshot memoizes it per (prefix, fingerprint) in the process dictionary: a re-render or diff of the same slot (and each level of a deep nest, whose inner statics would otherwise be re-walked once per ancestor) reuses the cached scoped statics instead of re-scoping. Only the first render of a given slot pays the walk; the fingerprint keys the statics the same way the client's statics cache does.

Scoping must not touch user content

A static carries framework markers (az="…", <!--az:…-->) and user bytes, and a static text child is spliced verbatim (the raw-HTML seam), so a page that shows markup carries those same sequences as ordinary content. Both scoping passes therefore discriminate rather than search-and-replace blindly:

  • Compile time (scope_az/4) is structural. The parse transform accumulates each static as a list of segments, with the markers it emits held as tagged {az_attr, Id} / {az_slot, Id} entries rather than bytes; scoping rebuilds exactly those through the backend's az_attr/1 / text_slot_open/1 with the fingerprint prepended, and copies literal segments through untouched. The fingerprint is taken over the unscoped segments, so scoping does not change it. Each dynamic attribute also leaves a {dyn_attr, Name, Bytes} segment carrying its name: HTML renders the name inside the dynamic, so without it two templates differing only in a dynamic attribute's name would share a fingerprint and the diff would patch one into the other, leaving the old attribute.
  • Runtime (the scope_static/3 renderer callback) has only bytes, so it anchors on the template's own fingerprint: every framework az in a compiled static is <Fp>-<id>, so the backend matches az="<Fp> / <!--az:<Fp> (HTML) or "az":"<Fp> (native), never the bare marker opener. After a pass the statics read <Prefix>-<Fp>-<id> and f becomes <Prefix>-<Fp>, so re-scoping an already-scoped snapshot anchors correctly too. The terminal backend writes no markers, so its scope_static/3 is the identity.

Server integration

The core engine (arizona_template, arizona_render, arizona_diff, arizona_live, arizona_stream, arizona_pubsub, arizona_js, parse transform) is transport-agnostic. The server is an arizona_server adapter package (the server env's required adapter), not part of Arizona; each adapter routes natively and must pass the black-box arizona_server_conformance_SUITE through its own arizona_conformance_driver -- the full contract is in adapters.md. The arizona_roadrunner package wires roadrunner in through a thin layer of modules that translate between roadrunner's callbacks and Arizona's shared pipeline (arizona_cowboy does the same for Cowboy with three):

  • arizona_roadrunner_server -- the arizona_server adapter: boots the listener and compiles routes
  • arizona_roadrunner_router -- compiles route specs into roadrunner's dispatch
  • arizona_roadrunner_http -- HTTP handler; sends what arizona_http:reply/3 returns
  • arizona_roadrunner_ws -- WebSocket handler; answers the upgrade with arizona_ws:upgrade/3 (the middlewares' stash rides the 101), starts the socket with arizona_ws:init/1, forwards frames to arizona_socket, and relays drain through arizona_socket:drain/2
  • arizona_roadrunner_controller -- controller-route handler; maps what arizona_controller:dispatch/3 returns onto roadrunner, routing a loop's messages to the controller's handle_info/3
  • arizona_roadrunner_req -- implements the arizona_req behaviour (the request abstraction consumed by handlers), including the optional resolve_route/3 for SPA navigate

The arizona_req behaviour is the boundary between the server and the engine: handlers and the shared pipeline only ever see an arizona_req:request(), never roadrunner's native request type.

Configuration -- arizona_config

Every arizona app-env value may be a literal or an environment-variable reference resolved at startup. arizona_config centralizes both the read and the resolution:

  • get_env/1 / get_env/2 -- drop-in replacements for application:get_env(arizona, Key[, Default]) that pass the stored value through resolve/1. The scattered scalar reads (arizona_origin, arizona_session, arizona_flash, arizona_crypto, arizona_sup, arizona_req, arizona_session_store_ets) call these.
  • resolve/1 -- expands references in an arbitrary term. Called once at the top of each adapter's start/2 (arizona_roadrunner_server, arizona_cowboy_server) to resolve the whole server opts map, so nested port / scheme / tls / proto_opts references work and every direct caller (the test server, CT suites) benefits without change.

Reference forms:

  • {env, "VAR"} -- required. Returns $VAR as a binary; raises env_not_set (with a format_error/2 message) when unset. For a secret with no sensible default.
  • {env, "VAR", Default} -- optional. Coerces $VAR to the type of Default, or returns Default when unset.

Coercion is by the default's Erlang type (env vars are always strings): integer, float (an integer value like "2" reads as 2.0), boolean ("true"/"false", case-insensitive), binary, atom (list_to_existing_atom/1), or list (a comma-split into trimmed binaries -- the csrf_origins / secret_key_previous shape). The required form yields a binary. A default of any other type (a map, a tuple, a list of atoms) has no coercion and raises unsupported_default (with a format_error/2 message) whether or not the variable is set.

resolve/1 recurses into maps, lists, and 2-tuples only:

resolve({env, Var}) -> ...;                 %% required
resolve({env, Var, Default}) -> ...;        %% optional, coerced
resolve(Map) when is_map(Map) -> #{K => resolve(V) || K := V <- Map};
resolve(List) when is_list(List) -> [resolve(E) || E <- List];
resolve({A, B}) -> {resolve(A), resolve(B)};
resolve(Other) -> Other.

Limiting tuple recursion to 2-tuples (proplist pairs like {port, ...}, {certfile, ...}) is the safety property: route tuples ({live, Path, Handler, Opts} and friends -- all >=3-tuples) fall to the Other clause and pass through untouched, so an operator-supplied literal {env, _, _} sitting inside a route's bindings/state is never rewritten. Resolution is idempotent, so resolving in start/2 is safe even if a caller already resolved.

The flip side is that env is a reserved tag: a reference is recognised by shape alone, so a proplist pair that merely happens to be keyed env ({env, "production"} among a listener's options) is read as a reference to the variable production -- substituted when it is set, and raising env_not_set when it is not. That message says {env, _} is the reference form, so a hijacked pair is diagnosable; the fix is to key the option something else.

MCP server

An Arizona app exposes a Model Context Protocol (MCP) server to AI agents (Claude Code, Cursor, Claude Desktop) by implementing the arizona_mcp behaviour and mounting it on a route:

{mcp, ~"/mcp", my_mcp, #{sessions => true, origins => [~"http://localhost:3000"]}}

MCP rides the same transport as everything else -- it is pure application-layer code, a controller (arizona_route:controller/1 turns an {mcp, ...} route into arizona_mcp_handler behind no pipeline), with no MCP-aware feature in any server. The boundary: the server adapter supplies the mechanism (routing, the request, the controller {loop, ...} reply, the {arizona_disconnect, _} signal); Arizona supplies the JSON-RPC 2.0 envelope, arizona_sse framing, the MCP lifecycle, the tool/resource/prompt registry, the session lifecycle, and the peer/Origin/auth policy.

Transport

MCP's Streamable HTTP maps onto controller reply kinds with no new transport feature:

  • POST -- the client sends one JSON-RPC message. The handler decodes and dispatches; it returns a buffered application/json reply, or -- for a tools/call carrying a _meta.progressToken from a client that accepts text/event-stream -- answers as an SSE stream (the tool's notifications/progress, then the result, then close).
  • GET (session mode) -- opens the long-lived server-to-client SSE channel as {loop, 200, [event-stream headers], State}; handle_info/3 forwards server-initiated notifications and stops the loop on {arizona_disconnect, _}.
  • DELETE (session mode) -- tears the session down.

The channel works on every protocol the adapter sends loops over; roadrunner wires them on h1, h2, and h3 (h2/h3 multiplex many sessions over one connection with no head-of-line blocking). Every Push flushes one frame to the wire with no coalescing, so a notifications/* message reaches the agent at emit time.

Progress streaming

When a token-bearing tools/call streams (the client accepted SSE), the tool runs in a worker process so the connection loop is free to push its progress as it arrives. In stateless mode the loop spawns the worker; in session mode the session spawns it (so the session stays responsive to a cancel) and the worker reports its threaded-back state on completion. Either way the tool emits via the Ctx handed to handle_tool/4 -- arizona_mcp:progress/2,3 relays each notifications/progress to the POST's loop, which pushes it; the final result is the last frame, then the loop stops. The context is inert (a no-op) for a non-streaming call, so a tool can always call progress/2,3.

A JSON-RPC object is encoded by whoever produces it, never by the process that writes it to the wire. json:encode/1 rejects a binary that is not valid UTF-8, and a callback module can return one; encoding at the producer keeps that failure inside the producer's crash guard, which answers -32603 like any other callback crash. So the tool worker frames its own progress notifications and its final result (arizona_mcp_handler:message_frame/1), and the connection loop only pushes bytes; the buffered reply paths encode inside the same guard. The one object that cannot be answered is a server-initiated notification -- it carries no id -- so an unencodable one is logged and dropped rather than taken out on the session process, which would kill every request in flight on it.

Dispatch, cancellation, and timeouts

Requests run in a worker process (the only exceptions are resources/subscribe/unsubscribe, which own a session-keyed pubsub subscription and run no app code), so the session process never blocks on app code -- it stays responsive to cancels, idle-reap, and server pushes even while a tool runs. Buffered (non-streaming) requests are served through a one-at-a-time queue (so their state threading stays serialized) and replied to asynchronously; a request that outlives request_timeout_ms (default 60s) frees the client with a -32603 while the session keeps running it. A streaming tools/call runs concurrently in its own worker, reading a state snapshot and threading nothing back -- so the serialized buffered queue is the only path that mutates session state, and nothing races on it.

Any in-flight request is cancellable: a notifications/cancelled {requestId} (routed to the session) or a client disconnect kills the worker and frees the caller -- a buffered caller with a -32603, a streaming POST loop told to stop. The session monitors each worker, so an unexpected worker death frees the caller with a -32603 rather than hanging it.

An in-flight streaming request holds its session alive the way an attached channel does -- the idle TTL is suspended while a stream runs, so a long stream is never reaped mid-run, and re-arms once it finishes or is cancelled. A buffered worker deliberately does not hold the session: the idle TTL stays the safety net that reaps, and so kills, a stuck buffered tool. Tearing the session down (DELETE, idle TTL, or shutdown) kills any still-running worker, so a blocking tool cannot orphan and run forever.

Stateless vs session mode

Without sessions => true every request is self-contained: the handler runs init/1 per request, dispatches, and discards the state. With sessions => true, initialize mints an Mcp-Session-Id and starts an arizona_mcp_session process (owned by arizona_mcp_sup, not linked to the connection, so it outlives any single request); later requests carrying that id route to the process via arizona_mcp_session_registry, which runs them one at a time (each in a worker, so a slow tool never blocks the session) against its held state. A stateful callback's returned state threads back into the session, so the session is genuinely stateful across requests; a crash answers -32603 and leaves the prior state intact. An idle session is reaped after session_ttl_ms (default 5 minutes).

Resumability and server push

The session assigns a monotonic id to each emitted SSE event and buffers the last session_buffer_max of them; a client that reconnects with Last-Event-ID gets the newer events replayed. Two ways to push a notifications/* message:

  • broadcast/3 -- publishes to an arizona_pubsub channel; every session subscribed to it (via its optional channels/1 callback) forwards the notification. Decoupled -- the caller holds no session reference.
  • notify/3 -- pushes to one session by its Mcp-Session-Id.

Both are no-ops for a session with no attached SSE channel.

Resource subscriptions and templates

resources/subscribe joins the session to a per-uri arizona_pubsub channel ({mcp_resource, Uri}); arizona_mcp:resource_updated/1 broadcasts to it, so every subscribed session forwards notifications/resources/updated. resources/unsubscribe leaves the channel, and pg auto-removes a session on exit, so there is no per-session subscription bookkeeping. It is the same fan-out path as broadcast/3 + channels/1, just keyed per uri and joined at runtime. The optional resource_templates/1 callback backs resources/templates/list (URI templates like mem://user/{id}), paginated through the same list_reply.

Logging and completion

logging/setLevel stores a minimum severity in the session map (log_min_severity, default info), threaded back like any other state; arizona_mcp:log/3 resolves the level severity caller-side and casts to the session, which emits a notifications/message only when the message is at or above the stored minimum. completion/complete decodes the ref ({prompt, Name} | {resource, Uri}) and the partial argument, calls the optional complete/3 callback, and caps the returned values at 100, setting hasMore. Both are capability-gated (logging / completions).

Pagination

The */list methods paginate with opaque cursors, framework-side: the tools/1 / resources/1 / prompts/1 callbacks keep returning their full list, and the transport slices it by an offset cursor (base64 of the offset), emitting nextCursor when more remain. The page size is the route's page_size opt (default 50), so a list that fits returns in one page with no cursor. A malformed cursor is a -32602; an offset past the end returns an empty final page. The callbacks never see a cursor. Because the cursor is an offset, it assumes a stable list across pages -- a list mutated mid-pagination can skip or repeat an item (acceptable for the near-static MCP lists, and a list_changed notification has the client re-list anyway).

Resource limits and keep-alive

Three opt-in knobs bound a session-mode route. max_sessions caps the live sessions per route (the registry tags each session with the request path it opened on and counts by it, so two routes sharing a handler module still cap independently); an initialize past the cap is answered with a 503 plus a JSON-RPC error body. The count is read just before starting, so a burst of concurrent initializes can admit a few over the cap -- fine for a soft limit. session_max_pending (default

  1. bounds the per-session buffered-dispatch queue; a dispatch arriving with the queue full is rejected with a -32603 rather than enqueued. session_keepalive_ms (default 30s, infinity disables) makes the session emit a periodic roadrunner_sse:comment/1 over an attached channel so idle proxies don't close the stream; the comment carries no event id, so it is never buffered for resumption, and the timer runs only while a channel is attached.

Capability gating and security

init/1 returns an atom-keyed capability map; the transport serves resources/* and prompts/* only when the matching capability was advertised, answering method not found otherwise. Tools are always available. Before any dispatch a request passes the security gate: the Origin allowlist (a DNS-rebinding defense), the loopback-peer check unless allow_remote_access => true (arizona_req:peer/1), and the route's optional auth hook, which is handed the arizona_req:request().

Known caveat -- loop backpressure

The SSE channel pushes notifications into the loop process's mailbox. If an app pushed faster than the client drains, an h1 loop mailbox is unbounded (h2/h3 have implicit stream-window backpressure). A dev-tool MCP server pushes trivial volume, so this is not on the critical path; it matters only for a deployment streaming high-volume notifications.