Tutorial 7 of 21 · for anyone who would rather write Vue than React

Write your islands in Vue

You'll start a fresh app, point it at Vue and write a single-file component the server renders and the browser hydrates. There is no React anywhere in it: not in the import map, not in the vendor tree, not in the bundle, not in the type declarations. The pages are the same lowered templates they always were. So is the Vue component.

Before you start

Straight from crates.io. No Node, no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
cargo install snapfire_vue
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

Install the Vue plugin

snapfirec doesn't compile Vue itself. When it sees a .vue file it goes looking for snapfirec-vue on your PATH and hands it every .vue file in the project in one batch. The plugin carries Vue's own compiler and runs it in QuickJS, so there's still no Node in the build. That's the third cargo install in the box above:

$ snapfirec-vue --version
snapfirec-vue 0.1.0 (@vue/compiler-sfc 3.5.13)

That @vue/compiler-sfc version is the plugin's, not yours. It compiles your components whatever version of Vue you end up vendoring for the browser.

Scaffold, then point it at Vue

A plain fsr new writes no framework at all, which is where you want to start.

$ fsr new box
wrote     box/.gitignore
wrote     box/config/app.toml
wrote     box/app/importmap.json
wrote     box/app/src/main.ts
wrote     box/app/routes/layout.tsx
wrote     box/app/routes/page.loader.ts
wrote     box/app/routes/page.tsx
wrote     box/app/routes/not-found.tsx
wrote     box/app/routes/error.tsx
wrote     box/app/styles/app.css
types     @snapfire/fsr-authoring      fsr 0.13.0
types     @snapfire/fsr-client         fsr 0.13.0
next      fsr use box/app react      # only if the application wants React; also vue, elements, htmx or tera
next      fsr dev box/app

Take the direction it offers, with vue instead of react:

$ fsr use box/app vue
mapped    @snapfire/fsr-client/vue     /static/js/fsr/vue.js
added     vue                          vue/vue.bundle.mjs  117786 bytes
types     @snapfire/fsr-authoring      fsr 0.13.0
types     @snapfire/fsr-client         fsr 0.13.0
types     vue                          vue 3.5.43

Three jobs in one command: the client's Vue adapter is mapped, Vue itself is vendored as a plain file and the declarations are fetched. The import map is now the whole client side of this application:

json
{
  "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/vue": "/static/js/fsr/vue.js",
    "vue": "/static/js/vendor/vue/vue.bundle.mjs"
  }
}

Five entries, no React anywhere. fsr use box/app vue --example would write a starter component too, which this tutorial writes by hand instead.

One thing that is not there yet: the *.vue module declaration your editor wants. It's written once there's a .vue import in the project to write it for, so run fsr types box/app again after the next section rather than now.

The layout imports from the template dialect

The scaffolded layout already takes Link from @snapfire/fsr-authoring/template and types its children as Children, because a bare application has no React to type them against. That's the dialect this whole tutorial stays in:

tsx
import { Island, Link, type Children } from "@snapfire/fsr-authoring/template";

import Tonight from "@src/ui/Tonight.vue";

export default function BoxLayout({ children, planned }: { children: Children; planned: number }) {
  return (
    <div className="shell">
      <header className="bar">
        <Link href="/" className="brand">
          box
        </Link>
        <Island when="load">
          <Tonight count={planned} />
        </Island>
      </header>
      <main className="content">{children}</main>
    </div>
  );
}

Children is what you write here where a React layout writes ReactNode. Nothing else about the file changes.

The build reads import Tonight from "@src/ui/Tonight.vue" and asks snapfirec-vue to describe the file: the template as Vue's own parser reads it, the <script setup> block and how the template reads each name the script binds. It lowers the component from that, to the same render tree a TSX template lowers to, so the server writes the component's markup. It still has to go inside Island: Vue's root is the island's, so a component Vue mounts can only be an island. Put it anywhere else and the build fails, naming the tag.

planned is a prop like any other, so something has to load it. The layout's loader does that and seeds the store key in the same file:

ts
import type { Ctx } from "@snapfire/fsr";

export async function load(_ctx: Ctx) {
  return { planned: 2 };
}

export const store = ({ data }: { data: { planned: number } }) => ({ "box/planned": data.planned });

The component

app/src/ui/Tonight.vue is an ordinary single-file component:

