Islands
Partial hydration: ship JavaScript only where behaviour lives.
A page ships no JavaScript until it declares an island. The server renders the region in full, attaches its props as JSON, and the client mounts only that region.
var props = new JsonBuilder();
props.beginObject();
props.field("start", PostStore.count());
props.endObject();
var fallback = new MarkupBuilder();
fallback.el("button", PostStore.count():string + " posts", "class", "counter");
h.raw(island(meta, "counter", props.done(), fallback.done()));island sets meta.needsClientRuntime, which is what adds the /_cataract/client.js tag. A page that declares no island ships no script tag at all.
The client side
Island modules live in app/islands/, named by their file:
import { defineIsland } from "cataract/client";
defineIsland("counter", (el, props) => {
const button = el.querySelector("button") ?? el;
let value = props.start ?? 0;
button.addEventListener("click", () => {
button.textContent = `${++value} posts`;
});
});The factory receives the island element and the parsed props, and runs once per element. A factory that throws is logged and the element left as rendered, so a failed mount degrades to a static region rather than a blank one.
The bundle
app/islands/*.js are concatenated with the client runtime into /_cataract/client.js. The import naming cataract/client is stripped during concatenation — the bundle has no module graph, and defineIsland is already in scope. Keeping the import written means the file stays a valid ES module that an editor and a real bundler both understand.
The runtime mounts on DOMContentLoaded, then watches the document with a MutationObserver, so islands introduced later by another island's DOM writes are picked up. An element is mounted at most once.
Progressive enhancement is the contract
The server renders the region's final markup, so the region is useful before — and if — the script runs. The search box on this site is the worked example:
- The server renders the input, an empty result list and a status line.
- The island attaches the listener and fetches
/api/searchas you type. - With JavaScript off the region is inert markup, and every page remains reachable through the sidebar and the links in the prose.
Props are attribute text in the document and visible to the client. Do not put anything in them the requester should not see.