The application · for app developers · Markdown

104. Islands in another framework

The question this chapter answers: how does a Vue component end up on a page the server rendered, what does the build do with a .vue file and what does an application that has no React in it look like?

Templates and islands

The pages, layouts and boundaries under routes/ are TSX the build lowers and the server renders, which chapter 003 covers. None of it is React: the lowerer reads a template's JSX and the server writes the markup. React is needed only when a component needs the browser for useState, a handler or a store read, because then the browser must run it. The build keeps a compiled twin of it for that.

An island written in Vue works the same way with a different runtime. The template places it, the server writes its marker and its props and the browser mounts it with Vue. The recipes example, recipes_vue_ts, works this way: every route is a template and every interactive piece is a .vue file under src/ui/. There is no React in the import map, the vendor tree, the bundle or the type declarations.

Placing a .vue component

A template imports the component as a file and places it inside Island:

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

export default function BoxLayout({ children, planned }: { children: Children; planned: number }) {
  return (
    <header className="masthead">
      <nav>
        <Link href="/">Recipes</Link>
      </nav>
      <Island when="load">
        <Tonight count={planned} />
      </Island>
      {children}
    </header>
  );
}

The build reads the import 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 the way it lowers a TSX template, to the same render tree and places it as an island with its props lowered like any other placement. The server writes the component's markup inside the <sf-i>, spelled the way Vue's own server renderer spells it, with the props script beside it. The browser hydrates over that markup rather than mounting fresh. Place the same component outside Island and the build refuses, naming the tag: a component Vue mounts can only be an island, since Vue's root is the island's.

A component the build cannot read stays foreign: the server writes the <sf-i> empty with its props and Vue mounts it fresh. The report says which and why, with the line:

text
rendered  src/ui/Tonight.vue#default         foreign     src/ui/Tonight.vue:10:1
foreign   src/ui/Tonight.vue:10:1            a class in `<script setup>`
          1 component mounts in the browser for it, written empty by the server
            src/ui/Tonight.vue#default

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 from @snapfire/fsr/client/vue, a const bound to an expression the build reads and functions, which an @event can name as its handler. Lifecycle and watch calls are the browser's and are passed over. In the template: interpolation, v-if, v-else-if and v-else, v-for over a list with an item and an index, a bound attribute, :class as a string, an array or an object, :style, v-show, v-html, v-text and a plain <slot />. v-model on an input, a checkbox, a radio, a textarea, a select or a .vue child writes the value Vue's server renderer writes and the browser keeps them in step; a child reads it with defineModel. v-bind of a whole object, named and scoped slots between .vue files and provide with inject lower too. A component written with the Options API lowers too: props, data, computed, methods and a created that calls this.$emit. A child that calls emit in <script setup> (or this.$emit in created) runs the parent's @event handler while it sets up, so state the handler writes shows in the parent's markup after the child, as it does under Vue's server renderer. <Transition>, <TransitionGroup> and <KeepAlive> render what Vue writes for them. <Teleport to="body"> writes its content at the end of the body, where Vue hydrates it. <Teleport to="#notices"> writes it inside the <Place name="notices"> a shell, layout or page declares. When the component's own template has an element whose id is notices, it writes it there instead. Outside that is residue: a component placed inside the template that is not a .vue file the script imports, v-slot anywhere but on a .vue child or a <template> directly inside one, a <Teleport> to a target that is neither body nor # and an id, a <Teleport defer> (Vue never updates a deferred Teleport it has hydrated) and a created that does anything but call this.$emit. The residue names its line in the .vue file.

Vue's ref and computed are signals and the build lowers them as state and as a derived value; chapter 102 maps the rest of a signals vocabulary onto the store.

A .vue child the script imports renders inline as part of the island, the way Vue renders it: <Tile :name="item.name" :stock="item.stock"> passes props by their camelCase name and its children fill the child's <slot />. An attribute the child does not declare as a prop moves onto the child's root element, as Vue moves it, unless the child sets inheritAttrs: false.

A .vue component can be placed in server mode too, <Island mode="server"><ServerVue /></Island>; the host steps its events the way chapter 102 describes for a React one. The build lowers each @event on an element into a handler: a setup function by name or called, an arrow or a statement over $event. A handler holds consts, if and else, writes to a ref or a useStore binding, n++, n.value += by or count.value = 10, calls to other setup functions and calls to actions. A write is seen by what the handler reads after it, as in Vue. .prevent and .stop are allowed; any other modifier, emit and anything else stays in the browser for a browser island and stops the build for a server one, naming the line:

