DEV Community

Cover image for Write it for the server, run it in the browser: dual-target UI components in Fitz
Martin Palopoli
Martin Palopoli

Posted on

Write it for the server, run it in the browser: dual-target UI components in Fitz

TL;DR — The companion UI ships as server-rendered components. But
the exact same .fitzv source now also compiles to WebAssembly and
runs client-side — no rewrite, no hand-maintained "client version". A
markup component like a Button (which renders an SVG icon) and a
list-driven one like a Select (fed List<FieldOption>) both go from
server-only to runs-in-the-browser by changing the compiler, not the
component. 38 of 38 components dual-target today; the live gallery
runs 22 of them. (Part 9 of the FitzLiveViews series.)

Part 6 built the companion UI library — ~40 importable components,
server-rendered, styled with scoped CSS, extracted from a real admin app.
Part 8 taught the same .fitzv to hydrate: paint on the server, then let
a WASM app adopt the DOM. This part closes a gap between those two: making
the companion components themselves run client-side from their exact
server source.

The usual deal: two versions of every component

Every "isomorphic UI" story eventually hits the same wall. You have a
component that renders on the server. You want it to also be interactive
in the browser. In most stacks that means a second implementation — a
client build of the same widget, kept in sync by hand. The server renders
one thing; a JS bundle re-implements it.

Fitz's companion UI started there too: a server-rendered set, plus a
parallel hand-written client set for the live gallery. The bet was that
a subset could share one source. It turned out most of it can.

One source, two backends

A .fitzv single-file component is compiled by two backends:

  • SSR → classic Fitz that builds an HTML string. This is what the LiveViews runtime serves and diffs over the WebSocket.
  • client-WASM (fitz build --target wasm-client) → Rust compiled to WebAssembly that builds real DOM with web-sys, runs in the browser, no server.

Same <template>, same state, same events. The compiler emits two things
from one file. The question was never can they share templates — they
already do. It was: which real companion components hit a wall on the
WASM side, and why?

The three walls (and how they came down)

