arizona_stateful behaviour (arizona v0.4.0)
View SourceBehaviour for stateful Arizona handlers.
A stateful handler owns a piece of UI with its own bindings, lifecycle,
and event handling. The framework instantiates one set of bindings per
mounted instance and re-renders / diffs against that instance whenever
its bindings change. The same behaviour serves both route-level pages
(spawned as a live-process root, with request data supplied as bindings
by the transport via arizona_middleware:extract/1 middlewares) and
embedded components (instantiated from a parent template via
?stateful(Handler, Props)).
Handlers include arizona_stateful.hrl, which sets the behaviour and
enables the parse transform that compiles ?html(...) calls in
render/1 into optimized template maps.
Required callbacks
mount/1-- runs once on instance creation; returns initial bindings plus any reset valuesrender/1-- returns the template for the current bindings; called on first render and after any change
Optional callbacks
handle_event/3-- responds to client eventshandle_info/2-- responds to mailbox messageshandle_update/3-- reacts to new props (from a parent re-render or apatchnavigation), threading the effect accumulatorhandle_drain/2-- coordinates a graceful shutdown before the live process exits (see "Graceful drain" below)unmount/1-- cleanup hook on instance removal
Resets and effects
Resets is a map of binding values that should be re-applied on top
of any modifications made by the handler -- typically used to clear
form fields or transient flags after they've been processed.
Effects is a list of arizona_effect:cmd/0 to run client-side after
the diff is applied (e.g. focus a field, dispatch a custom event).
Only the live-only callbacks carry effects. An effect is a command sent
over the WebSocket, so it needs a connected client to receive it -- and
mount/1 and render/1 also run on the request-free SSR path, where there is
none. The split follows from that: handle_event/3, handle_info/2,
handle_update/3 and handle_drain/2 never run at SSR and all return effects;
mount/1 runs on both paths and returns none (mount_ret/0 is
{Bindings, Resets}).
For a mount-time effect, self-cast from mount/1 and answer in
handle_info/2, which is live-only and does carry effects:
mount(_Bindings0) ->
Bindings = #{id => ~"page"},
?connected andalso ?send(arizona_connected),
{Bindings, #{}}.
handle_info(arizona_connected, Bindings) ->
{Bindings, #{}, [arizona_js:set_title(~"Ready")]}.The ?connected guard is load-bearing. ?send is self() ! Msg: inside a live
process that self-delivers and reaches handle_info/2, but at SSR self() is
the request process, whose mailbox nothing ever reads -- so an unguarded send is
silently dropped, with no crash to point at it.
This is also how a navigate gets a mount-time effect. A navigate remounts, and
mount/1 has no effects slot, so the effect arrives as its own pushed frame
rather than folded into the navigate reply.
Graceful drain
When the server adapter drains its listener (arizona_server:
notify_drain/2 during a rolling deploy, which leaves the listener
serving, or drain/2 for a full listener shutdown), each
live handler's optional handle_drain/2 runs. If a handler doesn't
export it, the framework defaults to exiting the live process with reason
{shutdown, drain} so the WebSocket closes with code 1001 (going
away). The JS client auto-reconnects (preserving any form state)
and the new live process mounts against the new server version.
To push a client-side indicator before closing:
handle_drain(Deadline, Bindings) ->
Remaining = Deadline - erlang:monotonic_time(millisecond),
{stop, Bindings, [
arizona_js:dispatch_event(~"draining",
#{~"ms_remaining" => Remaining})
]}.To stay alive past drain (e.g. a long-running export the user
explicitly chose to finish), return {Bindings, Resets, Effects}
like a normal handle_info/2. Return ok to acknowledge without
state change.
One exception -- a drain that lands before the view mounted. A
reconnecting client that promised its cached fingerprints
(_az_fps_follow) leaves the live process deliberately unmounted until
that frame arrives, and a deploy-drain broadcast is exactly what tends
to land in that window. handle_drain/2 does not run there: it
takes the handler's own bindings, and pre-mount those are still the raw
route bindings (route config plus middleware enrichments) that mount/1
never turned into the handler's own state -- a callback head
destructuring its mount keys would raise unhandled_drain and close
4500 instead of 1001. The live process takes the same default an
unexported callback gets (exit {shutdown, drain}), so the client's
form-state-preserving reconnect still runs. Nothing is lost: nothing has
been rendered for that connection to coordinate about, and the client is
mid-reconnect already.
handle_drain/2 vs unmount/1
Both run during a graceful shutdown, but they answer different questions and run at different points in the lifecycle:
handle_drain/2 | unmount/1 | |
|---|---|---|
| When | Before any exit decision | After the process decides to exit |
| Triggered by | Listener drain broadcast only | Any exit -- crash, normal, shutdown, drain |
| Can prevent exit? | Yes ({B, R, E} keeps the handler alive) | No, process is dying |
| Can push client effects? | Yes, reliably before close | Racy / unreliable |
| Has a deadline? | Yes (integer ms timestamp) | No |
| Purpose | Coordinate with the client | Local cleanup |
They compose: a deploy-time drain runs handle_drain/2 first, then
(if it returned {stop, ...} or wasn't exported) the live process
exits normal and unmount/1 runs during terminate. Both are
optional; implement either, both, or neither.
Sequence on a deploy drain when both are implemented:
- The server adapter sends the live process
{arizona_drain, Deadline}. - Live process's
handle_drain/2runs -- e.g. pushes a "reconnecting" indicator and returns{stop, B, Effects}. - Push frame leaves the WS session toward the client.
- Live process exits with reason
{shutdown, drain};terminate/2callsunmount/1for cleanup (unsub pubsub, release ETS, etc.). - WS session observes the exit and sends close code 1001.
- Client auto-reconnects to the new server version.
Why unmount/1 can't replace handle_drain/2: by the time
unmount/1 fires the process is exiting; messages sent from it
race the close frame. handle_drain/2 runs while the process is
fully alive, so push-before-close ordering is reliable in practice
on a single node (the framework sends the push from the live
process before returning {stop, normal, _}, so the WS session
sees the push frame before the EXIT signal).
Snapshot-template skew on post-drain reconnect
The JS client's auto-reconnect path treats the close as a transient
disconnect and preserves the current snapshot for the next session
(it adds _az_reconnect=1 to the upgrade URL; the server skips the
fresh-mount HTML and resumes diffing). If the deploy changed the
handler's render template, the new server's diff baseline won't match
the client's preserved snapshot, and the first diff may misalign
(visual glitches until the next full re-render).
This isn't drain-specific -- the same risk exists for any reconnect across a deploy -- but drain makes it more likely because clients reconnect deterministically on every deploy. Mitigations if the glitch is unacceptable for a given handler:
- In
handle_drain/2, pusharizona_js:reload()instead ofdispatch_event/2to force a full page reload on the client (loses any client-only state but guarantees template alignment). - Or use
arizona_js:navigate/1to redirect the client to a known good route on drain.
Default behavior (no effect or a custom dispatch_event effect)
takes the reconnect-with-snapshot path; choose explicitly when the
handler's template churns often.
Summary
Callbacks
Reacts to a transport-level drain signal. Optional.
Handles a client UI event. Optional.
Handles a mailbox message. Optional.
Reacts to new props arriving from an enclosing context. Optional.
Initializes a stateful instance. Called once when the framework mounts the handler.
Returns the template for the current bindings. Called on the first render and after every state change.
Cleanup hook called when the instance is removed. Optional.
Functions
Invokes the optional handle_drain/2 callback.
Invokes the optional handle_event/3 callback.
Invokes the optional handle_info/2 callback.
Invokes the optional handle_update/3 callback.
Invokes the required mount/1 callback on a stateful handler module.
Invokes the required render/1 callback on a handler module.
Invokes the optional unmount/1 callback on a handler module.
Formats dispatcher-tagged errors raised by the call_* wrappers above.
Types
-type bindings() :: arizona_template:bindings().
-nominal effect() :: arizona_effect:cmd().
-nominal effects() :: [effect()].
-type event_name() :: binary().
-type event_payload() :: map().
-type render_ret() :: arizona_template:template().
-type resets() :: map().
-type unmount_ret() :: term().
Callbacks
-callback handle_drain(Deadline :: integer(), bindings()) -> handle_drain_ret().
Reacts to a transport-level drain signal. Optional.
Fires when the listener broadcasts a graceful-shutdown notice
(typically during a deploy). The handler can push a "reconnecting"
indicator to the client, then return {stop, ...} to exit the live
process cleanly so the WebSocket closes with code 1001 ("going away")
and the client reconnects to the new server version.
Deadline is the millisecond timestamp by which the listener will
stop waiting. Subtract erlang:monotonic_time(millisecond) to get
the remaining grace window.
Returns:
ok-- acknowledge but do nothing{stop, Bindings, Effects}-- pushEffects, then exit normal (default when this callback is not exported, with empty effects){Bindings, Resets, Effects}-- push diff + effects, stay alive (the handler opts to keep serving past drain)
Distinct from unmount/1: this runs before the exit decision and
can push client effects reliably before the close frame. unmount/1 runs
after, during terminate, and is the right place for local
cleanup. See the "Graceful drain" section above for the full lifecycle
ordering.
-callback handle_event(event_name(), event_payload(), bindings()) -> handle_event_ret().
Handles a client UI event. Optional.
Event is the event name dispatched from az-click, az-submit,
etc. Payload carries any auto-collected form data plus explicit
extra fields. Returns updated bindings, resets, and a list of
client-side effects to run after the diff applies.
-callback handle_info(Info :: term(), bindings()) -> handle_info_ret().
Handles a mailbox message. Optional.
-callback handle_update(Props :: bindings(), bindings(), effects()) -> handle_update_ret().
Reacts to new props arriving from an enclosing context. Optional.
Two sources deliver props here, with identical semantics:
- A parent re-rendering an embedded child with new props (the child gets the chance to merge / transform them).
- A
patchnavigation delivering the new route's params to a route root (navigation is the root's prop source -- the analogue of a parent).
If not exported, the framework merges Props into Bindings directly.
For an embedded child it runs whenever the diff re-evaluates the child's
?stateful descriptor while the view already exists under that id and
handler -- that is, when the slot holding it re-evaluates, not only when the
props changed. A read outside the props (an enclosing case ?get(tab) of ...) or a prop derived from a changed binding whose value did not change
(x => ?get(n) div 10) delivers props term-equal to the previous ones. The
framework does not filter those out: a view keeps its bindings, which this
callback may have transformed, not the props it was last given, so there is
nothing to compare against. Keep the callback idempotent for equal props, and
compare against your own bindings when a side effect (a subscribe, a timer)
must happen only on an actual change.
It is not called when the descriptor is not re-evaluated: a dep-skipped slot,
a stream ?each item the diff did not touch (an insert evaluates only the
inserted and changed items), or a child whose own root is az-nodiff or that
sits inside an az-nodiff element (props are taken at mount only). The first
appearance, and a render that keeps the id but switches the handler, call
mount/1 instead. A patch calls it on every patch.
Effects is the accumulator of effects already queued for this update cycle
(the originating event's effects, then each child's, folded through the
render). Return it extended with this callback's own effects -- they are all
pushed to the client, exactly as for handle_event/3. Returning the
accumulator verbatim (the default) preserves upstream effects; dropping it
drops them. This threads effects without any caller-side concatenation.
handle_update(#{section := S}, Bindings, Effects) ->
{Bindings#{section => S}, #{}, [arizona_js:set_title(S) | Effects]}.
Initializes a stateful instance. Called once when the framework mounts the handler.
Returns {Bindings, Resets} where Resets is reapplied after every
subsequent handle_event/3 / handle_info/2 to clear transient
fields. Use the empty map #{} if you have nothing to reset.
There is no effects channel here, because mount/1 also runs on the
request-free SSR path where no client exists to receive one. Self-cast to
handle_info/2 for a mount-time effect -- see "Resets and effects" in the
module doc.
-callback render(Bindings :: bindings()) -> render_ret().
Returns the template for the current bindings. Called on the first render and after every state change.
Must use ?html(...) so the parse transform can compile it into a
template map.
For a route-root view the ?html(...) must be the direct body of
render/1, not delegated to another function (render(B) -> page(B).). The
parse transform injects the az-view marker and enforces the {id, ?get(id)}
root only when it sees a literal template call as render/1's tail; a delegating
tail falls through both, producing a root with no view marker whose events
silently resolve to the enclosing view. Compose with ?stateful/?stateless
children inside a direct ?html(...) root instead.
-callback unmount(bindings()) -> unmount_ret().
Cleanup hook called when the instance is removed. Optional.
Fires from terminate/2 for any exit reason -- crash, normal,
supervisor shutdown, or drain. Use for local cleanup that doesn't
need to talk to the client (release ETS tables, unsubscribe pubsub
topics, close ports, etc.). Messages sent from here race the WS
close frame; for drain-time client coordination use handle_drain/2
instead.
Runs once per instance. An unmount/1 that raises does not stop the
other views' unmounts (a sibling removed in the same diff, or the root
on the way out); the first error is raised once they have all run.
Functions
-spec call_handle_drain(Handler, Deadline, Bindings) -> handle_drain_ret() when Handler :: module(), Deadline :: integer(), Bindings :: bindings().
Invokes the optional handle_drain/2 callback.
Defaults to {stop, Bindings, []} when the callback is not exported
-- the framework's safe-default response to a transport drain is to
exit the live process cleanly so the WebSocket closes with code 1001
("going away") and the client reconnects to the new server version. Re-tags a
no-matching-clause crash at the callback's head as {unhandled_drain, H, Deadline, Bindings}; errors raised from inside the callback body
propagate untagged.
-spec call_handle_event(Handler, Event, Payload, Bindings) -> {bindings(), resets(), effects()} when Handler :: module(), Event :: binary(), Payload :: map(), Bindings :: bindings().
Invokes the optional handle_event/3 callback.
Re-tags the failure when the immediate stack frame is the user's
handle_event/3 itself: a missing export becomes
{missing_callback, H, handle_event, 3} and a no-matching-clause
crash becomes {unhandled_event, H, Event, Bindings}. Errors
raised from inside the callback body propagate untagged.
-spec call_handle_info(Handler, Info, Bindings) -> {bindings(), resets(), effects()} | ok when Handler :: module(), Info :: term(), Bindings :: bindings().
Invokes the optional handle_info/2 callback.
Returns ok if the handler does not export handle_info/2, letting
the caller skip the subsequent diff + push cycle. Re-tags a
no-matching-clause crash at the callback's head as
{unhandled_info, H, Info, Bindings}; errors raised from inside
the callback body propagate untagged.
-spec call_handle_update(Handler, Props, Bindings, Effects) -> handle_update_ret() when Handler :: module(), Props :: map(), Bindings :: bindings(), Effects :: effects().
Invokes the optional handle_update/3 callback.
Effects is the effect accumulator threaded through the update cycle; the
callback returns it extended. Falls back to merging Props into Bindings
and passing Effects through unchanged if the callback is not exported.
Re-tags a no-matching-clause crash at the callback's head as
{unhandled_update, H, Props, Bindings}; errors raised from inside the
callback body propagate untagged.
-spec call_mount(Handler, Bindings) -> {bindings(), resets()} when Handler :: module(), Bindings :: bindings().
Invokes the required mount/1 callback on a stateful handler module.
-spec call_render(Handler, Bindings) -> arizona_template:template() when Handler :: module(), Bindings :: bindings().
Invokes the required render/1 callback on a handler module.
Re-tags the failure when the immediate stack frame is the user's
render/1 itself: a missing export becomes {missing_callback, H, render, 1} and a head-pattern mismatch becomes
{render_no_clause, H, Bindings}. Errors raised from inside the
callback body propagate untagged.
Invokes the optional unmount/1 callback on a handler module.
No-op if the callback is not exported. Re-tags a no-matching-clause
crash at the callback's head as {unhandled_unmount, H, Bindings};
errors raised from inside the callback body propagate untagged.
-spec format_error(Reason, Stacktrace) -> ErrorInfo when Reason :: term(), Stacktrace :: [tuple()], ErrorInfo :: #{general := iolist()}.
Formats dispatcher-tagged errors raised by the call_* wrappers above.
Picked up by erl_error:format_exception/3 via the error_info
annotation, so the dev error page (and crash log) reads a sentence
that names the failing module/callback and (when known) the view id.