vue
<script setup lang="ts">
import { ref } from "vue";
import { useStore } from "@snapfire/fsr-client/vue";

import { plannedCount } from "@src/store";

const props = defineProps<{ count: number }>();
const held = useStore(plannedCount, props.count);
const open = ref(false);
</script>

<template>
  <div class="tonight">
    <button class="tonight-count" @click="open = !open">{{ held.value }} for tonight</button>
    <p v-if="open" class="tonight-note">Kept in the session. This is Vue state in the root layout, so it stays open as you move between recipes.</p>
  </div>
</template>

<style scoped>
.tonight-count {
  border: 1px solid #1b1d21;
  border-radius: 999px;
  background: #fff;
  padding: 6px 14px;
  font: inherit;
  cursor: pointer;
}
</style>

Look at the template for a second, because the two reactive values behave differently. open is a real ref, so Vue unwraps it and you write open. held is not: useStore gives you back a reactive object with a .value on it, so you write held.value. Write {{ held }} and you'll render the object instead of the count, which is an easy ten minutes to lose.

@snapfire/fsr-client/vue is the same keyed store a React island reads through its own hook. The key is declared once in plain TypeScript; the adapter is the only part that differs by framework:

ts
import { key } from "@snapfire/fsr-client/store";

export const plannedCount = key<number>("box/planned");

The count arrives twice, which looks redundant until you see what each one is for. The prop is the server's value for this render: it is in the markup, it is in the props script beside the marker and the component has it before any store exists, so the first paint is right with no flash. The store key is what makes it shared and reactive afterwards. useStore(plannedCount, props.count) reads the key if something already seeded it and falls back to the prop if not, then follows the key from then on. That is how a second island on the page, in any framework, can move this number without either component knowing about the other.

Drop the prop and the button renders whatever the store's default is until the seed is read. Drop the key and the count is only ever what this render said it was.

Fetch the declarations

Now that there's a .vue file to declare, run it:

$ fsr types box/app
types     @snapfire/fsr-authoring      fsr 0.13.0
types     @snapfire/fsr-client         fsr 0.13.0
kept      vue
wrote     types/foreign.d.ts
wrote     tsconfig.json

vue says kept because fsr use already fetched it. Declarations already sitting in app/types/ are left alone unless you pass --refresh; the two fsr packages come out of the binary and are rewritten every time. types/foreign.d.ts is the new part: the *.vue module declaration that needed a .vue file to exist first.

types/foreign.d.ts is the shim that makes import Tonight from "@src/ui/Tonight.vue" typecheck. It's a loose declaration, so a .vue component's props aren't checked at the placement the way a lowered component's are:

ts
declare module "*.vue" {
  const component: (props: { [prop: string]: unknown }) => any;
  export default component;
}

Build it

$ fsr build box/app
   Plugin cache: 0 of 1 answered
   Plugin:   snapfirec-vue 0.1.0 (@vue/compiler-sfc 3.5.13)
   Compiling VUE: "src/ui/Tonight.vue"
   Compiling CSS: "src/ui/Tonight.vue"
   Externals: '@snapfire/fsr-client', '@snapfire/fsr-client/store', '@snapfire/fsr-client/vue', 'vue'
rendered  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
          src/ui/Tonight.vue#default         lowered     vue
types     vue                    types/vue  vue 3.5.43
plan      generated/plan.sexp    2.9 KB written
typecheck tsc 7.0.2 from cache, clean

Two files came out of the component. dist/src/ui/Tonight.js is the module; dist/src/ui/Tonight.vue.css is the <style scoped> block with its data-v- attribute. The layout's import got rewritten to the .js.

Read the last rendered row: the .vue file is lowered, with vue where a TSX template says static. The build read the component through snapfirec-vue and lowered it into the plan, so the host renders it. foreign in that column would mean it couldn't, which is a section further down.

Every route module says static. A template with no state and no handlers of its own has nothing for the browser to change, so it isn't compiled, isn't registered and isn't bundled. That's why the app loads no framework for its pages.

Drop a useState into routes/page.tsx and you'll see what that rule is protecting. The page stops being static, so the registry wants to mount it. A .tsx module mounts through React, which this app doesn't have:

$ fsr build box/app
`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`; `fsr use <app dir> react` writes it

