Quill docs
Client runtime & behaviors
The client runtime is the small, hand-written vanilla JavaScript that brings islands to life in the browser. It has two faces: a set of dependency-zero JS modules that actually run in the browser, and a Rust bundler (qquill-runtime) that concatenates those modules in order, applies a safe minify, and returns a single self-executing script the host embeds. This page covers every module — the signal core, the lazy triggers, the behavior registry, the hydration scanner, boot, and the shipped reference behaviors — plus the deterministic per-page bundling and tree-shaking that lets each page ship only the behaviors it uses.
Two faces: hand-written JS + a Rust bundler#
The shipped artifact is hand-written vanilla JavaScript with zero runtime dependencies, living under qquill-runtime/src/js/*.js. Each module is authored as (function (Q) { ... })(Q) and attaches to a single shared Q namespace. The Rust crate qquill-runtime is a bundler: it include_str!s the JS modules, concatenates them in strict dependency order inside one IIFE that declares Q under "use strict", applies a minimal safe minify, and returns the bundle as a string for the host to embed in a single <script> on any page with at least one island. (Node.js is used only to run the JS conformance tests; it is never shipped.)
- 00_signal.js
- The signal core: signal / computed / effect with fine-grained dependency tracking, glitch-free, batched via a microtask flush, with cleanup and disposal.
- 10_triggers.js
- Lazy hydration triggers: load (microtask), visible (IntersectionObserver), interaction (first pointer/touch/focus/key), idle (requestIdleCallback -> setTimeout).
- 20_registry.js
- A behavior registry keyed by data-q-kind: register(kind, factory) plus internal lookup helpers.
- 30_hydrate.js
- Scans [data-q-island], reads the props sidecar, and hydrates each island in place on its declared trigger.
- 40_boot.js
- Publishes the public API on window.__qq and starts hydration on DOM ready. Runs LAST so behaviors are registered first.
- 50_behaviors.js
- The two reference behaviors (counter, dialog) sharing one module. Other kinds live in src/js/behaviors/*.js.
The signal core#
The core exposes Q.signal, Q.computed, Q.effect, Q.onCleanup, Q.batch, and Q.flushSync. Reads inside a computed or effect subscribe to the signals they touch (dependency tracking). It is glitch-free (a computed over two signals updates once per batch and is never observed mid-update), batched (N synchronous writes coalesce into one downstream run via a microtask flush), lazy and cached (a computed recomputes only when a dependency changed and only when read), and disposable (effects return a disposer; cleanups run on dispose and before each re-run).
signal(initial) returns an accessor: call it with no args to read (and track), or with one arg to write. A write to the same value is a no-op (no notify, no churn). .peek() reads without tracking. The model is pull-based: a write marks computeds stale and queues effects but does not recompute eagerly — consumers recompute on demand after every producer in the batch has committed, which is what guarantees glitch-freedom.
Request
// inside a behavior factory, via the ctx primitives:
var count = ctx.signal(0);
var doubled = ctx.computed(function () { return count() * 2; });
var stop = ctx.effect(function () {
console.log("doubled =", doubled());
});
count(1);
count(2); // two synchronous writes...Response
// effect runs once immediately: doubled = 0
// after the microtask flush, the two writes coalesce into ONE re-run:
// doubled = 4Output
count(1) then count(2) produce a single effect re-run (batched), and doubled is never observed as 2 in between (glitch-free). Calling stop() disposes the effect and runs its cleanups.| API | Signature | Behavior |
|---|---|---|
| Q.signal(initial) | () => read; (next) => write | Accessor. No-op on equal write. .peek() reads untracked. |
| Q.computed(fn) | () => value | Lazy, cached getter. Re-runs only when a tracked dep changed and it is read again. .peek() reads untracked. |
| Q.effect(fn) | () => dispose | Runs fn now; re-runs batched on dep change. fn may return a cleanup; returns a disposer. |
| Q.onCleanup(cb) | cb registered | Registers a cleanup from inside the running effect/computed; runs before each re-run and on dispose. |
| Q.batch(fn) | runs fn | Runs fn (synchronous writes are already batched via the microtask). |
| Q.flushSync() | drains queue | Drains queued effects now (used by tests / trigger code wanting effects applied before returning). |
Lazy triggers#
10_triggers.js maps a data-q-trigger value to a function that arms the trigger on an island element and calls the hydration callback exactly once when it fires. Each trigger returns a disposer so hydration can cancel an unfired trigger. The public entry is Q.arm(el, name, cb); unknown names fall back to load.
| data-q-trigger | Mechanism | Fallback |
|---|---|---|
| load | setTimeout(fire, 0) — next macrotask, effectively now | — |
| visible | IntersectionObserver; fires + disconnects on first intersection | onLoad if IntersectionObserver is unavailable |
| interaction | First pointerdown/touchstart/focusin/keydown on the island (capture phase), then tears down listeners | — |
| idle | requestIdleCallback(fire, {timeout: 2000}) | setTimeout(fire, 200) if requestIdleCallback is unavailable |
The behavior registry#
A behavior is the client logic for one component kind. Q.register(kind, factory) adds it, keyed by data-q-kind. Later registrations override earlier ones (last-wins), so a host app can swap a component's behavior. The runtime looks up the factory on hydration via internal Q._behavior(kind) and Q._hasBehavior(kind).
The factory receives a context object describing the island. Behaviors hydrate in place: they re-instantiate from props and wire events/bindings onto the existing server DOM — they never re-render the server tree.
| ctx field | What it is |
|---|---|
| ctx.el | The island root element (the server fallback subtree). |
| ctx.id | The data-q-island instance id. |
| ctx.kind | The data-q-kind string. |
| ctx.props | Parsed sidecar JSON (already un-escaped by JSON.parse). |
| ctx.signal / computed / effect / onCleanup / batch | The reactive primitives. |
| ctx.dispose(fn) | Register a teardown to run if the island is torn down. |
A factory may return a dispose function; it is added to the island's cleanups alongside anything registered via ctx.dispose.
Hydration scanner#
30_hydrate.js exposes Q.hydrate(root) (default root: document). It scans for [data-q-island], and for each un-armed boundary reads data-q-trigger and arms that trigger. When the trigger fires, hydrateOne reads the sidecar, parses props, looks up the behavior, builds the ctx, and calls the factory. It is idempotent: boundaries are armed at most once (_qArmed) and hydrated at most once (_qHydrated).
Props are read by matching the sidecar on BOTH type and id: script[type="application/qq"][data-q-for="<id>"] (the id is CSS-escaped). A missing sidecar yields {}; a malformed one is caught and also yields {} — so one bad island never kills the rest of the page. A kind with no registered behavior leaves the server fallback untouched (a valid degraded state). If a factory throws, _qHydrated is reset so a later pass can retry.
Each hydrated island gets an el._qDispose teardown that runs all cleanups and resets the hydration flag — so a client router can dispose an island on navigation.
Boot and the public surface#
40_boot.js publishes the public API on window.__qq (signal, computed, effect, onCleanup, batch, flushSync, register, hydrate) and starts hydration when the DOM is ready. It merges onto any pre-existing window.__qq, so a host that queued register() calls before the bundle loaded is preserved. If the document is still loading it waits for DOMContentLoaded; otherwise it starts immediately.
The shipped reference behaviors#
50_behaviors.js registers two reference behaviors that prove the islands spine end to end. Both hydrate in place onto the existing server DOM.
counter — a pure-signal proof. It finds the display target ([data-q-bind="count"], else an <output>) and the increment control ([data-q-action="inc"], else a <button>), reads props.start and optional props.step (defaulting to 0 and 1), creates a signal, and registers one effect that writes the count into the existing node. Clicking the button bumps the signal.
Request
Q.register("counter", function (ctx) {
var out = ctx.el.querySelector('[data-q-bind="count"]') || ctx.el.querySelector("output");
var btn = ctx.el.querySelector('[data-q-action="inc"]') || ctx.el.querySelector("button");
var start = (ctx.props && typeof ctx.props.start === "number") ? ctx.props.start : 0;
var step = (ctx.props && typeof ctx.props.step === "number") ? ctx.props.step : 1;
var count = ctx.signal(start);
var stop = ctx.effect(function () { if (out) out.textContent = String(count()); });
function onClick() { count(count.peek() + step); }
if (btn) btn.addEventListener("click", onClick);
ctx.dispose(function () { if (btn) btn.removeEventListener("click", onClick); stop(); });
});Response
// Given sidecar props {"start":0} and fallback
// <output data-q-bind="count">0</output> <button data-q-action="inc">+1</button>:
// each click increments the signal; the effect rewrites only the <output> text node.Output
No re-render of the server subtree — only the existing text node's textContent changes.dialog — a component-behavior proof. It ports the headless qquill-ui Dialog transitions to JS. The SSR fallback is the CLOSED dialog. Open/closed is a single signal driving one effect that sets aria-expanded on the trigger, toggles hidden/aria-hidden/data-state on the surface and backdrop, and manages focus (move into the surface on open, restore to the trigger on close). The trigger click opens it; Escape (captured at the document level), a backdrop click, or the close button closes it. It deliberately does not auto-open on hydrate, because the interaction trigger arms on events that precede the real click.
| kind | Module file | Notes |
|---|---|---|
| counter, dialog | 50_behaviors.js | Reference pair; share one module. |
| menu, popover, tooltip, drawer | behaviors/overlays_*.js | Overlay behaviors. |
| tabs, accordion, stepper | behaviors/disclosure_*.js | Disclosure behaviors. |
| combobox, command-palette | behaviors/selection_*.js | Selection behaviors. |
| switch-group, checkbox, radio, slider, toast | behaviors/forms_*.js | Form behaviors. |
| theme, theme-control, reveal, copy, playground, motion, tilt | behaviors/site_*.js | Site/docs behaviors; motion is the generic 3D pointer-follow behavior, tilt is the compatibility alias. |
Bundling: core, behaviors, boot#
The bundler exposes the module composition explicitly. CORE_MODULES is the four always-shipped modules (signal, triggers, registry, hydrate) — it does NOT include boot or any behavior. BEHAVIOR_MODULES is the 22 concrete behaviors keyed by data-q-kind in a fixed, deterministic order. MODULES is the full ordered list used by the full bundle.
- runtime_bundle()
- Builds the FULL bundle: core + every behavior (deduped) + boot. Kept for back-compat and pages that want everything.
- runtime_bundle_for(kinds)
- Builds a per-page bundle: core + ONLY the behaviors for the given kinds + boot. The recommended path.
- runtime_bundle_pretty()
- The unminified full bundle (comments + whitespace preserved). For debugging / source maps; not shipped.
- core_bundle_size()
- Byte size of the core-only payload (core + boot, minified) — the floor every island page pays.
- behavior_size(kind)
- The minified bytes a single behavior kind ADDS on top of core. 0 for unknown kinds.
runtime_bundle_for is the heart of tree-shaking. It always emits core, then walks BEHAVIOR_MODULES in its fixed order including a module only if one of its kinds was requested, never including the same module twice (so requesting both counter and dialog pulls their shared module once), then emits boot last. Unknown and duplicate kinds are ignored. Crucially, module order follows the fixed BEHAVIOR_MODULES order — NOT the caller's kinds order — so the output is content-hashable for an immutable Cache-Control / ETag.
Request
// Rust (host side):
let kinds = qquill_view::collect_island_kinds(&tree); // e.g. ["menu", "tabs"]
let bundle = qquill_runtime::runtime_bundle_for(
&kinds.iter().map(String::as_str).collect::<Vec<_>>(),
);Response
(function(){"use strict";var Q={};/* ...signal, triggers, registry, hydrate... */
/* ...menu behavior... */ Q.register("menu", /*...*/);
/* ...tabs behavior... */ Q.register("tabs", /*...*/);
/* ...boot... */})();Output
Contains Q.signal, Q.hydrate, Q.register plus Q.register("menu",...) and Q.register("tabs",...). Does NOT contain Q.register("combobox",...), Q.register("toast",...), etc. Wrapped in one IIFE under "use strict". runtime_bundle_for(["tabs","menu"]) and runtime_bundle_for(["menu","tabs"]) produce byte-identical output.Safe minify#
The bundler applies a minimal, deliberately conservative minifier (no JS parser). Per line it: strips full-line // comments (a line whose first non-whitespace is //), strips /* ... */ block comments (possibly multi-line), strips leading indentation and trailing whitespace, and drops now-empty lines. It then joins surviving lines with single newlines, which is safe because the modules use explicit statement terminators (no ASI hazards).
What it deliberately does NOT do, to stay correct without tokenizing: it never touches characters inside a string, template literal, or regex; it does not collapse interior whitespace (so else if stays intact). The block-comment stripper is a tiny state machine tracking single/double-quote strings, template literals, line and block comments — so a /* or // inside a string is never mistaken for a comment (e.g. a URL like http://example.com survives).