Native (JSON) render target
View SourceArizona renders HTML to browsers via ?html. The native target renders the
same server-side-diffing model to a JSON widget tree that a thin native
client (iOS/SwiftUI, Android/Jetpack Compose, or any other) turns into real
widgets. The server stays the source of truth: it ships JSON + diff ops over the
WebSocket and receives events back, reusing the entire engine — the diff,
the fingerprint cache, the op codes, and the transport — unchanged.
The web (?html) path is untouched: same HTML statics, SSR, SEO, and browser
client.
Authoring
Use ?native(...) instead of ?html(...). The element-tuple form, macros, and
handlers are identical:
-include_lib("arizona/include/arizona_stateful.hrl").
mount(Bindings) ->
{#{id => ~"counter", count => maps:get(count, Bindings, 0)}, #{}}.
render(Bindings) ->
?native({'Column', [{id, ?get(id)}, {padding, ~"16"}], [
{'Text', [], [?get(count)]},
{'Button', [{on_tap, arizona_android:push_event(~"inc")}], [~"+"]},
{'List', [], [
?each(fun item/1, ?get(items))
]}
]}).
item(Item) ->
{'Text', [], [maps:get(label, Item)]}.
handle_event(~"inc", _Payload, Bindings) ->
{Bindings#{count => maps:get(count, Bindings) + 1}, #{}, []}.- The tag is the widget type, verbatim.
'Column','VStack','Text','Button', … The framework is vocabulary-agnostic — it never interprets the tag; the client engine maps thetypestring to a real widget. There is no fixed widget list and no server-side validation: an unknown widget is the client's / your tests' concern, not a compile error. - Attributes are widget props. Static and dynamic both supported. Values are
string-encoded on the wire; the client coerces (typed props are a future
addition). A whole-value boolean prop is the exception: it is a JSON
true/false, static ({visible, true}) or dynamic ({enabled, ?get(on)}), and a dynamic one stays present in both states --OP_SET_ATTRsets it tofalserather than removing the prop. A staticfalsestays too ("enabled":false), unlike the web, wherefalsemeans the attribute is absent. Only a whole-value prop is typed: a boolean in content renders as the texttrue/false, an interpolated prop value ([~"a-", ?get(on)]) is text, so a boolean in it raisesbad_template_value. - Event handlers are props whose value is an effect command built with the
platform module:
{on_tap, arizona_android:push_event(~"inc")}. The command reaches the client as its JSON array ([0, "inc"]) whether it is folded into the statics or computed at runtime ({on_tap, ?get(cmd)}, carried indand patched byOP_SET_ATTR). A list of commands arrives as a list of arrays and runs in order, as on the web. A disabled command readsfalse({on_tap, case ?get(on) of true -> Cmd; false -> false end}); a client treats a tap prop that is not an array as no command and fires nothing. Events flow back exactly as on the web ([ViewId, Event, Payload]→handle_event/3). ?eachworks uniformly. A?eachinside?nativeis compiled for the native target automatically — no separate macro.?stateful/?statelesschildren also work; the embedded module must use the same target (see Constraints).
Serving HTML and native from one view
The framework injects nothing and decides nothing. To serve browsers HTML and
native apps a JSON tree from a single view, read the connecting client (its
User-Agent, supplied as a binding by arizona_middleware:extract([user_agent])) and
branch in render/1.
arizona_req:user_agent/1 returns the raw User-Agent header (no custom header
needed — most clients send one), or <<>> if absent. arizona_user_agent
turns it into a coarse, best-effort classification through pure, composable
helpers — call only the one(s) you need, no precomputed map:
browser/1— true when the UA carriesMozilla(browsers/webviews do; a native app's HTTP stack typically does not).os/1—watchos | tvos | webos | tizen | ios | android | windows | macos | linux | other. The smart-TV ones (tvos/webos/tizen) come from real TV-browser UAs;watchosand native apps need a descriptive UA (a generic one →other), so for native clients you control an explicit platform signal is more reliable than UA sniffing.mobile/1— best-effort mobile-device guess.
%% Route opts: middlewares => [arizona_middleware:extract([user_agent])]
mount(Bindings) ->
{#{id => ~"page", user_agent => ?get(user_agent)}, #{}}.
%% A browser/webview carries "Mozilla" -> HTML; a native app's HTTP stack
%% typically does not -> native. Reach for arizona_user_agent:os/1 or mobile/1,
%% or match the raw UA (e.g. your app's custom UA), only when you need to.
render(#{user_agent := UA} = Bindings) ->
case arizona_user_agent:browser(UA) of
true -> ?html({'div', [{id, ?get(id)}], [...]});
false -> ?native({'Column', [{id, ?get(id)}], [...]})
end.These helpers are heuristic and request-level; the extract([user_agent])
middleware runs on both the HTTP and WS paths. You own the binding name and the
branch -- if you prefer an explicit signal, your client can send a query param
and you extract it with arizona_middleware:extract([params]) instead.
The JSON widget tree
Each element compiles to a flat JSON object — type, az, and children are
reserved keys; every other key is a prop:
{
"type": "Column",
"az": "f1-0",
"az_view": true,
"id": "counter",
"padding": "16",
"children": [
{ "type": "Text", "az": "f1-1",
"children": [ { "type": "#slot", "az": "f1-1t0", "children": ["42"] } ] },
{ "type": "Button", "on_tap": [0, "inc"], "children": ["+"] }
]
}azis the diff-target token. Ops address"ViewId:az"; the client keeps anaz→ node registry.az_view(true) marks a view root; itsidprop is theViewId(theidreturned bymount). Events from a node route to the nearest enclosingaz_view.#slotis the addressable, transparent wrapper for any dynamic child (dynamic text, an?each, or a child component). The client renders a#slotby splicing itschildreninto the parent — a fragment. One#slotper dynamic child (the JSON analogue of the HTML comment markers).- No void elements — a childless widget is just
"children": [].
Wire protocol — reuses the fingerprint cache
Native uses the same op codes and the same statics-deduplication as the browser, so statics are sent once and never re-sent:
- The first time a fingerprint
fis seen, the payload is{"f": F, "s": [...statics...], "d": [...values...]}. - Once the client has cached
f(it reports them via["cached_fps", [...]]on connect), the server omitss:{"f": F, "d": [...]}. dcarries raw values: strings, numbers, and an effect command's JSON array. The browser concatenates them; the native clientJSON.stringifys each one as it interleaves.- Op codes (
OP_TEXT,OP_SET_ATTR,OP_INSERT/OP_MOVE/OP_REMOVE/OP_ITEM_PATCH,OP_REPLACE, …) are identical; only the payload content is a JSON widget skeleton instead of HTML. - A native client never loads an SSR page: its first frame is the live process's
mount_and_renderoutput wrapped asOP_REPLACEover the WebSocket. An HTTP GET of a native route is still served: the JSON document below, asapplication/json(each render backend names its document's media type viacontent_type/0; a render crash's error page stays HTML). The request-free render (arizona_render:render_to_iolist/1,render_view_to_iolist/2, soarizona_staticand the dev tools) zips the same JSON a client builds from that frame: each scalar a JSON string (a whole-value boolean prop a JSON boolean, an effect command its array), each?eacha nested array.
Native client contract
Reference implementations live in-repo: e2e/utils/native_client.js (a JS client used
by the native e2e project), clients/android/ (the Kotlin/Compose Android client), and
clients/ios/ (the Swift/SwiftUI iOS client).
All three are near-copies of the browser worker (assets/js/arizona-worker.js + arizona-core.js):
Cache statics by fingerprint in platform storage (browser uses IndexedDB); send
["cached_fps", [...]]on connect. Bound it: a fingerprint hashes a template's statics, so every edit mints a key and orphans the old one. The browser client keeps the 1000 most recently used and announces no more (FP_CACHE_MAXinassets/js/arizona-core.js); evicting only ever costs a re-send, since the server ships statics for anything unannounced.Interleave
s+dto reconstruct content — identical to the browser'szipTemplate, except eachdvalue is JSON-encoded (string → quoted, number → as-is) rather than text-concatenated. Recurse into nested{f,s,d}payloads and each{t:0,...}item-lists.Flatten fragments: render a
#slotby splicing itschildreninto the parent, and splice a nested array (an?eachexpansion) one level up. So["#slot children"]and[[item, item]]both flatten into the parent's children.Apply the op codes against a per-view
az→ node registry (the browser uses the DOM): an op's"ViewId:az"target resolves within that view, so sibling instances of the same stateful child — which share a fingerprint'sazvalues — don't collide.OP_TEXTsets a#slot's content,OP_SET_ATTR/OP_REM_ATTRset/drop a prop,OP_REMOVE_NODEdrops a node from its parent (a dynamic that returned theremovesentinel; one-way — re-adding needs a parent re-render), and the stream ops manage a container's keyed children — items keyed byaz_key, withOP_INSERT/OP_REMOVE/OP_MOVEreordering the list,OP_ITEM_PATCHapplying inner ops scoped to one item, and a container-levelOP_TEXTre-rendering the whole list.The registry is a derived index of the tree, not a snapshot of the first frame. Every op that (re)builds a subtree —
OP_TEXTreplacing a node's children,OP_INSERTgrafting a stream item — must index the new nodes and drop the destroyed ones, exactly asOP_REPLACEindexes what it renders. Otherwise everyazthe diff introduced is unaddressable: the browser re-queries the DOM each time and never sees this, but a registry built only atOP_REPLACEfails the very next op. That includes a nestedaz_viewthe payload brings in — the documentedcase ?get(flag) of true -> ?stateful(child, …)pattern installs a whole child view id through anOP_TEXT, and its first update is addressed to it. This holds for anOP_ITEM_PATCH's inner ops too: they address item-locally, but a subtree they rebuild still has to reach the per-view registry, because the child view it may introduce is addressed top-level (/native/stream-childis exactly this shape).Removal is the same obligation in reverse:
OP_REMOVE/OP_REMOVE_NODEmust drop the entries of what they detach. Skipping it is not merely untidy — becauseOP_INSERTindexes what it grafts in, a churning stream would add one registry (and pin one detached subtree) per insert/remove cycle, for the life of the connection, reclaimed only by a reconnect. Unindexing is identity-checked (registry[az] === node): stream items shareazvalues from one fingerprint, so deleting by key alone would unregister a surviving sibling.OP_REMOVE_NODEunindexes only when the splice actually found the node, so the registry never loses something still in the tree.Isolate each op — and each frame, and each effect. A batch's ops are independent, so an unexpected wire shape or an unresolvable target must log and skip that one op (as
applyOps/applyItemOpsdo inassets/js/arizona.js), degrading one slot instead of taking the app down. That includes an op code the client does not implement —OP_LIST_PATCHis browser-only today. The same applies to the two paths outside the op loop, which are equally server-driven: a frame whose JSON does not parse, and a malformed command in the"e"array. Both run on the client's main thread, where an escaping error is process death, not a lost frame. A tap keeps the strict, throwing path — it is a synchronous call on the caller's stack and the throw is the signal that a template built a bad command.On iOS this shapes the code, not just the error handling:
preconditionFailureand!trap, and a Swift trap is not catchable. Every wire-facing failure on the apply path therefore throwsWireError(FingerprintCache.statics,Interleaver,buildTree/addChild) or is guarded before the access (JSONValue.parseChecked, the effect argument, theOP_INSERTposition, which trapsArray.inserton a negative index).Run effects and navigate. A tap fires its node's command prop, routed to the node's nearest enclosing
az_view(the root, or a nested?statefulchild) — so events reach stateful children, not just the root. The prop holds one command or a list of them, told apart by whether its first element is an array, and a list runs in order; a prop that is not an array (falsefor a disabled command) fires nothing. The server's"e"(effects) array, one command per entry, is dispatched after the ops. The portable commands are handled —push_event, andnavigate(which sends["navigate", {path, qs}]on the same socket; the server'shandle_navigatere-mounts the target view and replies withOP_REPLACE). Web-only effects (set_title,dispatch_event, …) are skipped on native.Reconnect with backoff. On a dropped socket — any close other than a normal
1000, or an abrupt failure — reopen with exponential backoff ([1, 2, 5, 10]s steps, capped at 10s, ±20% jitter), resetting the attempt counter once a frame arrives. Because the native URL carries_az_reconnect=1, each reopen re-mounts and replies with a freshOP_REPLACE(native has no form state to preserve — that path is browser-only), so a server restart self-heals. An intentionalclose()(code1000) does not reconnect. To catch a silently half-open socket (no TCP error), the clients also send a 30s heartbeat ping ('0'; the server pongs'1'); a pong still pending at the next tick means the socket is dead, so they drop it and reconnect via the same path.
The native e2e exercises each example over the real socket: a counter
(/native/counter), a keyed list (/native/list), conditional tab switching
(/native/tabs), server-pushed ticks (/native/ticker), independent counters
(/native/multi), nested stateful children with per-child event routing
(/native/nested), a conditionally rendered stateful child whose view id only the
diff ever ships (/native/conditional) and the same shape nested inside a stream
item, where it arrives through an item-patch inner op (/native/stream-child),
navigation (/native/menu), and reconnect-after-drop. The in-repo Android
(clients/android) and iOS (clients/ios) samples are launchers that open /native/menu and
navigate to each on a device or Simulator.
Effect commands
Event-attribute values and handle_event/3 effects are built per platform,
all producing the same neutral wire format ({arizona_effect, [OpCode | Args]},
op codes in include/arizona_effect.hrl, encoded by arizona_effect):
arizona_js— web/browser builders.push_event,navigate, plus DOM-specific ones:toggle/show/hide, the class ops,set_attr/remove_attr,dispatch_event,focus/blur/scroll_to(CSS selectors),on_key(keyboard),set_title,reload.arizona_android— native builders for?nativeviews.push_eventandnavigate(duplicated; same wire as the web ones), and the home for Android-specific commands as they're added.
Only push_event/navigate are portable enough to exist on both — they're pure
client→server actions. The DOM/selector/keyboard commands (toggle, focus,
scroll_to, on_key, …) are web-only by nature and live only in arizona_js.
The reference native clients implement both portable commands: push_event
(from a tap or the "e" array) and navigate (a same-socket view transition).
Web-only effects appearing in the "e" array are skipped, so a handler that
returns them won't crash a native client.
Constraints
- One target per view tree. A
?nativeview's?stateful/?stateless/?eachchildren must also be?native(and vice-versa for?html). Mixing targets in one tree mismatches the statics format. This is a documented rule, not a checked one. - No server-side widget validation (let-it-crash / the client's concern).
- Props are string-encoded (a boolean, which is JSON
true/false, and an event prop's command, which is an array, excepted); the client coerces. Typed props, opt-in per-platform compile-time validators, and a portable cross-platform vocabulary helper are possible future additions. - A stateful child inside a stream item resolves through its own view. The
item's ordinary inner ops are applied via
OP_ITEM_PATCHagainst a flat item-localazmap, but a child view reaches the clients three ways, all of which land in the per-view registry: a[ChildViewId, ChildOps]wrapper nested inside the item patch (flatten_ops/2inarizona_socketunwraps that wrapper only at top level, so its first element is a view-id string, not an op code); anOP_INSERTthat grafts in an item already containing the child; and an item-patch innerOP_TEXTthat installs the child a conditional in the item template just switched on. That last one is why an inner-op rebuild indexes into the per-view registry as well as the item-local map — the child's own ops arrive top-level.