Skip to content

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 with Q.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, or idle. Renders to data-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.

MarkerWhereMeaning
data-q-island="<id>"boundary divStable per-page instance id. Must be unique within a rendered page. Matches the sidecar's data-q-for.
data-q-kind="<kind>"boundary divSelects the client behavior to instantiate.
data-q-trigger="load|visible|interaction|idle"boundary divWhen the runtime hydrates this island.
<script type="application/qq" data-q-for="<id>">sidecarHolds the island's initial props as escaped JSON; matched to the boundary by id.
data-q-bind="<id>"inside fallbackA 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.

rust
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.

Triggerdata-q-triggerClient behaviorUse when
Trigger::LoadloadFires on the next microtask (effectively "now", after the current script turn).The island must be live immediately on page load.
Trigger::VisiblevisibleIntersectionObserver; hydrates when the island scrolls into view. Falls back to load if IntersectionObserver is unavailable.Below-the-fold widgets; defer until seen.
Trigger::InteractioninteractionFirst 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::IdleidlerequestIdleCallback (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 set
Lifecycle of one island

Sidecar 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.

rust
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 + boot
From rendered tree to minimal bundle

See "Client runtime & behaviors" for the exact module list, the deterministic bundle ordering, and byte-size accounting.