Quill docs
Islands & client hydration
An island is a small, self-contained interactive component inside an otherwise static page. The server renders correct, complete HTML for the island (so it works with JavaScript disabled), wraps it in a boundary element, and emits a separate props sidecar. The client runtime later finds that boundary and "hydrates" it — wiring up behavior on the existing DOM — but only when the island's declared trigger fires. This page explains the island contract end to end: how to author an island in Rust, what HTML it produces, the four hydration triggers, the props sidecar, slots, and how Quill ships only the JavaScript a page actually uses.
What an island is#
Most of a Quill page is static server-rendered HTML with zero JavaScript. An island marks a subtree as interactive. At render time the island produces three things: (1) a boundary <div> carrying data-q-island / data-q-kind / data-q-trigger; (2) the server fallback HTML inside that boundary — the correct static state a no-JS visitor sees; and (3) a props sidecar <script type="application/qq"> holding the island's initial state as escaped JSON. The client runtime reads these and instantiates a registered behavior in place. It does not re-render the server tree — it re-uses it ("resumability-lite").
- Boundary element
- The wrapper
<div data-q-island data-q-kind data-q-trigger>that the runtime scans for. The island's fallback is rendered inside it. - Server fallback
- The correct static HTML for the island's initial state (e.g. a closed dialog, the starting count). Works with JS disabled; the client hydrates onto it, never replacing it.
- Props sidecar
- A sibling
<script type="application/qq" data-q-for="<id>">whose body is the island's initial props as escaped JSON. Collected and placed once per page (typically at end of<body>). - Behavior
- The client-side logic for one
data-q-kind, registered withQ.register(kind, factory). On hydration the runtime looks up the kind and calls the factory with a context. - Kind
- The component type string in
data-q-kind(e.g.counter,dialog,menu). It selects both the client behavior and which behavior module a page must ship. - Trigger
- When to hydrate:
load,visible,interaction, oridle. Renders todata-q-trigger.
The island contract#
The contract is a fixed set of DOM markers the Rust renderer emits and the JavaScript runtime reads. Both sides agree on these exact attribute names and values.
| Marker | Where | Meaning |
|---|---|---|
| data-q-island="<id>" | boundary div | Stable per-page instance id. Must be unique within a rendered page. Matches the sidecar's data-q-for. |
| data-q-kind="<kind>" | boundary div | Selects the client behavior to instantiate. |
| data-q-trigger="load|visible|interaction|idle" | boundary div | When the runtime hydrates this island. |
| <script type="application/qq" data-q-for="<id>"> | sidecar | Holds the island's initial props as escaped JSON; matched to the boundary by id. |
| data-q-bind="<id>" | inside fallback | A node whose text/attribute a signal drives on the client (see Signals). |
| data-q-slot="<name>" | <template> | A named hole a parent behavior can locate and fill on the client. |
Authoring-side helpers (data-q-action, data-q-part) are conventions specific behaviors look for inside the fallback — for example the counter behavior finds its increment button via data-q-action="inc", and the dialog behavior finds its trigger via data-q-part="trigger". They are not part of the universal contract; each behavior documents its own.
Authoring an island in Rust#
Build an island with the island(...) constructor from qquill-view. Its signature is island(instance_id, kind, trigger, props, fallback). The instance_id, kind, and props accept anything convertible into Cow<'static, str> (string literals or owned String); trigger is a Trigger enum value; fallback is a Node.
use qquill_view::{el, island, text, Node, Trigger};
fn counter(instance_id: &'static str, start: i64) -> Node {
// The static fallback the server renders (works with JS disabled).
let fallback = el("div")
.class("counter")
.child(
el("output")
.attr("data-q-bind", "count")
.child(text(start.to_string())),
)
.child(
el("button")
.attr("type", "button")
.attr("data-q-action", "inc")
.child(text("+1")),
);
// Props the client behavior re-instantiates from on hydration.
let props = format!("{{\"start\":{start}}}");
// (instance id, behavior kind, hydration trigger, props JSON, fallback).
island(instance_id, "counter", Trigger::Load, props, fallback)
}What it renders: a real example#
This is the integration test transcribed faithfully (qquill-signal/tests/island_integration.rs). Two signals supply the props; the island is built and rendered, then its sidecar is collected separately. Note the boundary div, the inert data-q-bind span for no-JS users, the absence of any inline sidecar in the body, and the script-safe escaping of </script> and & in the collected sidecar.
Request
use qquill_signal::{signal_id, JsonValue};
use qquill_view::node::Node;
use qquill_view::{collect_island_sidecars, island, render, Trigger};
let count = signal_id("count", 0i32);
let label = signal_id("label", String::from("</script><x>&"));
let props = JsonValue::object([count.props_entry(), label.props_entry()]).to_json();
// props == {"count":0,"label":"</script><x>&"} (raw JSON; '<' kept)
let fallback = Node::element("button").child(count.text_node());
let isle = island("counter-1", "counter", Trigger::Interaction, props, fallback);
let body = render(&isle);
let sidecar = collect_island_sidecars(&isle);Response
// render(&isle) -> body contains, in order:
<div data-q-island="counter-1" data-q-kind="counter" data-q-trigger="interaction"><button><span data-q-bind="count">0</span></button></div>
// collect_island_sidecars(&isle) -> sidecar:
<script type="application/qq" data-q-for="counter-1">{"count":0,"label":"</script><x>&"}</script>Output
The body carries NO inline sidecar (it never contains the string "application/qq"). The '<' in the label became < and '&' became & in the sidecar, so the payload cannot break out of the <script>. There is exactly one literal </script> closing tag.The split is deliberate: render emits only boundaries + fallbacks (so HTML streams cleanly), and collect_island_sidecars gathers every island's props script so the host can place them all once, typically just before the runtime <script> at the end of <body>.
Hydration triggers#
The Trigger enum chooses when an island hydrates. It renders to the lowercase data-q-trigger value the JS runtime matches on. Picking the latest acceptable trigger keeps the main thread free and defers work.
| Trigger | data-q-trigger | Client behavior | Use when |
|---|---|---|---|
| Trigger::Load | load | Fires on the next microtask (effectively "now", after the current script turn). | The island must be live immediately on page load. |
| Trigger::Visible | visible | IntersectionObserver; hydrates when the island scrolls into view. Falls back to load if IntersectionObserver is unavailable. | Below-the-fold widgets; defer until seen. |
| Trigger::Interaction | interaction | First pointerdown / touchstart / focusin / keydown on the island (capture phase). The activating event still produces the user's intent. | Controls that only need to respond once touched (menus, dialogs). |
| Trigger::Idle | idle | requestIdleCallback (2s timeout); falls back to setTimeout(200ms). | Non-urgent enhancements that can wait for spare time. |
server render client runtime (on trigger)
----------- ---------------------------
<div data-q-island ...> 1. hydrate() scans [data-q-island]
<fallback/> ──► 2. arm(el, trigger, cb) (load/visible/interaction/idle)
</div> 3. trigger fires -> hydrateOne(el)
<script application/qq 4. read sidecar by data-q-for, JSON.parse props
data-q-for=...>{...} 5. look up behavior by data-q-kind
</script> 6. factory(ctx): wire events/effects on EXISTING DOM
<script>runtime bundle</script> 7. el._qHydrated = true; el._qDispose setSidecar props#
Props are how the client re-instantiates an island's initial state without re-running server logic. The author serializes them to a JSON string; at render time escape_script_json encodes < as < and & as &. Those are legal JSON escapes for the same code points, so the body is still valid JSON and standard JSON.parse reverses them on the client — no special decoding needed. Because </script> can no longer appear, the payload cannot break out of the script element.
Collection is recursive: an island nested inside another island's fallback gets its own sidecar too, in document order. If a sidecar is missing or malformed, the runtime hydrates that island with empty props ({}) rather than throwing and killing every other island on the page.
- page_has_islands(node)
- Returns true if the tree contains at least one island. The host uses it to decide whether to ship the runtime bundle and sidecars at all.
- collect_island_sidecars(node)
- Returns the concatenated props
<script>tags for the whole tree, in document order. Empty when there are no islands. - collect_island_kinds(node)
- Returns the distinct kinds present, first-occurrence order. Feeds the per-page bundle so only needed behaviors ship.
Slots#
A slot is a named hole a parent island's behavior can locate and fill on the client. Build one with slot(name, children). On the server it renders as a <template data-q-slot="<name>"> marker holding any default children; the <template> keeps the default content inert until the client moves or clones it. With no children it is an empty marker. Slots are server-inert — they exist so a behavior has a stable target to project content into.
use qquill_view::{slot, text, render};
let s = slot("body", [text("default body")]);
// render(&s) ->
// <template data-q-slot="body">default body</template>Tree-shaking: shipping only what a page uses#
Quill never ships behaviors a page does not use. The host computes the distinct kinds in the rendered tree with collect_island_kinds and passes them to runtime_bundle_for, which emits the always-present core runtime plus only the requested behaviors plus the boot module. In the starter, returning a page through respond_html does this automatically: because the tree contains an island, it appends the props sidecars and the single runtime <script> carrying only that page's behaviors.
render(tree) ───────────────► HTML body (boundaries + fallbacks)
collect_island_sidecars(tree) ► <script type="application/qq">...</script> (once per page)
collect_island_kinds(tree) ───► ["counter"]
│
▼
runtime_bundle_for(["counter"]) -> CORE + counter behavior + bootSee "Client runtime & behaviors" for the exact module list, the deterministic bundle ordering, and byte-size accounting.