# 204. Vue, Web Components, htmx and Tera

**For:** app developers. Vue islands, custom elements, htmx regions and running more than one of them at once.

## How islands are mounted

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:

```ts
registerDeferredIsland("src/ui/Watch.tsx#default", { loader: () => import("./ui/Watch.js").then((m) => m.default), adapter: react });
registerDeferredIsland("src/ui/Holdings.vue#default", { loader: () => import("./ui/Holdings.vue").then((m) => m.default), adapter: vue });
```

`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.

Each `adapter` is a function that imports its framework's mount, patch and unmount. The browser calls it the first time a page holds one of that framework's islands, so a page with no Vue island loads no Vue. The `loader` defers the component module the same way.

## 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. Install it with `cargo install snapfire_vue`. 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`:

```tsx
import { Island, type Children } from "@snapfire/fsr/template";
import Tonight from "@src/ui/Tonight.vue";

<Island when="load">
  <Tonight count={planned} />
</Island>;
```

`@snapfire/fsr/template` carries the same placements typed without React, for an application with no React in it. `Children` is what it writes where a React layout writes `ReactNode`.

These details apply to Vue islands:

- The build asks `snapfirec-vue` to 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. If you place the component outside `Island` the build fails and names the tag, because Vue's root is the island's root.
- 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 `.vue` file got before the build read them. The report names the file, the line and the construct. Residue is `v-model`, `v-bind` of a whole object, a named or scoped slot, a component inside the template, `v-slot`, `inject` and a `<script>` without `setup`.
- 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 sit 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 its `children`.
- `useStore` from `@snapfire/fsr/client/vue` returns a reactive holder with a `.value`, not a `ref`, so a template reads `held.value` where a plain `ref` would 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. There is no hook and no ref, only a read and a callback. The read comes first because a subscription only carries what changes after it. You also need a `disconnectedCallback` that undoes the subscription and the listeners, 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:

```tsx
// 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 only way to give an element structured data.

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:

```tsx
<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.

## Tera pages

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 path
```

The 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}`:

```tera
<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     static
```

A 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.

A directory holds one or the other. A directory holding both a `page.tsx` and a `page.tera` is refused with an error naming the two files. `fsr use <app> tera` writes an example page beside its loader. It needs an `fsr` built with the `tera` feature. The published binary is built with it.

## 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 and 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 route
```

The 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:

```ts
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.

## Static templates

A template with no state and no handlers of its own has nothing for the browser to change. `fsr build --verbose` marks it `static` in the report. It has no browser twin, is not in the island registry, is not compiled and gets no island marker in the markup. The islands inside it are mounted by the document's own scan.

This is how an application with no React avoids loading 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:

```text
`routes/page.tsx#default` mounts through `@snapfire/fsr/client/react`, but the import map does not name `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 |

That is 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 the browser already provides it.

## Try it

Build an application whose only island is a `.vue` file and read `generated/islands.ts`. There is one registration, one Vue adapter loader and no React. Read the report `fsr build --verbose` prints: 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, including the `data-v-` stamps.

Now add a React island beside it and look again. There are two registrations and two adapter loaders. Both markers carry 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 with `--verbose`: 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. The build is refused and names both files.