text
`src/ui/ServerVue.vue#default` cannot be an island in server mode: routes/page.tsx#default places it there, but a handler did not lower: 13:25: `.enter` on `@keyup`, a modifier the build does not lower

Integers need a conversion. An integer a contract types as bigint reaches the server as an integer and a Vue component that multiplies it by a literal fails the render, the way chapter 100 says a loader must convert one before arithmetic. The recipe page passes serves={Number(recipe.serves)} for that reason: the browser handles either type, but the server render fails without the conversion.

Island, island and Slot come from @snapfire/fsr/template here rather than from @snapfire/fsr/client/react. They are the same placements and the build reads either import. Link comes from @snapfire/fsr/components, which the build points at the framework of each file that imports it. The template module is the portable form: a file written against it is valid whatever the application serves, typed by the dialect's own declarations when the import map has no React and through React's when it has, where Children reads as ReactNode and the placements as the React module's. An island written on the template module loads its placements from the client's template.js, which the react direction maps beside the React module. The React module is the React-only form, with useStore, useLocale and useHoisted that the template module never promises. A file importing it needs React's declarations to type-check. So a layout like this one writes Children and keeps working if the application gains React later; a file that wants React's hooks says so by its import.

The component itself

Tonight.vue is an ordinary single-file component: a <script setup lang="ts">, a <template> and a <style scoped>. Nothing in it is written for the server: the build reads it as it stands.

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>
  <button class="tonight-count" @click="open = !open">{{ held.value }} for tonight</button>
</template>

<style scoped>
.tonight-count { border-radius: 999px; }
</style>

useStore from @snapfire/fsr/client/vue is the store as a Vue ref: the same keyed store the layout's loader seeds and a React island reads with its own useStore, so a Vue masthead and a React panel would show the same number. On the server it reads the store the loader seeded, so the count is in the markup. The plan control in the example calls actions.recipe.$id.plan(...) from the generated client and writes the store optimistically, the same way the storefront's React button does.

The server writes what Vue's server renderer would write for the same component and props, anchors included: <!----> where the panel's v-if rendered nothing, <!--[--> and <!--]--> around a list or a fragment and the data-v- stamp of a scoped style on every element. That is what Vue's client walks when it hydrates. It is checked byte for byte against @vue/server-renderer in the lowerer's own tests:

html
<sf-i id="sf-i0" data-sf-module="src/ui/Tonight.vue#default"><div class="tonight" data-v-8ee200ee><button class="tonight-count" aria-label="tonight" data-v-8ee200ee>0 for tonight</button><!----></div><template data-sf-children>Kept in the session cookie. <a href="/tonight">See them</a>.</template></sf-i>

The <template data-sf-children> after the markup is the island's children. The panel is closed, so the template placed no <slot /> this render and the server had nowhere to write them; an inert template after the markup carries them instead, which the parser never shows and the scan never reaches. 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. When the template does place the slot, the children sit inside it in an <sf-s data-sf-children> region, which Vue hydrates as an element it rendered.

What the build does with it

Neither fsr nor snapfirec compiles Vue. fsr build looks for snapfirec-vue on PATH and asks it to describe every .vue file under the source directories in one batch before any route is lowered; snapfirec finds the same binary again when it bundles and hands it the same files to compile. The plugin carries Vue's own compiler, run in QuickJS, so no Node is involved. Install it with cargo install snapfire_vue. Without it nothing reads the file, so the component is neither lowered nor compiled and the bundle stops on the module the registry imports. A plugins row in the report says which binary was missing and the report is printed even though the build failed, since the fix is only clear from both errors together:

text
❌ `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.vue', which resolves to nothing
plugins   `snapfirec-vue` is not on PATH, so no `.vue` component was read or compiled; `cargo install snapfire_vue` puts it there, and until it does the bundle stops on the module the registry imports

That is not the foreign fallback. A component goes foreign when the plugin read it and the build could not lower what it found, which leaves a module to mount; a missing plugin leaves no module at all.

What comes back is a module and a stylesheet: dist/src/ui/Tonight.vue, the compiled module under the component's own name, whose imports the build resolves like any other module's, plus dist/src/ui/Tonight.vue.css, the component's <style scoped> with its data-v- attribute. The template's import Tonight from "@src/ui/Tonight.vue" stays as written and the host serves the module as JavaScript. The island registry the build writes registers the module with the Vue mounter:

ts
import { registerDeferredIsland } from "@snapfire/fsr/client";

const vue = () => import("@snapfire/fsr/client/vue").then((m) => ({ mount: m.vueMounter, patch: m.vuePatcher, unmount: m.vueUnmounter }));

export function registerIslands(): void {
  registerDeferredIsland("src/ui/Tonight.vue#default", { loader: () => import("../src/ui/Tonight.vue").then((m) => m.default), adapter: vue });
}

The Vue adapter is imported when a scan first finds one of these islands, which is what keeps a framework off a page that holds none of its islands.

The stylesheets reach the head before the first island mounts. fsr build names, under each route's layouts and page, the sheet of every Vue or Svelte component the module reaches through its imports. The host links on a document the ones its route names, after the application's own styles/*.css, so a component's rules come later in the cascade than the document's. A navigation adds the next route's sheets before it swaps the page and removes the ones it no longer names. A sheet no route names, a component placed only by its module's name, is linked on every document; the compiler lists every component sheet in dist/.snapfire-build.json under styles.

Unchanged components are not compiled twice. The plugin's output is cached on the worker's own version, the source, the options and every sibling file the component read, in memory across a watch and on disk in the output directory. fsr build --verbose says how much of it the cache answered:

text
   Plugin:   snapfirec-vue 0.2.1 (@vue/compiler-sfc 3.5.44)
   Plugin cache: 3 of 3 answered

A block may point at a sibling, <style src="./Tonight.css" scoped>. The plugin asks for the file, the build reads it and sends it. From then on a save to Tonight.css recompiles Tonight.vue.

Why the page loads no React

A template with no state and no handlers has nothing for the browser to change. The build marks it static in the --verbose report: it has no browser twin, it is not in the island registry, it is not compiled and the server writes its markup with no island marker around it. The islands inside it are mounted by the document's own scan. That is what lets the recipes application's bundle be src/**/* and two generated files, with the client, its store, its Vue adapter and vue as its only externals:

text
rendered  routes/layout.tsx#default          lowered     static
          routes/page.tsx#default            lowered     static
          routes/recipe/[id]/page.tsx#default lowered     static

The rule is the same in a React application. The storefront's catalog page and its layout are static too: the header the layout renders inline has state, so the renderer places it as an island of its own. A template hydrates only when it has state or handlers of its own. A page or a layout holding them is split at build time so that it stays static.

Navigation still keeps a static layout's DOM. A segment carries a digest of its own markup; a layout whose digest did not move is kept whatever changed below it. When it did move, after an action re-rendered it with a new count, the navigator replaces the layout's markup but keeps every island inside it that the new markup also places, by region key, moving the mounted element into the new markup and handing it its new props. The recipes masthead panel stays open through planning a recipe for that reason.

The lab

Run fsr build --verbose app in the recipes example and read app/generated/islands.ts: three registrations, all .vue, one adapter loader. Read the report: every route module is static and the three components are lowered with vue in the detail column. View the source of a recipe page before the scripts run: the plan control is a <sf-i> holding the button Vue will hydrate, followed by its props. The three component stylesheets are linked in the head after box.css.

Take snapfirec-vue off PATH and build again. The bundle stops on the modules the registry imports and the report still prints, with a plugins row naming the binary and the install command. The three components are missing from rendered altogether rather than listed foreign, since nothing read them.

Give Tonight.vue a class in its <script setup>. fsr build --verbose keeps the page static, marks the component foreign with the line and the bundle compiles it as before: the panel mounts fresh and everything else on the page is as it was.

Put an unclosed tag in Scaler.vue and build. The plugin's diagnostic names the file and the line, the other two components compile and the build stops.

Give routes/page.tsx a useState from react and a button that sets it. Build: the state and the button move into routes/page.island0.tsx, an island beside the page. The page stays static. The island is React's, since its state comes from react. This application's import map has no React, so the build stops:

text
`routes/page.island0.tsx#default` mounts through `@snapfire/fsr/client/react`, but the import map does not name `react` or `react-dom/client`; `fsr use <app dir> react` writes it

Remove the useState again and the application loads no React.

Open the masthead panel in a browser, then click "Cook this tonight" on a recipe. The count moves, the button changes and the panel is still open: the layout re-rendered around its island and the island kept its state.