# ferrovue
[](https://crates.io/crates/ferrovue)
[](https://docs.rs/ferrovue)
[](https://www.npmjs.com/package/ferrovue)
[](https://github.com/Kinsmir/ferrovue/actions/workflows/ci.yml)
[](#licence)
Vue single-file components compiled to Rust render functions. With them, a Rust server renders a
component's HTML with no JavaScript at run time, and the browser hydrates the result with the same
`.vue` file.
```text
Button.vue ──(ferrovue compiler, Node)──▶ button.rs ──(your Rust server)──▶ HTML ──▶ Vue hydrates it
```
ferrovue doesn't interpret templates. It takes what `@vue/compiler-sfc` compiles a template to for
server rendering (straight-line `_push` calls plus a few `@vue/server-renderer` helpers) and
translates that small, closed vocabulary into Rust that writes **the same bytes**. Vue still decides
element structure, attribute order, whitespace and fragment markers. If a single byte differed, the
browser would report a hydration mismatch, so anything outside that vocabulary is a compile error
that names the component and the construct. ferrovue never guesses.
The project has two halves:
| | What it is | Published as |
|---|---|---|
| `packages/ferrovue` | The compiler and the `ferrovue` command (TypeScript, runs on Node) | npm: [`ferrovue`](https://www.npmjs.com/package/ferrovue) |
| `crates/ferrovue` | The runtime the generated Rust calls: escaping, slots, islands, `<RouterLink>` matching. The router, vue-i18n and the escaping and number primitives they share are crates of their own (`crates/ferrovue-router`, `ferrovue-i18n`, `ferrovue-core`), which `ferrovue` re-exports | crates.io: [`ferrovue`](https://crates.io/crates/ferrovue) ([API docs](https://docs.rs/ferrovue)) |
## Quick start
### 1. Install both halves
```sh
pnpm add -D ferrovue # or npm / yarn
cargo add ferrovue serde --features serde/derive
cargo add --dev serde_json # the generated conformance helper uses it under cfg(test)
```
ferrovue needs Rust **1.89** or later (CI tests that and the latest stable). Generated code uses
let-chains, so the crate that includes it needs **edition 2024**.
### 2. Configure
`ferrovue.config.json` at your project root:
```json
{
"$schema": "https://cdn.jsdelivr.net/npm/ferrovue@0.7.0/schema.json",
"components": "client/components",
"out": "src/generated"
}
```
`$schema` points an editor at the configuration's JSON Schema, published with each version of the
npm package, so VS Code and other JSON editors complete the keys, describe them on hover and flag a
misspelt one. Name the version of `ferrovue` you use; `ferrovue init` writes it. ferrovue does not
read `$schema`. Offline, the copy in the installed package works as well:
`"$schema": "./node_modules/ferrovue/schema.json"`.
| Key | Required | Meaning |
|---|---|---|
| `components` | yes | Directory of `.vue` files to compile |
| `out` | yes | Directory the Rust modules are written to. **Every module ferrovue wrote there is replaced**, and one no component produces any more is removed; a `.rs` file without ferrovue's `// @generated` header is left alone, and `--check` reports it |
| `routes` | no | JSON file listing the app's routes: each a vue-router path, or `{ "path", "name", "children" }`. Or `{ "pages": "client/pages" }`: a folder of pages, whose file names give the routes as vue-router's file-based routing reads them (`index.vue`, `[id].vue`, `[[id]].vue`, `[...path].vue`, `(group)` folders, a `name.vue` beside `name/` as its layout), each page compiled as a component. Needed for `<RouterLink>`, `<RouterView>` and `useRoute()` |
| `router` | no | Instead of `routes`: `{ routes, base?, linkActiveClass?, linkExactActiveClass? }`, matching `createWebHistory(base)` and `createRouter`'s options |
| `stores` | no | Directory of Pinia option stores whose state components may read |
| `trustedHtml` | no | Rust type of a `TrustedHtml` prop (needed for `v-html`): `ferrovue::BasicHtml` (text with a few formatting tags, built in), `ferrovue::InlineHtml` (inline formatting tags only, built in, which makes every `TrustedHtml` prop inline HTML), `ferrovue::Sanitised` (HTML cleaned by ammonia, the `ammonia` feature), or a type of your own such as `crate::html::CleanHtml` |
| `helpers` | no | `{ module, functions }`: functions a template may call, each mapped to a Rust twin |
| `twins` | no | Components ferrovue does not compile, each rendered by a Rust function of yours: `{ "VBtn": { "rust": "crate::ui::v_btn", "props": { "label": "string" }, "slots": ["default"] } }`. See [Escape hatches](#escape-hatches) |
| `i18n` | no | vue-i18n: `{ messages, locale?, fallbackLocale? }`, the directory of locale files (`en.json`, `nl.json`), the default locale and the fallbacks |
| `clientDirectives` | no | Custom directives with no server output (no `getSSRProps`), by name without `v-`: `["focus"]` |
| `scopeId` | no | How a `<style scoped>` id is hashed, as `@vitejs/plugin-vue` hashes it: `"filepath-source"` (the default, the plugin's in a production build) or `"filepath"`. See [Scoped styles](#scoped-styles) |
| `viteRoot` | no | Vite's root, relative to this file's directory, from which a component's path is hashed (default `.`) |
| `isProduction` | no | Whether `@vitejs/plugin-vue` compiles for production, as Vite's `isProduction`: `true` (the default) for `vite build`, `false` for the dev server. It names the variables `v-bind()` in `<style>` sets. See [Scoped styles](#scoped-styles) |
| `cssModules` | no | `{ generateScopedName, hashPrefix?, context? }`, as Vite's `css.modules` names CSS module classes: needed for `<style module>`. `context` is the directory Vite runs in (default `viteRoot`). See [Scoped styles](#scoped-styles) |
| `builders` | no | `false` leaves out each props struct's and shared type's `new()` and setters, for an app that builds its props as struct literals (default `true`) |
### 3. Write a component
```vue
<!-- client/components/Greeting.vue -->
<script setup lang="ts">
defineProps<{ name: string; unread: number; note?: string }>();
</script>
<template>
<p class="greeting">Hello, {{ name }}!<b v-if="unread">{{ unread }} new</b><i v-if="note">{{ note }}</i></p>
</template>
```
### 4. Generate and render
```sh
pnpm ferrovue init # a starter ferrovue.config.json and components/Hello.vue
pnpm ferrovue # writes src/generated/{greeting.rs, mod.rs}
pnpm ferrovue --check # in CI: exits 1 if the committed modules are stale
pnpm ferrovue --check --diff # …and shows what differs, as `diff -u` does
pnpm ferrovue --check --format json # errors as JSON, for an editor or CI annotations
pnpm ferrovue --watch # regenerates when components or configs change
```
`-c, --config <path>` reads another configuration file. `--watch` regenerates on a change to what a
run reads (the configuration, the components, stores, locale messages, pages or routes file, and
the `.ts` files components import types from), not on any other file of the project. A Vite project
can use the plugin instead, which does the same in its dev server and before a build:
```ts
// vite.config.ts
import ferrovue from "ferrovue/vite";
export default { plugins: [ferrovue({ config: "config/ferrovue.json" })] };
```
`root` is the project root the configuration's paths are relative to (Vite's working directory by
default), and `config` the configuration file in it, as `--config` takes it (`ferrovue.config.json`
by default).
Every error carries a stable code, as `error[FV0602]: components/Card.vue:4:17: …`, documented in
the crate's [`error_codes`](https://docs.rs/ferrovue/latest/ferrovue/guide/error_codes/index.html)
guide. `--format json` writes the same errors as JSON with the exit status unchanged, and a VS Code
problem matcher for `tasks.json` puts them in the Problems panel: both are in the
[quick start](https://docs.rs/ferrovue/latest/ferrovue/guide/quick_start/index.html#editor-and-ci-diagnostics).
In the editor, the `ferrovue/volar` plugin shows them as you type (step 5).
```rust
#[rustfmt::skip]
mod generated;
use generated::greeting::{self, Props};
// `new` takes the required props; each optional one has a setter.
let props = Props::new("Ada", 3).note("welcome back");
// The markup alone:
let html: String = greeting::html(&props).into_string();
// …or as an island the client hydrates: wrapped in
// <div data-island="Greeting" data-props="{…}">…</div>
let island = greeting::island(&props).into_string();
// (`island()` exists for a component that renders from its props alone: the client rebuilds it from
// `data-props`. One that also takes slots, the route, stores, translations, teleports, provided
// values or the page head has `html()`, and the page's own app mounts it; see examples/fullstack.)
// …or straight into a buffer you already hold:
let mut page = String::from("<!doctype html><body>");
greeting::render(&mut page, &props);
```
With `routes` configured, the generated `route_table::router()` builds the router once. Resolve
each request's location with `router.at(path)` and pass the result to the components that take a
route.
The router and vue-i18n are features of the crate, `router` and `i18n`, both on by default. An
application with neither routes nor translations can leave them out, and build neither
`ferrovue-router` nor `ferrovue-i18n`: `ferrovue = { version = "0.7", default-features = false }`.
With the `maud` feature, `ferrovue::Html` implements `maud::Render`, so `(greeting::html(&props))` can go
straight into a `maud::html!` page.
For `v-html`, `ferrovue::BasicHtml` is built in: `BasicHtml::new(untrusted)` escapes everything but
`<b>`, `<i>`, `<em>`, `<strong>`, `<code>`, `<br>`, `<p>`, `<ul>`, `<ol>` and `<li>` written with no
attributes, and balances them; `BasicHtml::from_text(text)` turns plain text into paragraphs.
`ferrovue::InlineHtml` does the same with `<b>`, `<i>`, `<em>`, `<strong>`, `<code>` and `<br>`
only: it is what a prop typed `InlineHtml` from `ferrovue/types` is on the server, and the HTML
`v-html` may write inside a `<p>`.
With the `ammonia` feature, `ferrovue::Sanitised` is HTML cleaned by [ammonia](https://docs.rs/ammonia):
name it as `trustedHtml` and build a `v-html` prop with `Sanitised::new(untrusted)`, or
`Sanitised::with(&builder, untrusted)` for a policy of your own. The server renders the cleaned
string, and the island's `data-props` carries that same string to the client.
With the `axum` feature, `Html` is an `IntoResponse`, and with `actix-web` a `Responder`: a handler
responds with the component, as `text/html; charset=utf-8`. `into_html(props)` and
`into_island(props)` take the props by value, so a handler that builds them returns the page:
```rust
async fn hello(Path(name): Path<String>) -> impl IntoResponse {
greeting::into_html(greeting::Props::new(name, 0))
}
```
With the `dioxus` feature, a component goes into a Dioxus 0.7 page (`dioxus-ssr` or fullstack):
`{greeting::island(&props)}` in `rsx!` is the island element itself, with exactly the markup Vue
hydrates inside it. See `examples/dioxus` and the crate guide's
[Dioxus page](https://github.com/Kinsmir/ferrovue/blob/main/crates/ferrovue/docs/guide/dioxus.md).
### 5. Diagnostics in your editor
`ferrovue/volar` is a plugin for Vue's language tools, which the Vue (Official) extension for VS Code
and Vue support in other editors run. It compiles the project as you type and underlines what
ferrovue refuses in the open component, with the error's code linked to its documentation. Name it
in the `tsconfig.json` that includes your components, then run **Vue: Restart Vue and TS servers**:
```json
{
"include": ["components/**/*.vue", "components/**/*.ts"],
"vueCompilerOptions": { "plugins": ["ferrovue/volar"] }
}
```
The [quick start](https://docs.rs/ferrovue/latest/ferrovue/guide/quick_start/index.html#diagnostics-in-your-editor)
has the details; `examples/fullstack/tsconfig.json` enables it.
## What a component may use
ferrovue compiles `<script setup lang="ts">` components, and components with no script at all
(an icon, a divider), which take no props. Props are declared by type, with
`defineProps<{ … }>()`, an interface, or a type alias.
| Area | Supported |
|---|---|
| Prop types | `string`, `number` (integers, `i64`, written and computed as JavaScript does, exact within ±2⁵³), `Float` from `ferrovue/types` (fractions, `f64`), `boolean`, string-literal unions (`"sm" \| "md"`), `T \| undefined`, `T \| null` (an `Option` written as `null`), arrays (`T[]`, `Array<T>`, `readonly T[]`), interfaces and object type aliases (recursive ones too), dictionaries (`Record<string, T>`, `{ [key: string]: T }`, `ferrovue::Record` in Rust, which keeps JavaScript's order of keys), another component's exported `Props`, `TrustedHtml`, `InlineHtml` |
| Shared types | Interfaces and type aliases imported from `.ts` files (generated once, into `types.rs`), from a store's file, or from another component's `.vue` file; objects of a shared type can be passed between components. A TypeScript `enum` whose members are literals is the type of its values: a string, or a number |
| Constants | Constants imported from `.ts` files and `enum`s (imported, or declared in the component), evaluated when the component is compiled: strings, numbers, booleans, `null`, lists of them, objects of them read field by field (`LABELS.save`, `Tone.Loud`, `Rank[5]`), and lists of objects of them, which become a `const` in `types.rs` (`{ value, label }` items get a type named after the constant, `OptionsItem`, unless the constant is typed with an interface: `OPTIONS: Option[]`; a field that is `null` in some items is `T | null`). See [Constants](#constants) |
| Props | `withDefaults`, destructured props with defaults (`const { size = "md" } = defineProps<…>()`), the props object (`const props = defineProps<…>()`, then `props.label` in the template or in script code), optional booleans (Vue casts an absent one to `false`), `defineModel` (named, required, with defaults), components with no props. A generic component (`<script setup generic="T extends Item">`) renders each type parameter as its constraint, which is all the template can rely on |
| Setup | `ref`/`shallowRef`, `computed` (an expression or a single `return`; one that may throw, through `.repeat()`, `.padStart()`, a date, `$t` or a helper, is evaluated where the render first reads it, once, as Vue does), refs that start empty, typed by their type argument (`ref<Row[]>([])`, `ref<Row>()`, `ref<string \| undefined>(undefined)`), plain `const`/`let`, `.value` in script code, helpers with Rust twins, a plain `<script>` block beside setup. Lifecycle hooks, `watch` (not `immediate`), `defineEmits`, `defineSlots`, `defineOptions`, `defineExpose`, template refs (`ref(null)`, `useTemplateRef`) and functions are client-only. The template may name them only from event handlers, which the server drops |
| Provide and inject | `provide` and `inject` resolved through the component tree, slot content seeing the providers of the component that renders it, as Vue does: keys that are string literals or `InjectionKey` symbols exported from `.ts` files; values that are props, refs, `computed`s, constants or objects of them (an interface's fields, `reactive()` unwrapping refs); defaults, factory defaults (`inject(k, () => …, true)`), object defaults built as the key's interface (`inject(k, { size: "md" })`), and keys holding functions, which only event handlers may call. A page rendered from Rust passes the generated `Provides`, empty or holding what the client app gives `app.provide`. See the crate's [`provide_inject`](https://docs.rs/ferrovue/latest/ferrovue/guide/provide_inject/index.html) guide |
| Text | `{{ }}` of strings, integers and booleans; `+`, `-`, `*`, `/`, `%`, with integers and fractions as JavaScript computes them; `<`, `>`, `<=`, `>=` (between numbers, or between strings by UTF-16 code unit), `===`, `!==`; unary `-`, `!`; `??`, `\|\|`, `&&`, `?:`; template literals; optional chaining `a?.b`; `.length`; `String()`, `.toString()`, `.toFixed()`, `Math.max`/`min`/`abs`/`round`/`floor`/`ceil`/`trunc`; `Number()`, `parseInt()` (no radix, 10 or 16), `parseFloat()`; `JSON.stringify()` of strings, numbers, booleans and lists of those; array literals |
| Strings | `.trim()`, `.trimStart()`, `.trimEnd()`, `.toUpperCase()`, `.toLowerCase()`, `.includes()`, `.startsWith()`, `.endsWith()`, `.indexOf()`, `.lastIndexOf()`, `.slice()`, `.substring()`, `.at()`, `.charAt()`, `.split()`, `.replace()` and `.replaceAll()` with string patterns (`$&`, `` $` ``, `$'`, `$$` read as JavaScript reads them), `.padStart()`, `.padEnd()`, `.repeat()`; indices and lengths count UTF-16 code units, as JavaScript's do (see [Strings](#strings)) |
| Dates | `new Date(s)` and `Date.parse(s)` of an ISO string (a date alone, or a date and time with a `Z` or an offset, as ECMAScript's date time string format specifies), `new Date(ms)`, `.toISOString()`, `.getTime()`, `.valueOf()` and the `.getUTC…()` methods, invalid dates included, as JavaScript computes them. Prices and formatted dates are written in Rust and passed as strings, or filled into a `$t` message. See the crate's [`dates_and_prices`](https://docs.rs/ferrovue/latest/ferrovue/guide/dates_and_prices/index.html) guide |
| Conditions | `v-if` / `v-else-if` / `v-else`, `?:`, `&&` and `||`, with optional and nullable values narrowed as TypeScript narrows them: `v-if="user"`, `user !== undefined`, `user !== null`, `user != null`, and `!user`, `user === undefined` or `user === null` for the `v-else` |
| Lists | `v-for` over arrays (of strings, numbers, objects or child props), array literals and number ranges (`n in 5`); with an index; with destructured items (`{ id, name } in rows`); nested; on `<template>`. `.includes()` (a number looked for in a list of numbers whether either is a `number` or a `Float`, as JavaScript compares them), `.join()`, and `.filter()`, `.map()`, `.some()`, `.every()`, `.find()`, `.findIndex()` with an arrow function of the item (a name, or destructured) and its index, whose body is an expression, narrowing inside as outside, or with `Boolean`; `.filter(Boolean)` of a list or an array literal whose items may be `null` or `undefined`, which keeps the ones present and truthy as a list of the values (`[name, title, city].filter(Boolean).join(" · ")`); `Boolean(x)`; `.slice()`; chained and nested; a computed list read by `v-for`, `.join()`, `.length`, `.includes()`, kept by `computed`, or handed to a child |
| Dictionaries | `v-for="(value, key, index) in r"` over a `Record<string, T>`, and `([key, value], index) in Object.entries(r)`, in JavaScript's order: keys that are array indices first, in numeric order, then the others as the props give them; `Object.keys(r)`, `Object.values(r)`, `Object.entries(r).length`; handed to a child |
| Attributes | static and bound attributes, boolean attributes, `:hidden`, `data-*` and `aria-*`, `v-bind` objects |
| `class` | strings, arrays, objects (`{ active: on }`, computed keys), `cond && "x"`, `cond ? "x" : null`, merged with a static `class` |
| `style` | objects (camelCase or kebab-case keys, `--custom` properties), arrays of objects, strings, merged with a static `style`, and `v-show`; later values override earlier ones as in Vue. A global `<style>` block is allowed |
| Scoped styles | `<style scoped>`: the id on every element, on child components' roots (a root that is itself a component, fragments, recursion and `inheritAttrs: false` as Vue renders them) and, from a component with `:slotted()` rules, on the slot content it is given, forwarded slots included; inside `<Transition>`, `<KeepAlive>`, `<Teleport>` and `v-if`; on `<RouterLink>` and what it holds, as vue-router renders them |
| `v-bind()` in `<style>` | variables bound to props, setup values, `computed`s and constants, quoted expressions included (`v-bind('size + "px"')`), in a scoped or global `<style>`: written into the root's `style` after its own and what a parent passes, on each root of a fragment, and handed to a root that is a component, as Vue's server writes them; `undefined` and `null` as `initial`, an empty string as a space; named as plugin-vue names them for production or development (`isProduction`) |
| CSS modules | `<style module>` and `<style module="name">`, read as `$style.x`, `name.x`, `$style['x-y']` and through `useCssModule()`, each class a constant in the generated code, when `cssModules` mirrors Vite's `css.modules.generateScopedName`: computed as postcss-modules computes it in Vite (`[local]`, `[name]`, `[hash:base64:5]`, `[path]` and the rest), composed classes, `@value`s and keyframes included |
| Components | imported child components, `v-bind` of a child's own `Props`, listeners in any form (`@x`, `v-on="obj"`, `@[name]`), which the server skips, `v-model` on a child's `defineModel`, recursion; props named in `kebab-case` or `camelCase`; `defineAsyncComponent(() => import("./X.vue"))` (or `{ loader: … }`), rendered as the component it loads, which Vue's server renderer waits for |
| `<component :is>` | over a closed set of choices, compiled to a `match`: an imported component (`:is="Card"`), an HTML element's name (`is="h2"`), a `computed` or `?:` choosing among them, a prop typed as a union of string literals (`as: "h1" \| "h2"`), and an object of imported components (or element names) declared in setup or imported from a `.ts` file, read by such a prop (`ICONS[name]`); props, fallthrough attributes, slots and scope ids reach each choice as they reach a static child; also inside `<KeepAlive>` and `<Transition>`. See the crate's [`generated_code`](https://docs.rs/ferrovue/latest/ferrovue/guide/generated_code/index.html#component-is) guide |
| Escape hatches | `<ClientOnly>` from `ferrovue/client`, whose default slot the server never renders, so anything may go in it; components listed in `twins`, rendered by Rust functions of yours. See [Escape hatches](#escape-hatches) |
| Fallthrough attributes | what a parent passes a child beyond its props (static and bound attributes, `class`, `style`, `data-*`, `aria-*`, booleans, `undefined`): onto its single root, merged with the root's own class and style and replacing its other attributes where they stand, as Vue's `mergeProps` merges them; none for two roots; on through a root that is a component, or a `<RouterLink>`; with `inheritAttrs: false`, onto the elements or components that bind `v-bind="$attrs"` or a `useAttrs()` binding, before or after their own; beside scope ids. Listeners are dropped, as Vue's server drops them. See the crate's [`generated_code`](https://docs.rs/ferrovue/latest/ferrovue/guide/generated_code/index.html#fallthrough-attributes) guide |
| Slots | default and named slots, fallbacks, `$slots.name` tests and the same through `useSlots()`, scoped slots (`<slot :item="x">` and `#item="{ item }"` or `v-slot="props"`), whose props a parent can hand to its own children |
| Forms | `v-model` on text inputs, checkboxes, radios, `<select>` and `<textarea>` (renders the initial state) |
| Built-ins | `<Transition>`, `<TransitionGroup>`, `<KeepAlive>`, `<Suspense>` (synchronous content), `<Teleport>` (to any target, nested, disabled: see below), `v-text`, `v-once`, `v-pre`, `v-memo`, custom directives listed in `clientDirectives` |
| Vue Router | `<RouterLink>` (resolved by name or imported) with a string `to` or `{ name, params, query, hash }` / `{ path, query, hash }`, `active-class`, `exact-active-class`, `aria-current-value`, `replace`; vue-router's own encoding and active-link matching, nested routes included (a parent link is active on its children's pages, exact only on its own); a history base; optional parameters (`:id?`); routes built from a folder of pages, which the Vite plugin also gives the client as `ferrovue/routes`. `useRoute()` and `$route`: `path`, `fullPath`, `hash`, `name`, `params`, and `query` (a value written once, without `=`, or repeated, exactly as vue-router parses it; `typeof route.query.q === "string"` narrows one to a single string). `<RouterView>`, at the top and in nested route components: each takes the page it shows as a slot |
| Pinia | option stores with a typed `state`, and setup stores (`defineStore(id, () => { … })`) whose returned refs are typed by `ref<T>()` or their initial literal; read through `useX()` or `storeToRefs`, in the template or in `computed`; getters that are an expression of the state, and a setup store's computeds, which may read each other |
| vue-i18n | `$t` and `useI18n()`'s `t` and `locale`: named and list values, plurals by vue-i18n's rule, literals, linked messages with `upper`/`lower`/`capitalize`, nested and flat keys, fallback locales, a missing key shown as itself. Messages are parsed at build time by vue-i18n's own compiler |
| `v-html` | only on a `TrustedHtml` prop (`import type { TrustedHtml } from "ferrovue/types"`), or an `InlineHtml` one (inline tags only, `ferrovue::InlineHtml` on the server, with no `trustedHtml` needed), the only HTML it may write inside a `<p>`; never on a table's structure, a `<select>` or `<optgroup>`, or an SVG or MathML element that holds no HTML. See the crate's [`escaping`](https://docs.rs/ferrovue/latest/ferrovue/guide/escaping/index.html#where-v-html-may-go) guide |
| Page head | `useHead` (and `useServerHead`) from `@unhead/vue` 3: `title`, `titleTemplate`, `base`, `meta`, `link`, `script`, `style`, `noscript`, `htmlAttrs` and `bodyAttrs`, with values the server computes (props, setup bindings, getters such as `() => props.title`, `list.map(x => ({ … }))` for a list of tags), `key`, `tagPosition`, `tagPriority` and `tagDuplicateStrategy`; `useSeoMeta` (and `useServerSeoMeta`) of string, number, boolean and `null` values. Collected into a `ferrovue::Head` in the order Vue's server runs them, and written as unhead's `renderSSRHead` writes them: its tag order, deduplication, title template and escaping. See [Page head](#page-head) |
Refused at compile time, each with an error that names the construct:
- `<style module>` without `cssModules` in the configuration; a CSS module in another file, one a
preprocessor compiles, or one that imports from another file; `v-bind()` in `<style>` of a value
that is not a string or a number
- `<RouterView>` in a component with `<style scoped>`, which would give the page that component's
id; and, since vue-router renders a link from virtual nodes, a `<slot>` inside a `<RouterLink>`
that takes scope ids, or an element inside one in slot content given a `:slotted()` id
- `<component :is>` over an open set (a `string`, a `Component`, a key that is not a literal union),
over a prop that may be absent, or naming a key its object lacks or a tag that is not an HTML
element; `v-html` or `v-text` on it; and, inside an element it chooses (which Vue renders from
virtual nodes), a `<slot>` with fallback content, `v-show`, and `v-model` on a `<select>`
- in provide and inject: a key that is neither a string literal nor an `InjectionKey` symbol exported
from a `.ts` file, `inject(key)!`, a value that may be `null` or `undefined`, values of different
types (or a ref and a plain value) under one key, setup that assigns to an injected value,
`provide` in a component holding `<RouterView>`, a string key injected inside a Rust twin's slot,
and `provide(…)` not imported from `vue`
- `v-show`, and `v-model` on a `<select>`, in the content of a `<RouterLink>` or of a Rust twin's
slot, which Vue renders from virtual nodes
- in the page head: options given to `useHead`, `useHeadSafe`, `templateParams`, a `titleTemplate`
function, event handlers (`onload`), a `class` or `style` that may be `null` (on which unhead's
renderer throws), and an object or array given to a `useSeoMeta` key
- the Options API (a `<script>` without `setup`), and a type parameter of a generic component with
no constraint (`generic="T"`)
- constants that are not literals (`Date.now()`, a function), an object constant read whole or by a
key chosen at run time (`LABELS[key]`), and `new` of anything but `Date` (`new Map()`)
- the current time (`new Date()`, `Date.now()`), `new Date(year, month, …)`, a date string outside
ECMAScript's date time string format or with a time and no `Z` or offset, which browsers may read
differently (one from the props panics as it renders), the local getters (`.getFullYear()`,
`.getHours()`), `.toString()` and the other `Date` methods; and, permanently, locale formatting:
`Intl`, `.toLocaleString()`, `.toLocaleDateString()`, `.toLocaleTimeString()`, `.localeCompare()`
and vue-i18n's `$d` and `$n`, whose output differs between Node and each browser
- `<RouterLink custom>`, slot props that are array literals, defaults in destructured slot props, and outlets of one slot that pass different props
- custom directives not listed in `clientDirectives`
- `watchEffect`, `watch` with `immediate`, `onServerPrefetch`, top-level `await`, and statements in setup that change state
- `route.meta` and `route.matched`
- in a folder of pages: a parameter beside text in one part of a path (`prefix-[id].vue`), repeatable
and optional catch-all parameters, parameter parsers (`[id=int]`), named views (`index@aside.vue`),
`definePage()`, a `<route>` block, and two pages, or a page and a component, with one component name
- Pinia getters that read `this` or return a function
- ordering comparisons between a string and a number, which JavaScript makes numeric
- regular expressions (`.replace(/x/g, …)`, `.split(/,/)`), replacement functions, a search's
starting position or a split's limit (`.includes(x, 3)`, `.split(",", 2)`), `.toLocaleUpperCase()`
and `.toLocaleLowerCase()` (the server's locale is not the browser's), `parseInt` with another radix
or of a number, `.repeat()` by a negative literal
- array methods given anything but `Boolean` or an arrow function whose body is an expression
(`.map(String)`, `x => { return … }`), `.map()` to optional values, methods other than
`.filter(Boolean)` over a list of optional values, a computed list as a slot prop
- a dictionary's field read by name (`r.key`, `r[key]`), which may be absent although TypeScript says
it is not; `Object.entries()` anywhere but as a `v-for`'s source; dictionaries of optional values
- two strings that may each hold half of a surrogate pair compared, searched or joined (see
[Strings](#strings))
- a type that is both `null` and `undefined` (`T | null | undefined`, `x?: T | null`), a default
for a nullable prop, and `=== null` or `=== undefined` of a value that may be either (`a?.b` where
`b` is nullable; `== null` tests both)
- an attribute (`class` and `style` aside) bound to a value that may be neither a string, a number
nor a boolean: a list, an object, or a `route.query` value, which is an array when its key is
repeated. Vue's server renderer leaves the attribute out and hydration then sets it, silently;
join a list (`.join(",")`), or narrow a query value with `typeof route.query.q === "string"`
- any method call without a Rust twin
- a value read from `$attrs` or `useAttrs()` (`$attrs.title`), which has no type; an attribute a
component is passed that would reach a prop of the component (or `<RouterLink>`) its root passes it
on to; `$attrs` in a component that is the root of one that may be handed scope ids, where it would
hold them; an attribute named by a number, or bound to a `$route.query` value; a class merged by
`v-bind` with a string that may equal the class before it, which Vue would write once; attributes
passed to a component whose root is a `<Transition>` or `<KeepAlive>` around a `v-if`, which Vue's
server drops but its client keeps
Every error names the file, line and column, with the line quoted and a caret under the construct.
An object prop handed to a child component is cloned. Its strings are `Cow`s, so borrowed ones
cost nothing to copy.
### Constants
A constant a component imports from a `.ts` file, or an `enum`, is evaluated when the component is
compiled, as `types.rs` holds the shared types:
```ts
// components/catalog.ts
export const LABELS = { title: "Catalogue", empty: "Nothing yet" } as const;
export const SIZES = ["sm", "md", "lg"] as const;
export const SORTS = [
{ key: "name", label: "By name" },
{ key: "price", label: "By price" },
] as const;
export enum Tone { Calm = "calm", Loud = "loud" }
```
`{{ LABELS.title }}` and `v-if="tone === Tone.Loud"` are written into the page as the strings they
are, and `v-for="s in SIZES"` walks an array literal. A list of objects becomes a `const` beside
the shared types, with a type for its items (here `SortsItem`, or the interface the constant is
declared with):
```rust
pub const SORTS: &[SortsItem<'static>] = &[
SortsItem { key: Cow::Borrowed("name"), label: Cow::Borrowed("By name") },
SortsItem { key: Cow::Borrowed("price"), label: Cow::Borrowed("By price") },
];
```
A constant is a literal, a list, an object, or another constant of the same file. One that is
anything else (`Date.now()`, a function) is refused where the template reads it, naming the file
and line it is declared on.
### Scoped styles
A `<style scoped>` component's elements carry `data-v-<id>`, and its CSS is rewritten by the client
build to select them. The server has to write the id the client build chose, which is not something
the browser checks when it hydrates: a wrong id hydrates cleanly and leaves the styles unapplied. So
ferrovue computes it as `@vitejs/plugin-vue` does (the first 8 hex digits of a SHA-256 of the
`.vue` file's path from Vite's root, followed by its source unless only the path is hashed), and the
two must be configured alike:
| `@vitejs/plugin-vue` | `ferrovue.config.json` |
|---|---|
| `vite build` with the default options | `"scopeId": "filepath-source"` (the default) |
| `features: { componentIdGenerator: "filepath" }` (any mode), or the dev server | `"scopeId": "filepath"` |
Set `viteRoot` when Vite's root is not the directory holding `ferrovue.config.json`. The Vite
plugin (`ferrovue/vite`) compares the two when a component has scoped styles: a build in which they
differ fails, and the dev server warns.
`v-bind()` in `<style>` sets CSS variables on the component's root, which Vue's server writes into
its `style` and ferrovue writes there too. Their names follow the same id, and whether plugin-vue
compiles for production (`vite build`) or development (the dev server): set `isProduction` to match,
`true` by default. CSS modules (`<style module>`) take their class names from Vite's
`css.modules.generateScopedName`: set it to a pattern, and `cssModules` in `ferrovue.config.json` to
the same, and ferrovue computes each class as postcss-modules does in Vite. The Vite plugin compares
both, and fails a build in which they differ. See the crate's
[`scoped_styles`](https://docs.rs/ferrovue/latest/ferrovue/guide/scoped_styles/index.html) guide.
The default is the plugin's production behaviour because the production build is the one readers
get: with every option left alone, its styles apply. But plugin-vue hashes the path alone in its dev
server, so with the default a page rendered during development carries other ids than the dev
client and shows unstyled. The recommended setup is `componentIdGenerator: "filepath"` with
`"scopeId": "filepath"`, as [`examples/fullstack`](https://github.com/Kinsmir/ferrovue/tree/main/examples/fullstack) does: the ids are then the
same in development and production, and do not change, nor change the generated Rust, whenever a
component's source does.
A component a parent may hand ids to has a `render_scoped(…, attrs)` beside `render`, which
generated parents call (for one a parent also passes attributes to, `attrs` is an `fv::Attrs`
holding them and the ids). A component whose outlets pass a slot scope id (`:slotted()`) takes slot
content that is given it: `Slot::slotted` for a slot, a third `&str` parameter for a scoped slot's
closure. `render`, and content from Rust, need none of it: markup written from Rust carries no ids.
Where Vue's own server render gives other ids than its client render, ferrovue writes the server's:
a `:slotted()` component's slot fallback, which only the client gives the slot scope id, and a
component with `inheritAttrs: false` that is another component's root, whose root only the client
gives the ids that other component inherits.
### Strings
String methods count as JavaScript counts: in UTF-16 code units, so `"🦀".length` is 2 and
`"🦀 crab".slice(3)` is `"crab"`. Strings are ordered by code unit too, which puts every character
from U+E000 to U+FFFF after one beyond U+FFFF. Each runtime routine is held to vectors recorded from
JavaScript (`crates/*/tests/vectors/`).
A JavaScript string can hold half of a surrogate pair (`"🦀".slice(0, 1)`, `.charAt(1)`,
`.split("")`), and a Rust string cannot. ferrovue writes U+FFFD in its place, which is exactly what
the page carries anyway: a server sends Vue's string as UTF-8, and UTF-8 writes each half as U+FFFD
(`res.end`, `Buffer.from` and `TextEncoder` all do). Its length is the same. Where the half itself
would decide the result, ferrovue refuses at compile time: two strings that may each hold a half
compared or ordered, side by side (`a.slice(0, 1) + b.slice(1)`, a `.join("")`, a class object's
names), one searched for in another (`.includes(a.charAt(0))`), repeated or padded where halves
would join, ordered against anything but a literal below U+D800, or written by `JSON.stringify`
(which escapes it) or into a `<RouterLink>` (whose encoding throws). Three corners remain, by design:
- a string holding U+FFFD in the props compares equal to a half, or finds one, where JavaScript
would tell them apart;
- a half handed to a child component or a helper is U+FFFD there, which matters only if that
component then compares or joins it as above;
- Vue itself cannot hydrate such text cleanly: the browser reads U+FFFD where its own render holds
the half, and reports a mismatch.
Where JavaScript throws, the generated code panics, as Vue's render rejects: a negative or infinite
`.repeat()` count, and a `.repeat()`, `.padStart()` or `.padEnd()` past the longest string V8 makes
(2²⁹ − 24 code units).
### Dictionaries
A `Record<string, T>` prop is a `ferrovue::Record`, built from pairs
(`[("b", 1), ("10", 2)].into_iter().collect()`) or read from JSON. It holds its keys in the order a
JavaScript object does, array indices (`"0"` to `"4294967294"`) first in numeric order, so `v-for`
walks them as Vue does, and it is written back as JSON in that order, which the browser reads back
the same.
### Translating
With `i18n` configured, the generated `i18n` module holds every locale's messages. Build an `I18n` per
request, in the reader's locale, and pass it to the components that translate:
```rust
let i18n = generated::i18n::i18n("nl"); // falls back as configured
page::render(&mut out, &props, &i18n);
```
### Escape hatches
Some components will never compile: a component library's render functions, code that measures the
page in `onMounted`. Two ways keep one of them from blocking the page around it.
**`<ClientOnly>`**: the server writes its `#fallback` slot (or a comment, without one), and the
browser hydrates that same fallback, then swaps in the default slot once mounted. Both sides render
the fallback first, so this is exact by construction. ferrovue does not compile the default slot at
all: any component, import or expression may go there.
```vue
<script setup lang="ts">
import { ClientOnly } from "ferrovue/client";
import { VDataTable } from "vuetify/components";
defineProps<{ rows: { name: string }[] }>();
</script>
<template>
<ClientOnly>
<VDataTable :items="rows" />
<template #fallback><p class="loading">Loading the table…</p></template>
</ClientOnly>
</template>
```
The swap needs Vue running on that part of the page, so put `<ClientOnly>` inside an island (or an
app the client hydrates whole); elsewhere the fallback stays. `<ClientOnly>` takes no attributes.
**Rust twins**: a component the server should render, written by you in Rust. List it under
`twins` with the props a template may pass it and the slots it may fill:
```json
{
"twins": {
"VBtn": {
"rust": "crate::ui::v_btn",
"props": { "label": "string", "size": "int?", "block": "bool" },
"slots": ["default"]
}
}
}
```
A template then uses `<VBtn>` (or `<v-btn>`), imported from anywhere or registered globally, and the
generated parent calls `crate::ui::v_btn` with the props, the slots (each a `ferrovue::Slot`) and
`&ferrovue::Attrs`: the attributes it is given beyond its props and the scope ids its root takes.
`twins.rs` beside the components holds `VBtnProps`, `VBtnSlots` and the signature your function must
have. Prop types are those of helpers (`string`, `int?`, …); an absent `bool` is `false`, and a
bare `block` is `true`, as Vue casts them.
**Exactness is your responsibility.** ferrovue cannot check that `v_btn` writes the bytes Vue's
server renderer writes for `VBtn`: its guarantee rests on your code here, as it does for helpers. Prove it with fixtures of the components that use the twin, held to Vue by the
conformance suite (see [Testing against Vue](#testing-against-vue)). Slot content reaches the twin as a component's render function sees it
(`<!--v-if-->` for an absent `v-if`, as Vue writes virtual nodes). The twin decides where it goes and
whether fragment markers surround it, as the component does. `examples/fullstack` has a
twin (`src/ui.rs`) and its fixtures (`fixtures/`).
### Teleports
A component that renders a `<Teleport>` takes a `ferrovue::Teleports`. After rendering the page body,
write what was teleported where each target is, as Vue's `ssrContext.teleports` is written:
```rust
let teleports = ferrovue::Teleports::new();
page::render(&mut body, &props, &teleports);
let modals = teleports.get("#modals").unwrap_or_default(); // inside <div id="modals">
```
As Vue recommends, teleport to a dedicated element such as `#modals`: the browser hydrates
a target from its first node, and `body` also holds the app.
### Page head
A component that calls `useHead` or `useSeoMeta` from `@unhead/vue` takes a `ferrovue::Head`, which
collects the calls of the whole tree in the order Vue's server runs them. After rendering the body,
write the head as unhead's server renderer writes it:
```rust
let head = ferrovue::Head::new(); // createHead(): lang="en", charset and viewport; or Head::without_defaults()
book_page::render(&mut body, &props, &head);
let tags = head.render(); // head_tags, body_tags_open, body_tags, html_attrs, body_attrs
let page = format!("<html{}><head>{}</head><body{}>{}{}{}</body></html>",
tags.html_attrs, tags.head_tags, tags.body_attrs, tags.body_tags_open, body, tags.body_tags);
```
On the client the head is unhead's: give the page's app its client head,
`mountPage(layout, islands, { plugins: [createHead()] })` with `createHead` from
`@unhead/vue/client`, and it takes over the tags the server wrote. The crate's
[`head`](https://docs.rs/ferrovue/latest/ferrovue/guide/head/index.html) guide has the details.
### Scoped slots from Rust
A scoped slot's `Slots` field takes a closure that receives the props its outlet passes, as a
generated `…SlotProps` struct borrowed for the render. The closure returns whether it wrote content;
returning `false` shows the slot's fallback, as empty content does in Vue.
```rust
let row = |out: &mut String, p: &data_list::RowSlotProps<'_>| {
ferrovue::escape_into(out, p.label);
true
};
data_list::render(&mut page, &props, data_list::Slots { row: Some(&row), ..Default::default() });
```
### Hydrating islands
`mountIslands` from `ferrovue` hydrates every `data-island` element on the page with the
component of that name, from the props the server wrote. With the Vite plugin, `ferrovue/islands`
gives it every island there is, so nothing has to be listed by hand:
```ts
import { hydrateState, mountIslands } from "ferrovue";
import islands from "ferrovue/islands"; // written by `ferrovue()` from `ferrovue/vite`
hydrateState(pinia);
await mountIslands(islands, { pinia, router }); // one Pinia and one router for every island
```
The islands are exactly the components that have an `island()`: those that render from their props
alone, as the compiler finds while it generates them. That is the only set the server can write
`data-island` for, so it needs no folder convention or list to keep in step, and making a component
an island is a matter of the server calling its `island()`. Each is keyed by its `NAME`, the file
name without `.vue`, which is what `data-island` carries.
Each entry is a lazy `import()`, so the bundler gives every island a chunk of its own and a page
fetches the code of the islands it holds and no more. `mountIslands` takes such loaders and
components alike, so a hand-written island can join them: `mountIslands({ ...islands, Chart })`.
An island whose name is not among them is left as the server rendered it, with a warning naming it
(`onError` to handle it otherwise), as is one whose component fails to load.
An island can wait to hydrate, and to fetch its code, until it is needed. `hydrate` on the
server's `island()` writes a `data-hydrate` attribute on the wrapper, which `mountIslands` honours
with Vue's lazy hydration strategies:
```rust
use ferrovue::Hydrate;
reviews::island(&props).hydrate(Hydrate::Visible); // scrolled into view
footer::island(&props).hydrate(Hydrate::visible_with("200px")); // within 200px of the view
chart::island(&props).hydrate(Hydrate::Idle); // the browser is idle
counter::island(&props).hydrate(Hydrate::Interaction); // pointerenter, click or focus
menu::island(&props).hydrate(Hydrate::InteractionOn(&["keydown"]));
sidebar::island(&props).hydrate(Hydrate::media("(min-width: 60rem)"));
```
The island's markup is unchanged, so nothing about exactness changes. An event that woke an island
waiting for interaction is dispatched again once it has hydrated, so the first click is not lost.
An island without `data-hydrate` hydrates at once, as before.
An island's `<style>` goes into its chunk too, and Vite links it only once the island's script has
loaded, after the server's markup is on screen. Link those stylesheets from the page up front: a
server reading Vite's manifest takes the `css` of the entry's `dynamicImports` as well as its own
(`examples/fullstack/src/assets.rs`).
An application whose own navigation layer leaves the page can keep `<RouterLink>` for its `href`s
and active classes with `linkRouter(routes, { navigate })` from `ferrovue/link-router`: a router
over the routes file (or the default export of `ferrovue/routes`, with a folder of pages) whose
every navigation after the first goes to `navigate`. The routing guide
(`ferrovue::guide::routing`) explains each part of it.
### Hydrating a whole page
A page built from a layout whose slots hold components, each rendered from its own props, can be
hydrated as one app. `ferrovue::Page` takes each slot's parts as `Part`s, made from the same
`html()` values that write the markup, renders the layout, and writes what it rendered as a
`<script type="application/json">`; `mountPage` reads it back, loads the components it names and
hydrates the layout with each slot's parts:
```rust
let mut page = ferrovue::Page::new();
let content = page.slot("default", books.iter().map(|b| Part::new(pick::NAME, pick::html(b))));
let reviews = page.hole("reviews"); // filled later, while the page streams
let record = page.render_to(&mut out, picks::html(&props, picks::Slots {
default: Some(content.slot()),
reviews: Some(reviews.slot()),
}, &route));
```
```ts
import { mountPage } from "ferrovue";
import islands from "ferrovue/islands";
await mountPage(() => import("./Picks.vue"), islands, { pinia, router });
```
The record repeats the text of every prop, so a page hydrated this way is larger than one of
islands; the guide's `islands_and_hydration` page compares the two, and `examples/fullstack` serves
a page of each kind.
An application that goes to the next page without a reload shows it from the record in the fetched
document, with the same app:
```ts
import { hydrateState, readPage, renderPage } from "ferrovue";
const next = readPage(await (await fetch(href)).text()); // no DOMParser: works in a worker too
const pinia = createPinia();
hydrateState(pinia, { text: next.state });
const router = linkRouter(routes, { navigate, location: href }); // at the next page's location
current = await renderPage(() => import("./Picks.vue"), islands, next.record, { pinia, router, previous: current });
```
`renderPage` mounts the next page's app in place of the container before it unmounts `previous`, so
a router both use stays started. `renderRecordedPage` from `ferrovue/testing` checks that a page
rendered from its record on the client shows what the server wrote, and `conformanceSuite` checks
the same of every fixture.
### Hydrating Pinia state
```rust
ferrovue::state_script_into(&mut page, "__pinia", &stores); // a <script type="application/json">
```
```ts
import { hydrateState } from "ferrovue";
hydrateState(pinia); // before app.mount(): every store starts from what the server rendered
```
### Fixtures in an application's vitest run
`fixtureApp` from `ferrovue/testing` installs Pinia for a fixture's `$stores`, vue-router for its
routes and vue-i18n for its `i18n` option, importing each itself. A component's `useBasket()`,
`useRoute()` or `useI18n()` reads the instance its own import of the package gives it, so the two
must be one module instance. vitest loads `ferrovue/testing` from `node_modules` with Node, and
runs the application's code through Vite; where a peer goes through Vite too (listed in
`server.deps.inline`, aliased, linked from elsewhere, or installed twice), the fixture installs a
different instance from the one the component reads, and the render fails with Pinia's "there was
no active Pinia" or an undefined route. Pass the application's own modules in the options:
```ts
const app = await fixtureApp(component, readFixture(json), routes, {
pinia: await import("pinia"),
vueRouter: await import("vue-router"),
vueI18n: await import("vue-i18n"),
});
```
Without them, `fixtureApp` imports the peers itself, as before. Another way is to have vitest run
ferrovue through Vite as well, which puts it on the application's module graph; this also covers
`vue` itself, which `fixtureApp` always imports:
```ts
// vitest.config.ts
export default defineConfig({ test: { server: { deps: { inline: ["ferrovue"] } } } });
```
### Streaming with holes
`ferrovue::hole()` is a slot whose content you write later. Render a layout with holes,
`split_holes` the output, and stream the pieces with each hole's content between them, in whatever
order the content is ready. With the `stream` feature (which `axum` and `actix-web` turn on),
`ferrovue::HtmlStream` is that streamed body, from the render and a future for each hole's content:
```rust
let body = ferrovue::HtmlStream::new(page).hole(async move { reviews_of(&id).await });
```
### Testing against Vue
An application holds its own components to Vue as ferrovue's suite does, with a fixtures directory
and two calls. A fixture is a component's props as JSON (plus `$slots`, `$route`, `$stores` and
`$locale` where it takes them) beside the HTML Vue rendered for them:
```text
fixtures/Reviews/three.json {"reviews":[{"reader":"Ada","stars":5,"text":"The spice must flow."}]}
fixtures/Reviews/three.html recorded from Vue with FERROVUE_FIXTURES_WRITE=1
```
```ts
// test/conformance.test.ts, run by vitest with environment: "happy-dom"
import { join } from "node:path";
import type { Component } from "vue";
import { conformanceSuite } from "ferrovue/testing";
await conformanceSuite({
config: join(import.meta.dirname, "../ferrovue.config.json"),
components: import.meta.glob<Component>("../client/components/*.vue", { eager: true, import: "default" }),
pinia: await import("pinia"),
vueRouter: await import("vue-router"),
});
```
```rust
ferrovue::conformance!("fixtures", generated::render_json, at_least = 24);
```
The Vue half fails on a component with no fixtures, a fixture naming no component, a project with
no components, generated Rust that differs from what `ferrovue` writes now, a fixture Vue renders
differently from its `.html`, and a recorded `.html` that hydrates with any warning;
`FERROVUE_FIXTURES_WRITE=1` records the `.html` files instead. The Rust half renders every fixture through the generated `render_json`,
compares the bytes, and fails when fewer than `at_least` fixtures are found. The crate's
[`testing`](https://docs.rs/ferrovue/latest/ferrovue/guide/testing/index.html) guide has the
details; `examples/fullstack` uses both.
## Performance
Time to render one component to an HTML string. On the left is Vue's `renderToString` on Node, and
on the right is the Rust that ferrovue generated from the same `.vue` file. Both get the same props,
and both write the same bytes: each side checks its output against
`crates/ferrovue/benches/expected/<scenario>.html` before anything is timed.
| Scenario | What renders | Output | Vue `renderToString` | ferrovue | Speed-up |
|---|---|---|---|---|---|
| `small` | `Nav`: two `<RouterLink>`s resolved against the current route | 206 B | 36.41 µs | 0.989 µs | 36.8× |
| `list` | `Lists`: 1,000 words, 1,000 numbers, 100 groups of 10 members | 102 KiB | 355.8 µs | 46.16 µs | 7.7× |
| `tree` | `Tree`: a recursive component, binary tree 8 levels deep (255 nodes) | 9.5 KiB | 522.2 µs | 3.86 µs | 135.2× |
| `page` | `Dashboard`: 22 `Panel`s with named slots, a `Text`, 20 `Frame`s holding loops | 8.0 KiB | 181.3 µs | 5.06 µs | 35.8× |
These are mean times per render. The Vue column is tinybench's mean, with 5 s per scenario after
a 1 s warm-up. The ferrovue column is criterion's mean point estimate, using its defaults of a 3 s
warm-up and 100 samples.
What each side measures:
- **Vue**: one awaited `renderToString(app)`. Vue is the production build (`NODE_ENV=production`).
Each component uses the `ssrRender` its SSR build would have. The app is created once outside
the loop, along with its Pinia instance and, for `small`, a vue-router already at `/users/me`.
- **ferrovue**: a new `String` plus one call of the generated `render`, built with the release
profile. The props, the router and the `Route` are built once outside the loop.
Measured on an AMD Ryzen 5 3600XT (6 cores / 12 threads, up to 3.8 GHz) with 62 GiB of RAM, running
Linux 7.2.8-2-cachyos (CachyOS, x86_64). Software: Node 26.10.0, Vue 3.5.43, rustc 1.99.0. The
machine was lightly loaded (a load average of about 1) but not idle, so treat the numbers as
indicative.
The speed-up says as much about Vue as about ferrovue. Most of Vue's time goes on component
instances and their virtual nodes, of which `tree` has 255 and `list` one: per byte written, Vue
renders `list` some fourteen times faster than `tree`, while ferrovue writes the two at a similar
rate.
To reproduce:
```sh
pnpm bench # both halves: pnpm bench:rust, then pnpm bench:js
pnpm bench:rust # cargo bench -p ferrovue --bench render (crates/ferrovue/benches/render.rs)
pnpm bench:js # Vue's renderToString with tinybench (packages/ferrovue/bench/ssr.bench.ts)
pnpm bench:record # rewrite benches/expected/*.html from Vue, after changing a scenario
```
The benchmarks are not part of `pnpm test`. CI runs both in a short mode only to check that they
still build, run and agree on the HTML. It never compares their timings. What CI does hold to a
budget is the number of instructions each scenario runs, within 5% of a committed baseline, and
the time ferrovue and `cargo build` take on a project of 500 components; [TESTING.md](https://github.com/Kinsmir/ferrovue/blob/main/TESTING.md#performance-budgets)
describes both.
## Versions
Byte-identical output depends on Vue's own SSR compiler, so ferrovue compiles with an exact
`@vue/compiler-sfc` (currently 3.5.44), and its conformance fixtures are recorded from one version
of Vue and of each integration. Your project may use any later patch of the same minor:
| Peer | Supported | Recorded from |
|---|---|---|
| `vue` | `~3.5.44` (3.5.44 and later 3.5 patches) | 3.5.44 |
| `vue-router` (optional) | `~5.4.0` | 5.4.0 |
| `pinia` (optional) | `~4.1.0` | 4.1.0 |
| `vue-i18n` (optional) | `~11.4.15` | 11.4.15 |
| `@unhead/vue` (optional) | `~3.4.3` | 3.4.3 |
A new minor of any of them (Vue 3.6, vue-router 5.5, …) needs a ferrovue release that re-records
the fixtures from it. A weekly CI job re-records them from the newest patch each range allows and
hydrates the committed HTML with it, so a patch that changes what Vue writes or hydrates is caught
before it reaches you; a ferrovue patch follows when one does.
### Deprecations
Something ferrovue stops supporting is deprecated first, for at least one minor release, and
removed in the next major release. A deprecated item of the crates carries `#[deprecated]`, so the
Rust compiler warns where it is used. A deprecated configuration key warns with
`warning[FV1117]` and a deprecated command-line option with `warning[FV1118]`, each naming what
replaces it, and the JSON Schema marks the key deprecated for editors. Each release's CHANGELOG lists
what it deprecates under "Deprecated".
## Repository layout
```text
crates/ferrovue/ the Rust runtime crate, which generated code calls
src/ a module per part (`Html`, slots, class and style, the state script,
strings, teleports, the page head, fallthrough attributes, `BasicHtml`
and `InlineHtml`,
`Sanitised`, the fixture check `conformance!` writes), each module's
unit tests beside it in <module>/tests.rs; lib.rs re-exports them all,
and the three crates below
docs/guide/ the crate guide, `ferrovue::guide` on docs.rs
tests/conformance/ components, fixtures, recorded HTML, generated Rust
tests/literal_props/ components compiled with `"builders": false`
tests/sanitised/ a component whose `TrustedHtml` is `ferrovue::Sanitised`
tests/vectors/ vectors recorded from JavaScript and Vue
tests/properties.rs property-based tests of the runtime
benches/ criterion and instruction-count benchmarks of generated renderers
crates/ferrovue-core/ escaping and JavaScript's numbers, which the other crates share
tests/vectors/ vectors recorded from escapeHtml, String(n), Math and toFixed
crates/ferrovue-router/ vue-router's matching and links (the `router` feature)
tests/vectors/ vectors recorded from vue-router
crates/ferrovue-i18n/ vue-i18n's t() (the `i18n` feature)
crates/ferrovue-contract/ unpublished: Rust using every item of the generated-code contract,
and the version check's message (trybuild)
packages/ferrovue/ the compiler (npm package)
src/index.ts `ferrovue`: the browser API, `mountIslands`, `mountPage`, `readPage`, `renderPage`, `hydrateState` and the types
src/compiler.ts `ferrovue/compiler`: `generate`, `write`
src/context.ts, model.ts `ferrovue.config.json`, and the types and values the compiler passes around
src/schema.ts the configuration's keys, types and defaults, from which `schema.json` is written
src/component.ts, script.ts, typescript.ts, constants.ts
a `.vue` file read, <script setup>, TypeScript types, constants and enums
src/template.ts, children.ts, slots.ts, loops.ts, vhtml.ts
the compiled template: statements, child components, slots, v-for,
where `v-html` may go
src/expr.ts, strings.ts, numbers.ts, narrowing.ts, calls.ts, lists.ts, parens.ts
expressions: operators, strings, numbers, narrowing, calls, lists,
the parentheses Rust needs
src/attrs.ts, classes.ts, styles.ts, fallthrough.ts
attributes, class and style, attributes a parent passes on
src/dynamic.ts `<component :is>` over a closed set of choices
src/plugin.ts the plugin interface (see CONTRIBUTING.md)
src/plugins/ vue-router, Pinia, vue-i18n, scoped styles, <Teleport>, shared types,
<ClientOnly>, Rust twins, provide and inject, the page head
src/rust.ts, emitter.ts the Rust source written out
src/cli.ts, args.ts, vite.ts
the `ferrovue` command, its options, and the Vite plugin
src/diff.ts the diff `ferrovue --check --diff` prints for a stale file
src/errors.ts, diagnostics.ts
every error code with its title, and errors as the CLI, JSON and Vite show them
src/volar.ts, editor.ts `ferrovue/volar`: errors in the editor, from a plugin for Vue's language tools
src/client.ts browser-side helpers: `mountIslands`, `mountPage`, `readPage`, `renderPage`, `hydrateState`, `<ClientOnly>`
src/islands.ts `ferrovue/islands`, which the Vite plugin writes: every island, loaded lazily
src/link-router.ts `ferrovue/link-router`: `<RouterLink>` while the application navigates on its own
src/routes.ts a routes file as vue-router's route records
src/file-routes.ts the routes vue-router's file-based routing builds from a folder of pages
src/page-routes.ts `ferrovue/routes`, which the Vite plugin writes from the pages
src/testing.ts `ferrovue/testing`: utilities for a project's own conformance suite
src/fixture.ts, settle.ts `fixtureApp` and `readFixture`, and waiting for async components to settle
src/conformance.ts `conformanceSuite`, which `ferrovue/testing` exports
src/ssr.ts `attachSsrRender`, which `ferrovue/testing` exports
src/hydration.ts `hydrateRecordedPage` and `renderRecordedPage`, which `ferrovue/testing` exports
src/client-render.ts the DOM of a client render as it is compared with the server's markup
src/types.ts `ferrovue/types`: `TrustedHtml`, `InlineHtml`, `Float`
test/ compiler, CLI, router, vector, island, Vite and conformance tests
api/ the package's API report, an `.api.md` per export and `cli.txt` (`pnpm api:report`)
schema.json the JSON Schema of `ferrovue.config.json` (`pnpm schema:generate`)
browser/ conformance fixtures and recorded pages hydrated in real browsers (`pnpm test:browser`)
bench/ Vue renderToString benchmarks, the other half of Performance
fuzz/ the randomised differential tester (`pnpm fuzz`)
examples/greeting/ the smallest setup: one component rendered from Rust
examples/fullstack/ axum + Vite: islands (some hydrating later), a page hydrated whole,
Pinia state, routes from a folder of pages, the page head, streaming,
<ClientOnly>, a Rust twin, and a conformance suite
examples/dioxus/ a Dioxus page, rendered with dioxus-ssr, with an island in it
fuzz/ cargo-fuzz targets for the runtime's escaping and JSON (see TESTING.md)
scripts/ferrovue-in.ts the `ferrovue` command run in a project of this repository (`pnpm conformance:check`, CI)
scripts/inspect.ts Vue's SSR code and render of a component beside the Rust ferrovue generates for it
scripts/release.ts the release version bump (see RELEASING.md)
scripts/api-report.ts the npm package's API report, written and checked (`pnpm api:report`, `pnpm api:check`)
scripts/traceability.test.ts the README's "What a component may use" rows, traced to conformance components in TESTING.md
```
## Development
```sh
pnpm install
pnpm test # compiler, CLI and the Vue half of the conformance suite
pnpm typecheck
cargo test --all-features
```
[TESTING.md](https://github.com/Kinsmir/ferrovue/blob/main/TESTING.md) explains how the suite fits together and how to add a case,
[CONTRIBUTING.md](https://github.com/Kinsmir/ferrovue/blob/main/CONTRIBUTING.md) how to add support for a Vue construct, and
[RELEASING.md](https://github.com/Kinsmir/ferrovue/blob/main/RELEASING.md) how a release is cut.
[`examples/fullstack`](https://github.com/Kinsmir/ferrovue/blob/main/examples/fullstack/README.md) is a complete app to copy: an axum server, a Vite client, islands, Pinia, vue-router and streaming, with a test that it hydrates.
## Licence
Licensed under either of [Apache License, Version 2.0](https://github.com/Kinsmir/ferrovue/blob/main/LICENSE-APACHE) or [MIT licence](https://github.com/Kinsmir/ferrovue/blob/main/LICENSE-MIT),
at your option. Unless you explicitly state otherwise, any contribution you intentionally submit for
inclusion in this work, as defined in the Apache-2.0 licence, is dual-licensed as above, without any
additional terms or conditions.