Interactivity with no framework at all
You'll build a tool library that has no component framework in it. The pages are the same lowered templates as any other FSR app. The interactive bits are custom elements, which the browser upgrades in place over markup the server already wrote. When a region needs to talk to the server it uses htmx, asking the host for one segment of a route. Nothing hydrates. The import map ends up with six entries in it.
Before you start
Straight from crates.io. No Node, no package manager.
cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version
Every command and screenshot on this page was captured with fsr 0.x.
Scaffold it
fsr new writes no framework, which for once is exactly what you want. Ask for the two directions this tutorial uses and you can name both in one command:
$ fsr new shed
$ fsr use shed/app elements htmx
mapped @snapfire/fsr-client/elements /static/js/fsr/elements.js
mapped @snapfire/fsr-client/htmx /static/js/fsr/htmx.js
added htmx.org htmx.org/htmx.org.bundle.mjs 60373 bytes
types @snapfire/fsr-authoring fsr 0.13.0
types @snapfire/fsr-client fsr 0.13.0
types htmx.org htmx.org 2.0.10
edit src/main.ts: import htmx from "htmx.org";
edit src/main.ts: import { bindHtmx } from "@snapfire/fsr-client/htmx";
edit src/main.ts: bindHtmx(htmx); after enableNavigation()
The edit lines are the part it won't do for you: three lines for src/main.ts that you'll add in a later section, printed now so you know they're coming. Everything above them is done.
That leaves an import map of six entries, which is the whole client side of this application:
{
"imports": {
"@snapfire/fsr-client": "/static/js/fsr/index.js",
"@snapfire/fsr-client/std": "/static/js/fsr/std.js",
"@snapfire/fsr-client/store": "/static/js/fsr/store.js",
"@snapfire/fsr-client/elements": "/static/js/fsr/elements.js",
"@snapfire/fsr-client/htmx": "/static/js/fsr/htmx.js",
"htmx.org": "/static/js/vendor/htmx.org/htmx.org.bundle.mjs"
}
}Templates import their placements from @snapfire/fsr-authoring/template, which is the same Island, Link and Slot you know, typed without React. The scaffolded layout already does.
Write a custom element in a template
A hyphenated tag is just markup. The lowerer treats it as an element and its attributes as attributes, so the server renders everything inside it before any script runs:
<shed-tally count={reserved}>
<button type="button" className="tally" aria-expanded="false">
{reserved} reserved
</button>
<div className="tally-panel" hidden>
<p>Reservations are kept in the session cookie.</p>
</div>
</shed-tally>The dialect's declarations type a hyphenated tag as a custom element whose attributes are its own. You still get <divv> flagged as the typo it is. An hx-get on an anchor passes for the same reason any hyphenated attribute does.
An attribute the declarations don't name takes a scalar, children, a style object or a handler. An array of objects fails the typecheck, because an attribute on an element nothing hydrates is text. If you want structured data in there, give the element a template and make it a prop, which the next section covers.
The element's module wires up what the server wrote:
import { get, subscribe } from "@snapfire/fsr-client/store";
import { reservedCount } from "../store.js";
class ShedTally extends HTMLElement {
#stop: (() => void) | null = null;
connectedCallback(): void {
const button = this.querySelector<HTMLButtonElement>("button.tally");
const panel = this.querySelector<HTMLElement>(".tally-panel");
if (!button || !panel) return;
const toggle = () => {
panel.hidden = !panel.hidden;
button.setAttribute("aria-expanded", String(!panel.hidden));
};
const show = (count: unknown) => {
if (typeof count === "number") button.textContent = `${count} reserved`;
};
button.addEventListener("click", toggle);
show(get(reservedCount));
const unsubscribe = subscribe(reservedCount, show);
this.#stop = () => {
button.removeEventListener("click", toggle);
unsubscribe();
};
}
disconnectedCallback(): void {
this.#stop?.();
this.#stop = null;
}
}
customElements.define("shed-tally", ShedTally);get and subscribe from @snapfire/fsr-client/store are the whole store adapter here. React gets a hook and Vue gets a reactive holder because those frameworks have somewhere to put a reactive value. An element doesn't, so it reads the value once and takes a callback for the changes, updating the DOM itself. The layout's loader seeds the key exactly as it would for a React island.
The get comes first because subscribe only tells you about changes after you subscribe. Here the server already wrote the count into the button so you'd get away without it, but anything the server didn't write, a property or a computed label, would sit empty until the key next moved.
disconnectedCallback undoes what connectedCallback did. A morph that moves an element disconnects it and connects it again, so skip this and a moved tally ends up with two click listeners that cancel each other out; a removed one stays subscribed and keeps getting written to.
Nothing registers this element with FSR. src/main.ts imports the module, the module calls customElements.define and the browser upgrades every matching tag it already parsed.
A shadow root the server writes
An element that wants its own styles has a shadow root. The parser attaches one from a <template shadowrootmode="open"> inside the element before any script runs, so the server can write it. You don't write that template by hand in the page, though. It lives in its own file under elements/, named after the tag:
// elements/loan-planner.tsx
export default function LoanPlanner({ deposit, days, max }: { deposit: number; days: number; max: number }) {
return (
<>
<style>{":host { display: block } output { font-weight: 600 }"}</style>
<label>
Borrow for
<input type="range" name="days" min="1" max={`${max}`} value={`${days}`} />
<output>{days} days</output>
</label>
<p>£{deposit} held until it comes back.</p>
</>
);
}The page then places the tag and nothing else:
<loan-planner name="days" deposit={20} days={7} max={14} />The template's props are the element's attributes. The build writes them into generated/elements.d.ts, so leaving one out is a typecheck error at the placement rather than an empty shadow root you find in a browser:
$ fsr build shed/app
routes/page.tsx(10,8): error TS2322: Type '{ name: string; deposit: number; max: number; }' is not assignable to type 'Placed<({ deposit, days, max }: { deposit: number; days: number; max: number; }) => TemplateNode>'.
name passes because the class reads it and the template doesn't take it. Anything the template does take is checked by its declared type, so deposit="twenty" gets you Type 'string' is not assignable to type 'number'.
Here's what the server writes:
$ curl -s localhost:3000/
<loan-planner name="days" deposit="20" days="7" max="14"><template shadowrootmode="open"><style>:host { display: block } output { font-weight: 600 }</style><label>Borrow for<input type="range" name="days" min="1" max="14" value="7"/><output>7 days</output></label><p>£20 held until it comes back.</p></template></loan-planner>
Styled and laid out from the first paint, with no framework involved and no stylesheet to link.
Notice the scalars went to two places: into the template and onto the host as attributes, which is what a :host([disabled]) rule or a getAttribute in the class wants. A prop that isn't a scalar only goes to the template. Give the planner a rates={[{ label: "day", pence: 150 }]} and the list renders inside the shadow root with no rates attribute on the host anywhere, because an attribute on an element nothing hydrates is text and an array isn't. That's also why you can't just hang an array off an ordinary custom element: the checker won't have it.
The template is markup and nothing else. Put a useState or an onClick in it and the build stops:
$ fsr build shed/app
`elements/loan-planner.tsx#default` is an element template, which is markup the server writes: it holds state or a handler, which belong in the element's class
Same if you misname the file, since the filename is the tag:
$ fsr build shed/app
elements/LoanPlanner.tsx: an element template is named after its tag, which is lowercase, starts with a letter and holds a hyphen
Getting at that root from the class
innerHTML doesn't attach declarative shadow roots, so an element that arrives inside an htmx swap finds its template sitting there as an ordinary child instead. shadowOf covers both cases:
import { shadowOf } from "@snapfire/fsr-client/elements";
const root = shadowOf(this, this.#internals);It hands back the root the parser attached or attaches one from the template it finds and takes the template out. Navigation is already fine without it: the navigator writes what it applies with setHTMLUnsafe where the browser has it, so a page you reached by clicking a link keeps its shadow roots.
You get an open root unless the template says otherwise. Return a <template> as the template's root and its shadowroot attributes are the root's:
return (
<template shadowrootmode="closed" shadowrootdelegatesfocus>
<style>{":host { display: block } output { font-weight: 600 }"}</style>
<label>
Borrow for
<input type="range" name="days" min="1" max={`${max}`} value={`${days}`} />
</label>
</template>
);That comes out as <template shadowrootmode="closed" shadowrootdelegatesfocus> on the element. Only the four shadowroot attributes are allowed on it; anything else stops the build, because the parser throws that template element away once it becomes the root. A closed root isn't on this.shadowRoot, which is the second argument's job: pass your ElementInternals and shadowOf finds it there. An element with no internals gets null.
There's a second gotcha with shadow roots and it's why the planner has internals at all. A control inside one has no form owner, so that slider won't be submitted by the form around it however you nest it. If you want the element to be a field, say so with static formAssociated = true and hand the value over yourself through ElementInternals.setFormValue, under the name on the host. An action then reads it like any other field.
A definition that waits until its element is in view
Everything above gets defined when the entry module runs, which is what you want for a masthead and not what you want for a panel at the bottom of the page. An element has no mount step, so the thing a timing can defer is its definition:
<Island when="visible" define="@src/elements/time-ago.ts">
<ul className="loans">
{loans.map((loan) => (
<li key={loan.tool}>
{loan.tool} <time-ago datetime={loan.back}>back {loan.back}</time-ago>
</li>
))}
</ul>
</Island>The child here is an element, not a component, so the server writes its markup inside the island marker and the list reads fine with no script at all. Build it and the registry has exactly one entry:
import { defineMounter, registerIsland } from "@snapfire/fsr-client";
export function registerIslands(): void {
registerIsland("src/elements/time-ago.ts#default", { loader: () => import("../src/elements/time-ago.js"), mount: defineMounter });
}defineMounter doesn't mount anything. All it does is let the island machinery import the module when the list scrolls into view; the elements inside then upgrade themselves. The module needs an export for the dynamic import to work, so export the class.
Build it
$ fsr build shed/app
Externals: '@snapfire/fsr-client', '@snapfire/fsr-client/elements', '@snapfire/fsr-client/htmx', 'htmx.org'
All externals resolve through "importmap.json"
rendered elements/loan-planner.tsx#default lowered static
routes/error.tsx#default lowered static
routes/layout.tsx#default lowered static
routes/not-found.tsx#default lowered static
routes/page.tsx#default lowered static
plan generated/plan.sexp 2.4 KB written
typecheck tsc 7.0.2 from cache, clean
Every route module says static, so none of them gets bundled and the element template says it too. What ships is src/**/* plus the two generated files.
Ask the host for one segment
An island round trip sends back a payload the client applies. htmx doesn't work that way: an attribute names a URL, the response is markup, the markup gets swapped into a target. So what it needs from the host is one segment of a route as plain HTML with nothing around it. That's __fragment in the query:
$ curl -s 'localhost:3000/?category=Timber&__fragment'
<section class="page"><nav class="chips">…</nav><ul><li>Circular saw</li><li>Chisel set</li></ul>…
<script type="application/json" data-sf-store>{"shed/reserved":{"$":"f","v":2.0}}</script>
$ curl -s 'localhost:3000/?__fragment=nope'
no slot named `nope` on this route
Either way the host renders the whole route, layouts included, waits for every deferred segment instead of streaming a fallback, then picks the one segment out of the tree and writes it with no shell, no segment delimiters and no sidecar. Your loaders never see the key: ctx.query and the segment keys are whatever they'd be for a document.
__fragment with no value gives you the page segment. __fragment=loans gives you the parallel slot of that name, wherever it sits on the route. An unknown name is a 404.
The shelf chips are that request written as attributes:
<a href={`/?category=${shelf}`} hx-get={`/?category=${shelf}&__fragment`} hx-target="closest .page" hx-swap="outerHTML" data-sf-native>
{shelf}
</a>data-sf-native tells FSR's navigator to keep its hands off this anchor so htmx gets the click. Leave it off and both of them try to handle it.
Tell the two libraries about each other
Look at the last line of that fragment again. It carries the same inert store seed a full document carries, so the store keeps following the server through htmx just as it does through a payload. Getting the two libraries to notice each other is one line:
import htmx from "htmx.org";
import { boot, enableNavigation } from "@snapfire/fsr-client";
import { bindHtmx } from "@snapfire/fsr-client/htmx";
import { registerIslands } from "@generated/islands.js";
import "./elements/shed-tally.js";
import "./elements/loan-planner.js";
registerIslands();
boot();
enableNavigation();
bindHtmx(htmx);You pass htmx in rather than have the client import it, so the binding uses whatever version your import map names.
bindHtmx wires it up both ways. After htmx settles a swap it calls adopt(), which reads any seed nothing has read yet; that's how the masthead count moves on a page whose layout was never re-rendered. Then it calls scan(), which would mount any island the fragment happened to place. Going the other way, the navigator fires sf:navigate once it applies a payload plus an sf:fill for each deferred segment; on those the binding calls htmx.process so the forms and regions the navigator wrote are live.
Skip that second direction and a form you reached by clicking a link is markup htmx never saw. The browser posts it natively and the whole document reloads, which at least makes the bug obvious.
Recap
Everything interactive on this page came from something the browser already ships: an element definition, a shadow root the parser attached and an attribute naming a URL. FSR's job was rendering the markup, typing the tags so a bad placement fails at build time, answering the fragment requests and keeping the store seeded across both kinds of update.
Next up: 098. React and Vue on one page.