Islands
Opt-in per-subtree hydration. Mark a region of your page with
island(view) and Purity will render the rest of the document as static
HTML with zero client JavaScript. Only the islanded regions ship a
client chunk, and each one hydrates on its own configured trigger.
ADR 0038 is the design rationale; this page is the practical guide.
When to reach for an island
Use islands when your page is mostly static and only a few regions need to react to anything:
- marketing landing pages with a CTA button or signup form,
- blog posts with a like button, comment counter, or share widget,
- documentation pages with a sidebar TOC or search,
- product listings where only the filter / cart count are interactive.
Use the default (whole-page hydration) when your page is mostly interactive — dashboards, editors, mail clients, anything where most of the document responds to state.
The opt-in shape preserves the default: apps that don't call island()
see no change in build output, bundle size, or runtime behaviour.
The API
import { island, mountIslands } from '@purityjs/core';
island(view, options?)
Brands a view function as an island. On the server, the rendered output
is wrapped in <purity-island data-pi-id="N" data-pi-trigger="…" style="display:contents">…</purity-island>. On the client, the brand
is a no-op when called directly — mountIslands() is what makes the
trigger fire.
import { island, component, html, state } from '@purityjs/core';
const Counter = component('my-counter', () => {
const n = state(0);
return html`<button onclick=${() => n.update((v) => v + 1)}>${n}</button>`;
});
export const Interactive = island(Counter, { hydrate: 'visible' });
| Option | Type | Default | Effect |
|---|---|---|---|
hydrate |
'load' | 'idle' | 'visible' | 'interact' | \media:${string}`` |
'load' |
When the island's chunk hydrates. See the trigger matrix below. |
mountIslands(views, options?)
Client-side runtime that finds every <purity-island> wrapper in the
document and schedules its hydration. Entries match by 1-based ID —
the first island SSR-rendered on the page is views[0], the second
is views[1], and so on.
Each entry can be either:
- an eager branded view:
mountIslands([Counter])— ships with the shell; - a lazy dynamic-import thunk:
mountIslands([() => import('./counter.ts').then((m) => m.Counter)])— each island lands in its own Rollup-split chunk, requested only when the trigger fires.
The lazy form is what delivers the headline byte savings. Use it unless an island is small enough that splitting it into its own chunk costs more (HTTP overhead) than inlining it would save.
// entry.client.ts
import { mountIslands } from '@purityjs/core';
mountIslands([
() => import('./islands/counter.ts').then((m) => m.Counter),
() => import('./islands/like.ts').then((m) => m.Like),
]);
| Option | Type | Default | Effect |
|---|---|---|---|
root |
ParentNode |
document.documentElement |
Scope the wrapper scan to a subtree. Useful for tests. |
onMount |
(id: number, root: Element) => void |
none | Called once per island after hydrate() returns. Instrumentation. |
The trigger matrix
| Trigger | Hydrates when… | Falls back to |
|---|---|---|
'load' |
the next microtask after mountIslands() runs. |
— |
'idle' |
the browser is idle (requestIdleCallback, timeout 2 s). |
setTimeout(…, 1) on Safari pre-17 |
'visible' |
the wrapper enters the viewport (IntersectionObserver). |
'load' when the API is missing |
'interact' |
first pointerdown / focusin / keydown / click / submit inside the wrapper. |
— |
media:(…) |
the CSS media query matches (matchMedia). |
'load' when matchMedia missing |
Triggers are mutually exclusive in this release — one per island.
Composite triggers (e.g. "whichever of visible or interact fires
first") can be added later without breaking the API.
First interaction during lazy loading
If a click or form submit reaches an 'interact' island before its
client chunk hydrates, Purity holds the first activation and replays it
once after hydration. Repeated activations during the wait are ignored,
so a submit button does not submit twice. Native keyboard activation
flows through the same click/submit path; Enter or Space on a custom
control with an activation ARIA role replays its keydown. Form submits
inside open Declarative Shadow DOM roots are captured too. Focus and
text input are not intercepted while the chunk loads.
The replayed event is synthetic and cannot restore browser-granted user
activation. File and color pickers, modified clicks, new-tab links, and
download links keep their native action instead of waiting for replay.
For controls that need a trusted event or a transient user gesture, use
'load' or 'visible' hydration so their handler is ready beforehand.
Cross-island state
Each island has its own signal graph. Two islands on the same page do
not share state() unless you set it up explicitly. Three documented
patterns cover the realistic cases:
- URL state via
currentSearch()/currentHash()(router primitives). Works without any new primitive. - Storage-backed signals — wrap a
state()in alocalStorage/sessionStorage/cookieadapter. (A first-classpersist()helper is on the backlog.) - Server round-trip via
serverAction()for state that must be authoritative.
The most common footgun: closing over a module-scope state() in your
island. When the island is split into its own chunk, the closure is
duplicated, not shared. Two <my-counter /> instances on the same page
would each have their own module-scope counter.
What works in this release
island()brand and SSR<purity-island>wrapper.mountIslands()client runtime, all five trigger kinds.- Per-island chunk split via dynamic-import thunks.
- Custom-element-rooted islands (auto-upgrade via DSD, then in-place hydration after host props are bound).
- html-rooted islands (single-element root).
Known limitations
- Multi-rooted islands (a view that returns a fragment with multiple sibling elements) hydrate through the single hydrate-walker path. If you hit issues, wrap the island content in a single element.
- Trusted event replay is impossible after an asynchronous import. The first ordinary activation is replayed synthetically, but browser APIs requiring a trusted event or transient user activation still need an already hydrated island.
island()detection by the Vite plugin is not automated yet. The user wiresmountIslands(…)with explicit dynamic-import thunks. A future Vite plugin pass can transformmountIslands([X, Y])into the lazy form automatically.- Cross-page island state is bounded by your storage / URL choices. An SPA navigation re-mounts islands on the destination page.
Example
See examples/islands-blog/ for a runnable
demo: a content-heavy blog page with a 'load' counter and a
'visible' like button. Build it with npm install && npm run build
from the example directory; the production output ships the shell as
plain HTML and serves each island as its own chunk.