The build checks every adapter its registry would import against the import map before it writes anything, so you get the module, the adapter and the missing specifiers rather than a bare specifier error from the bundler. It names the command that would fix it too, which is the right move here only if you actually wanted React; take the useState back out instead.

Here's the registry the build wrote:

ts
import { registerIsland } from "@snapfire/fsr-client";
import { vueMounter, vuePatcher, vueUnmounter } from "@snapfire/fsr-client/vue";

export function registerIslands(): void {
  registerIsland("src/ui/Tonight.vue#default", { loader: () => import("../src/ui/Tonight.vue").then((m) => m.default), mount: vueMounter, patch: vuePatcher, unmount: vueUnmounter });
}

mount is the one you'd expect. patch runs when the island is already mounted and the server sends new props for it: after an action, after a revalidation or after a navigation that keeps the region. vueMounter mounts the component inside a one-element wrapper that holds its props reactively; vuePatcher assigns the new props onto that object, so Vue re-renders in place instead of tearing the island down and losing whatever state it had.

unmount is the other end. When a navigation takes away the markup an island was mounted in, the client hands the island to its entry's unmounter, nested islands first, so Vue runs the component's cleanup and the app is torn down. Leave a root running in a detached element and its effects never clean up, so whatever they hold stays held.

That import is static. @snapfire/fsr-client/vue imports vue itself, so the registry pulls the Vue runtime in the moment src/main.ts runs. It happens on every page of the application, whether or not the page you are on places an island. What the loader defers is the component module, Tonight.js, which is a couple of kilobytes.

What keeps a framework out of the graph completely is the build never writing its line. This application has no React island anywhere, so nothing imports reactMounter and React is not reachable from the entry module at all. An island's adapter comes from its file extension: .vue goes to Vue's, anything the lowerer reads goes to React's. An extension no framework claims is a build error rather than a guess.

What the server wrote

$ fsr serve box/app
$ curl -s localhost:3000/
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/js/app/src/ui/Tonight.vue.css">
...
<header class="bar"><a href="/" class="brand" data-sf-link="exact" aria-current="page">box</a>
  <sf-s data-sf-island data-sf-region="routes/layout.tsx#default|i0" data-sf-when="load">
    <sf-i id="sf-i0" data-sf-module="src/ui/Tonight.vue#default"><div class="tonight" data-v-f9a5d1dc><button class="tonight-count" data-v-f9a5d1dc>2 for tonight</button><!----></div><template data-sf-children>Kept in the session. <a href="/tonight">See them</a>.</template></sf-i>
    <script type="application/json" data-sf-props="sf-i0">{"count":{"$":"f","v":2.0},"$k":"routes/layout.tsx#default|i0"}</script>
  </sf-s>
</header>
...
<script type="application/json" data-sf-store>{"box/planned":{"$":"f","v":2.0}}</script>

The component is in the markup. The server wrote what Vue's own server renderer would write for the same component and props, so Vue hydrates over it rather than mounting fresh, exactly as a React island does. Three details are Vue's own spelling and worth recognising:

  • data-v-f9a5d1dc is the scoped style's stamp, on every element the component rendered. It matches the data-v- attribute in Tonight.vue.css.
  • <!----> is the anchor where the v-if rendered nothing. Vue leaves a comment node so it knows where to put the paragraph when open flips.
  • 2 for tonight is the count, in the markup before any script runs. useStore read the key the layout's loader seeded, on the server.

That last one is the reason to care. The number is right in the first paint, with no flash and no request.

<template data-sf-children> after the markup is the island's children. The panel is closed, so this render placed no <slot /> and there was nowhere to write them; an inert <template> carries them instead, which the parser never shows. The mounter reads it before Vue hydrates and takes it out of the document, so the slot has its content the moment the panel opens.

The last line is the store seed, written once at the end of the document from the loader's store export. That is where box/planned comes from: the client reads the seed during boot() and useStore finds the key already set.

data-sf-region is the placement's key. It's how the navigator works out that the island in the new markup is the same one that's already mounted, so it can hand it fresh props instead of remounting it. That's the key patch uses.

The component's stylesheet is linked after the app's own, so its rules land later in the cascade. The compiler lists the file under styles in dist/.snapfire-build.json and the host reads that at boot.

Children