1. Markup helpers. A Button renders an icon: {raw_html(icon(name).raw)}.
That icon helper returns an SVG string wrapped in Html (fitz's
newtype over raw markup). Two problems on WASM: interpolating a markup
string into the DOM escapes it (you'd see &lt;svg&gt; as text), and
the Html type didn't exist client-side at all.

Fixed with two pieces. A raw-HTML sink: {raw_html(x)} injects the
string via set_inner_html on the parent instead of an escaping text node
(React's dangerouslySetInnerHTML model). And an Html shim: the
Html newtype maps to a tiny generated struct so helpers that return it
transpile. The neat part — on the SSR side the emitter strips the
raw_html(...) marker (classic interpolation is already raw), so the SAME
source is byte-identical on the server. Write {raw_html(icon(name).raw)}
once; it renders unescaped on both ends.

2. List-driven components. A Select is {#for o in options} over a
List<FieldOption> — a list of a nominal type — with {#if o.on} per
option. The {#for} and the field-access condition lowered fine. The wall
was feeding it data: passing <Select options="{opts}" /> (a
List<nominal> prop) and seeding opts: List<FieldOption> = [FieldOption
{ ... }]
(a List<nominal> default). Both were primitive-only on WASM.

Fixed by letting a bare state field lower to a .clone() for any
non-nullable target — a Vec<FieldOption> clones the same as an i64 —
and by teaching the default emitter to lower a nominal struct-literal.
Now Select, RadioGroup, and a BarChart (fed List<Bar>) all render
with real data client-side.

3. Helper-body list building. A pager helper does let out = []; for n
in 1..pages { out.push(n) }
. That range for and a .push on a local
list (not a state field) weren't supported in helper bodies. Small fix,
now they are.

The sweep: 38 of 38

With those in place, the honest measure: build every companion component
to WASM and see what compiles. 16 of the 18 remaining did with no
changes at all. Adding the earlier markup/list work got us to 36 of 38.

The two that held out were the interesting ones. Pager and
ConfirmDialog are controlled components: their buttons fire
fall-through events (data-flv-click="page_prev", confirm_delete)
that aren't the component's own events — they bubble up to the parent's
@ws loop, which does the real work. A fall-through event had no meaning
in a standalone browser mount with no parent loop.

So we gave it one. A data-flv-click whose name isn't a local event now
fires the component's callback slot when a composing parent binds it
(<Pager @page_prev="..." />), and is a documented inert no-op when
nobody does (standalone). The view checker was relaxed to accept that
binding when the child emits the event via data-flv-* — not only when
it declares it. With that, Pager and ConfirmDialog compile and render
to WASM too: 38 of 38 — one source, server and browser.

(Two more residual gaps closed in the same pass: interpolated props into a
Nullable<T> target now wrap Some(...), and a List<nominal> state
default can omit fields — they fill from the nominal's declared defaults,
byte-accurate with the server.)

The payoff

The live gallery now runs 22 companion components compiled to
WebAssembly, in the browser, from their exact server source — badges,
cards, alerts, inputs, a button with a real SVG icon, a select and radio
group fed live options, a bar chart, and now the controlled Pager +
ConfirmDialog. No parallel client versions. You wrote them for the
server; the compiler made them run in the browser too.

That's the whole pitch of a compiled, isomorphic language: the component
is the component. The runtime is a compiler flag.

Next: fine-grained reactivity (so a live text input keeps its caret) —
patch-in-place instead of naive re-render.

Top comments (2)

Collapse
 
svgicons profile image
Svg/icons •

Since icon(name) becomes raw SVG on both targets, how do you keep per-instance accessibility IDs and currentColor theming consistent between SSR and WASM?

Collapse
 
martin_palopoli profile image
Martin Palopoli • • Edited

Good question — two separate things there:

currentColor: consistent by construction, because there's literally one SVG string. The icon helper is a plain function that returns markup wrapped in Html:

// icon.fitz — one source, no per-target branch
fn icon(name: Str) -> Html {
let body = match name {
"trash" => "...",
"check" => "",
_ => ""
}
return html(
" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" \
class=\"flv-icon\">" + body + ""
)
}
That exact string is what SSR concatenates into the HTML and what WASM injects via set_inner_html (the raw_html(...) sink). currentColor and the 1em sizing are resolved by the browser's CSS engine against wherever the node lands — and CSS doesn't care whether the markup came from a server string or set_inner_html. Color is just inherited color from the enclosing control:

.flv-btn[data-variant="danger"] { color: #fff; } /* icon paints white /
.flv-btn[data-variant="ghost"] { color: var(--flv-text); } /
follows theme */
One string, resolved by the cascade either way — nothing to keep in sync.

Per-instance a11y IDs: the icons are presentational today — no

, no id, no aria-labelledby. The accessible name comes from the enclosing control, not the glyph. An icon-only Button interpolates aria-label per instance, and that interpolation is byte-identical across targets:</p> <p>// Button.fitzv — same template compiles to SSR string and to web-sys DOM<br> <button type="button" class="flv-btn" data-variant="{variant}"<br> data-flv-click="{on_click}" aria-label="{aria_label}"><br> {#if icon != ""}<span class="flv-btn-icon">{raw_html(icon(icon).raw)}</span>{/if}<br> {#if label != ""}<span class="flv-btn-label">{flv(label)}</span>{/if}<br> </button></p> <p><Button icon="trash" aria_label="Delete row" on_click="delete_row" /><br> So there's no per-instance icon ID to reconcile — the aria-label lives on the control and is one interpolation, emitted the same on both ends.</p> <p>You're pointing at a real boundary though: if an icon did need a unique <title id> + aria-labelledby per mount, a compile-time-baked SVG string can't mint that id on its own. You'd have to generate it in the component (a state field seeded identically on both ends) rather than inside the icon string — there's no shared per-instance id generator across SSR/WASM yet. That's the honest limit; it just doesn't bite for decorative glyphs.</p>