Menu
Public repository / master
AkurAI-Framework / site/content/docs/islands.md
Latest change f77b709bd5d10a720dc73afb8db12765666bb659 - OLI-244: virtual-list and rich-text islands with sanitizer, node tests, docs by Ólafur Búi Ólafsson
# Islands & hydration
The framework renders every page server-side: the SSR engine fills your HTML
against the page context, and the browser receives complete, real markup. Most
of that markup is static and should stay that way. **Islands** are the small,
explicitly-marked regions that need to become interactive after load.
`islands.js` is a few-KB, no-build, zero-dependency runtime that hydrates *only*
those marked regions — in place, reusing the server-rendered nodes. No virtual
DOM, no re-render, no flicker. See it live on the [islands demo](/islands).
It builds directly on [`signals.js`](/docs/reactivity): an island's setup
function uses the same `signal` / `effect` primitives to wire reactivity.
## What an island is
An island is any element carrying `data-island="name"`, where `name` matches a
component you registered in JavaScript. Everything *not* inside a `data-island`
element is never touched — it's pure server HTML.
```html
<article data-island="counter">
<script type="application/json" data-island-state>{"count": 7}</script>
<div id="counter-value">7</div>
<button data-counter-inc>+1</button>
</article>
```
## The `registerIsland` API
```js
import { registerIsland, signal, effect } from "/islands.js";
registerIsland("counter", (el, initialState) => {
const count = signal(initialState.count ?? 0);
const out = el.querySelector("#counter-value");
effect(() => { out.textContent = String(count()); });
el.querySelector("[data-counter-inc]")
.addEventListener("click", () => count(count() + 1));
});
```
- **`name`** — the string matched against `data-island="…"`.
- **`setupFn(el, initialState)`** — runs once per matching island after
hydration. `el` is the server-rendered root; `initialState` is the parsed
initial state (always an object). Mutate `el` in place — the return value is
ignored. Wire signals/effects/listeners to the *existing* nodes; do not
replace them.
Registration order doesn't matter: the runtime defers its first hydration scan
to a microtask, so inline `<script type="module">` registrations on the page run
before islands are hydrated.
## Initial state from the server markup
Hydrated signals start at exactly the value the server printed, so there is no
flash of changed content. The runtime reads initial state from two sources, in
priority order:
1. A child `<script type="application/json" data-island-state>{…}</script>` —
best for rich or nested state; it isn't an attribute, so no HTML escaping.
2. A `data-state='{…}'` attribute on the island root — the same convention
`signals.js` uses.
Whichever is found is JSON-parsed and passed as `initialState`. If neither is
present (or the JSON is invalid), `initialState` is `{}`.
This is the contract that kills the flash: render the value once on the server,
print it in the markup, seed the signal from it. The first interactive paint
equals the SSR paint.
## How it differs from full-page reactivity
[`signals.js`](/docs/reactivity) auto-scans `[data-scope]` roots and wires
declarative `data-text` / `data-show` / `data-model` / `data-for` directives
generically — you write no JavaScript. Islands are **named components**: the
server decides which islands exist and what state each starts with, and you write
the wiring once in JS via `registerIsland`. Reach for directives when the markup
can describe the behavior; reach for islands when a region needs real component
logic, or when you want a hard boundary between "interactive" and "static".
The two compose: an island's `setupFn` is free to import and use `signal` /
`effect` (re-exported from `/islands.js`), and may even run the `signals.js`
directive binder over its own subtree.
## No virtual DOM, no flicker — by design
Hydration never creates or replaces nodes. It attaches behavior to the DOM the
server already sent. There is no diffing, no reconciliation, and no moment where
the UI is rebuilt from a JS-side model. Remove `islands.js` and the page still
renders — it just stops being interactive. Win by subtraction.
## Verifying in the browser
Open `/islands` and check the console:
```
[islands] hydrated counter
[islands] hydrated toggle
```
- The **counter** shows `7` on first paint (never `0`) and the +1 / −1 / Reset
buttons mutate it live.
- The **toggle** panel is visible on first paint; unchecking the switch hides it.
- The **static** region has no `data-island` and is never logged or modified.
## Installing the client as a package
Instead of vendor-copying `signals.js`, `islands.js`, `spa.js` and `akurai-client.js`, build the
versioned `@akurai/client` package (version = `VERSION`) and install the tarball:
```
scripts/build-client-package.sh # -> target/client-package
(cd target/client-package && npm pack) # -> akurai-client-<version>.tgz
```
Import via `@akurai/client/signals`, `/islands`, `/spa`, `/akurai-client`.
## Built-in islands: `virtual-list` and `rich-text`
Import `/virtual-list.js` and `/rich-text.js` to register them.
- **`virtual-list`** — windowed list for thousands of rows. Only the visible rows
(plus overscan) are in the DOM. Set `data-row-height` (px) and a fixed `height`
on the root; pass `{"items": [...]}` as island state with a `<template>` row.
For custom rows use `mountVirtualList(el, {items, rowHeight, render})`, which
returns `{setItems, refresh, destroy}`. `computeWindow` is the pure window math.
- **`rich-text`** — contenteditable editor with a toolbar (`data-cmd="bold"`,
`italic`, `underline`, `insertUnorderedList`, `insertOrderedList`, `link`).
A hidden input named by `data-name` always holds HTML sanitized by
`sanitizeHtml` (tag allowlist; only `http(s):`/`mailto:` links; no attributes
but `href`). Pasted content is sanitized too. Emits a bubbling `rt-change`.
Tests: `node --test tests/js`.