Other frameworks and none
The seam is a module id
A placement carries a module id and nothing else. The server writes <sf-i data-sf-module="src/ui/Holdings.vue#default"> around whatever it rendered there; the browser looks that id up in the island registry and calls the mounter the entry names. Nothing in the plan, the payload or the renderer knows which framework is behind an id.
That is why a page can hold more than one:
registerIsland("src/ui/Watch.tsx#default", { loader: () => import("./ui/Watch.js").then((m) => m.default), mount: reactMounter, patch: reactPatcher, unmount: reactUnmounter });
registerIsland("src/ui/Holdings.vue#default", { loader: () => import("./ui/Holdings.vue"), mount: vueMounter, patch: vuePatcher, unmount: vueUnmounter });mount runs the first time. patch runs when the server sends new props for an island that is already mounted, after an action, a revalidation or a navigation that keeps the region. The framework adapter decides what patching means: React re-renders the root, Vue assigns onto the reactive props object the component was mounted with. unmount runs when a navigation takes the island's markup away, nested islands first, so the framework can clean up; a root left running in a detached element holds everything its effects hold.
Those imports are static and each adapter imports its own framework, so the registry pulls in every framework the application uses as soon as the entry module runs, on every page of it. The dynamic loader defers the component module rather than the runtime. A framework stays out of the graph only when the build writes no mounter line for it, which is when the application has no island of that kind anywhere.
Vue
snapfirec does not compile Vue. It hands every .vue file in the project to snapfirec-vue, which carries Vue's own compiler and runs it in QuickJS. cargo install snapfire_vue is the whole install; without it the build stops, naming the binary and the files that needed it.
Two files come back per component: the module, plus the <style scoped> block as <Name>.vue.css with its data-v- attribute. The compiler lists those stylesheets in dist/.snapfire-build.json and the host links each one after the application's own CSS.
A template imports the component as a file and places it inside Island:
import { Island, type Children } from "@snapfire/fsr-authoring/template";
import Tonight from "@src/ui/Tonight.vue";
<Island when="load">
<Tonight count={planned} />
</Island>;@snapfire/fsr-authoring/template carries the same placements typed without React, which is what an application with no React in it wants. Children is what it writes where a React layout writes ReactNode.
Four things to know:
- The build asks
snapfirec-vueto describe the file and lowers the component from that, into the same render tree a TSX template lowers to. The server writes what Vue's own server renderer would write for the same component and props; Vue hydrates over it. Place the component outsideIslandand the build fails, naming the tag: Vue's root is the island's. - A component holding what the build cannot read stays
foreign. The server writes the marker empty with its props and Vue mounts it fresh, which is what every.vuefile got before the build read them. The report names the file, the line and the construct. Residue isv-model,v-bindof a whole object, a named or scoped slot, a component inside the template,v-slot,injectand a<script>withoutsetup. - A Vue island's children are the placement's own markup. Where the render placed a
<slot />they sit inside it in an<sf-s data-sf-children>region, which Vue hydrates as an element it rendered; where it placed none, they ride in an inert<template data-sf-children>after the markup and the mounter puts them in place before Vue hydrates. A React island takes the same region as itschildren. useStorefrom@snapfire/fsr-client/vuereturns a reactive holder with a.value, not aref, so a template readsheld.valuewhere a plainrefwould auto-unwrap. The server reads it too, so a seeded count is in the markup at first paint.
Custom elements
A hyphenated tag is markup. The lowerer takes it as an element and its attributes as attributes, so the server renders it and everything inside it before any script runs. The dialect's declarations type a hyphenated tag as a custom element whose attributes are its own, so a typo in an ordinary tag is still caught while <shed-tally count={n}> and an hx-get on an anchor both pass. An attribute the declarations do not name takes a scalar, children, a style object or a handler; an array of objects fails the typecheck, since an attribute on an element nothing hydrates is text.
Nothing registers the element with FSR. The entry module imports a file that calls customElements.define and the browser upgrades every matching tag it has parsed. fsr use <app> elements adds the import-map entry and, with --example, writes a template and a class to start from. get and subscribe from @snapfire/fsr-client/store are the store adapter: no hook, no ref, a read and a callback. The read comes first because a subscription only carries what changes after it. A disconnectedCallback that undoes the subscription and the listeners is the other half, since a morph that moves an element disconnects and reconnects it.
Element templates
An element's shadow root is written by a template under elements/, one file per tag, the file 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>
<input type="range" name="days" min="1" max={`${max}`} value={`${days}`} />
<output>{days} days</output>
</>
);
}A page places the tag alone, <loan-planner name="days" deposit={20} days={7} max={14} />. The template's props are the element's attributes and the build types the tag with them in generated/elements.d.ts, so a missing or mistyped one fails at the placement. An attribute the class reads and the template does not take, name above, passes. The build lists the template in its report the way it lists a route module, lowered static.
A scalar prop reaches the template and the host, which is what a :host([disabled]) rule wants. A non-scalar reaches the template only, which is the way to give an element structured data at all.
A template holding state or a handler stops the build, since those belong in the class. So does a file whose name is not a valid custom element tag, since the name is the tag.
The root is open unless the template returns a <template> of its own, whose shadowrootmode, shadowrootdelegatesfocus, shadowrootclonable and shadowrootserializable become the root's. Nothing else may sit on it, since the parser discards that element once it becomes the root.
innerHTML attaches no declarative shadow roots, so an element arriving inside an htmx swap finds its template as an ordinary child. shadowOf from @snapfire/fsr-client/elements answers both cases and takes ElementInternals as a second argument to reach a closed root. A navigation needs none of it: the navigator applies markup with setHTMLUnsafe where the browser has it.
An element has no mount step, so what a timing can defer is its definition:
<Island when="visible" define="@src/elements/time-ago.ts">The build registers that module with defineMounter, whose mount does nothing. The island machinery imports the module when the region comes into view and the elements inside upgrade themselves.
A page that is a Tera template
A page with no state is lowered to a tree the host walks. A page can skip the lowering and be a template the host renders from the file instead. Put page.tera where page.tsx would go and the route is the same route:
routes/
layout.tera the frame, placing the page with {{ slot(name="content") }}
layout.loader.ts
page.tera /
page.loader.ts
post/[slug]/
page.tera /post/{slug}
page.loader.ts
templates/
nav.tera a partial, included by this pathThe loader is unchanged: TypeScript, lowered, run by the host like every other loader. What it returns is the template's context, so an index writes {% for post in posts %} over the same posts a page.tsx would have taken as props. params, identity and locale reach the template through the loader rather than directly. A layout places the page where a TSX layout writes {children}:
<div class="board">
<header>{% include "templates/nav.tera" %}</header>
<main>{{ slot(name="content") }}</main>
</div>Every .tera under app/ is loaded into one Tera and named by its path under the app, which is why the include names templates/nav.tera and why a partial may sit anywhere outside vendor/, dist/ and generated/. extends resolves the same way. The build has nothing to lower, bundle or typecheck for a template, so the report lists it as template where a TSX page is lowered:
rendered routes/hello/page.tera#default template
routes/layout.tsx#default lowered static
routes/page.tsx#default lowered staticA template still places an island with {{ island(module="src/ui/Thing.tsx#default") }}; the build reads the literal and bundles that module, so an island can sit inside a page nothing else in the application hydrates. The host reads the templates at boot and refuses a plan naming one the tree does not hold, so a renamed partial is a boot error rather than an empty page.
One or the other, since a directory holding both a page.tsx and a page.tera is refused naming the two files. fsr use <app> tera writes an example page beside its loader. It needs an fsr built with the tera feature, which the published binary is.
htmx over fragments
An island round trip carries a payload the client applies. htmx carries none: an attribute names a URL, the response is markup, the markup is swapped into a target. __fragment in the query is how the host answers that.
GET /?category=Garden&__fragment the page segment, filtered by the query it carried
GET /tool/3?__fragment=loans the parallel slot named loans, wherever it sits on the route
GET /?__fragment=nope 404, no slot named `nope` on this routeThe host renders the whole route either way, layouts included, waits for every deferred segment rather than streaming a fallback, then picks the segment out of the tree and writes it with no shell, no segment delimiters and no sidecar. Loaders never see the key.
A fragment ends with the same inert store seed a document carries. Put data-sf-native on an anchor htmx owns so the navigator leaves the click alone. fsr use <app> htmx vendors htmx, writes its map entry and prints the lines main.ts needs. Wiring the two libraries together is one line:
bindHtmx(htmx);That covers both directions. After htmx settles a swap it calls adopt() to read unread seeds and scan() to mount any island the fragment placed. After the navigator applies a payload it dispatches sf:navigate plus an sf:fill per deferred segment; on those the binding calls htmx.process. Leave the second direction out and a form reached by a client navigation posts natively and reloads the document.
Why a page loads no framework for its routes
A template with no state and no handlers of its own has nothing for the browser to change. The build marks it static in the report: no browser twin, not in the island registry, not compiled, no island marker in the markup. The islands inside it are mounted by the document's own scan.
That rule is what lets an application with no React have no React anywhere. Add a useState to a page and it stops being static, so the registry wants to mount it through React and the build stops:
`routes/page.tsx#default` mounts through `@snapfire/fsr-client/react`, but the import map does not name `@snapfire/fsr-client/react`, `react` or `react-dom/client`An island's adapter comes from its file extension. A source the lowerer reads is React's; a .vue file is Vue's. An extension a compiler plugin claims with no client adapter behind it is an error, as is an extension nothing claims at all. Before it writes the registry the build checks each adapter it would import, plus the specifiers that adapter imports, against the app's import map and the shell's.
What mixing costs
Measured from the files a page carrying both actually loads, gzip at level 9:
| raw | gzip | |
|---|---|---|
| React, with its adapter | 152.4K | 49.1K |
| Vue, with its adapter | 126.4K | 49.3K |
| the fsr client | 61.2K | 17.9K |
About 98K compressed for the two frameworks before a line of application code runs, on every visit. It is worth that for a migration done island by island or for two teams sharing a page without agreeing on a framework first. A custom element costs none of it, because it is the browser.
The lab
Build an application whose only island is a .vue file and read generated/islands.ts: one registration, one mounter import, no React. Read the report: every route module is static and the component is lowered with vue in the detail column. View the source before the scripts run and the component's markup is already there, data-v- stamps and all.
Now add a React island beside it and look again: two registrations, two mounters, both markers carrying the server's markup, each in its own framework's spelling. Then put a v-model on an input in the Vue component and build: the report marks it foreign with the line and the marker comes back empty, while everything else on the page is unchanged.
Ask for a fragment with curl and read the last line of it. The store seed is there, which is how a count in an untouched layout moves after an htmx swap.
Put a template under elements/ and place its tag with one prop missing: the typecheck names the placement, not the template. Give the template a prop that is an array and fetch the page: the list is inside the shadow root and no attribute of that name is on the host.
Then fsr use <app> tera --example and build: the report lists the new page as template and tsconfig.build.json never names it. Put a page.tsx beside that page.tera and build again: refused, naming both files.