Markup the layout writes inside a Vue island reaches the component as its default slot. Put a <slot /> where it belongs:

vue
<template>
  <div class="tonight">
    <button class="tonight-count" @click="open = !open">{{ held.value }} for tonight</button>
    <p v-if="open" class="tonight-note"><slot /></p>
  </div>
</template>

and write the children in the layout:

tsx
<Island when="load">
  <Tonight count={planned}>
    Kept in the session. <a href="/tonight">See them</a>.
  </Tonight>
</Island>

The children are the layout's markup, so the server renders them wherever this render put the <slot />. Open the panel on the server and they sit inside it, in an <sf-s data-sf-children> region Vue hydrates as an element it rendered. With it closed, as above, there is no slot to write them into; they ride along in the inert <template data-sf-children> and the mounter puts them in place before Vue hydrates.

Either way the browser doesn't render them again; it keeps what the server wrote, including inside a v-if that opens later. A React island gets the same region as its children.

When the build can't read it

Not every component lowers. Put a v-model on an input in Tonight.vue and build again:

$ fsr build box/app
rendered  src/ui/Tonight.vue#default         foreign     src/ui/Tonight.vue:16:12
foreign   src/ui/Tonight.vue:16:12           `v-model`
          bind `:value` for the markup and handle the input event in the browser; two-way binding is not lowered
          1 component mounts in the browser for it, written empty by the server

foreign rather than lowered, with the file, the line and the column. The build doesn't stop and the page doesn't break: the server writes the marker empty with its props and Vue mounts the component fresh, which is what every .vue file did before the build learned to read them. You lose the first paint for that one component and nothing else.

html
<sf-i id="sf-i0" data-sf-module="src/ui/Tonight.vue#default"><sf-s data-sf-children>Kept in the session. <a href="/tonight">See them</a>.</sf-s></sf-i>

The children are still there, because they're the layout's markup rather than the component's.

What lowers is the subset a server can evaluate. In <script setup>: defineProps with withDefaults around it, ref, shallowRef, computed of an arrow, reactive, useStore, a const bound to an expression the build reads and functions, which are the browser's. Lifecycle and watch calls are passed over, since the server never runs one. In the template: interpolation, v-if / v-else-if / v-else, v-for with an item and an index, a bound attribute, :class as a string, array or object, :style, v-show, v-html, v-text and a plain <slot />.

Residue is everything else: v-model, v-bind of a whole object, a named or scoped slot, a component inside the template, v-slot, inject, a <script> without setup. Take the v-model back out and the component goes back to lowered.

One thing to watch that isn't a directive. An integer a contract types as bigint arrives as an integer, so a component that multiplies it by a literal fails the render on the server where the browser never noticed. Convert it at the placement, serves={Number(recipe.serves)}, the same way a loader has to.

The dev loop works

fsr dev watches .vue files like anything else. Save one and you get:

dev: changed app/src/ui/Tonight.vue
   Compiling VUE: "src/ui/Tonight.vue"
   Compiling CSS: "src/ui/Tonight.vue"
typecheck tsc 7.0.2 from cache, clean

and the browser picks up the new module. It's a refresh rather than Vue's own HMR, so the component starts from its props again.

When the plugin is missing

Take snapfirec-vue off your PATH and build:

$ fsr build box/app
❌ `snapfirec-vue` is not on PATH; `cargo install snapfire_vue` puts it there
   needed by "src/ui/Tonight.vue"
❌ "dist/generated/islands.js" imports '../src/ui/Tonight.js', which resolves to nothing
Error: Build failed. See the errors above.

You get the binary it went looking for, the command that installs it and every file that needed it. The second error is the first one's consequence: with nothing to compile the .vue file, the module the registry imports was never written. The build stops here, so there's no report to read.

Recap

An island placement carries a module id. The registry maps that id to a mounter. Nothing in the plan, the payload or the renderer reads that id, which is why putting Vue where React was took no host configuration at all.

The component itself went further than the seam had to. The build read it through Vue's own parser and lowered it, so the server wrote its markup and Vue hydrated over it, the same deal a React island gets. A component holding something the build can't read falls back to mounting fresh and says so in the report.

Next up: 097. Interactivity with no framework at all.

Built with SnapFire FSR. Pure Rust runtime, zero Node.js on the server.

Proudly Created by Excerion Sun LLC