Islands & Partial Hydration
An island is a component that renders to static HTML on the server and hydrates independently on the client, on a strategy you choose. The rest of the page ships zero JS. This is how you keep a content-heavy page fast while still having interactive widgets.
When to use it
A mostly-static page (blog, docs, marketing) with a few interactive widgets (a counter, a menu, a comment box).
You want to defer hydration until idle / scroll-into-view / first interaction.
When not to use it
A fully interactive app (dashboard, editor) — there, hydrate the whole tree; islands add overhead with no benefit.
Declaring an island
import { island } from '@pyreon/server/client' // NOT the @pyreon/server barrel
export const Counter = island(() => import('./Counter'), {
name: 'Counter',
hydrate: 'visible', // hydrate when scrolled into view
prefetch: 'idle', // warm the chunk during browser idle first
})Import island from @pyreon/server/client, never the @pyreon/server barrel — the barrel drags node: server code into the client bundle.
name is shown here for clarity, but under @pyreon/vite-plugin it's optional for a const-bound declaration — the plugin derives a collision-free one from the binding. See IslandOptions.
Hydration strategies
'load'(default) — hydrate immediately on page load (above-the-fold interactive content).'idle'— hydrate when the browser is idle (requestIdleCallback).'visible'— hydrate when scrolled into view (pair withprefetch: 'idle').'interaction'— hydrate on the firstfocus/click/pointerenter/touchstart/submittargeting the island (modals, dropdowns, command palettes — content that's interactive but not visible on load). First matching event triggers hydration and removes all the listeners (one-shot). Onlyclickand formsubmitare replayed against the live element after hydration completes — a capturedfocus/pointerenter/touchstartwakes the island but isn't re-dispatched (focus in particular can't be reliably re-fired once the user has tabbed past it).'interaction(<events>)'— same, but hydrate on the first match from a custom comma-separated event list, e.g.hydrate: 'interaction(focus)'orhydrate: 'interaction(click,touchstart)'.'media(<query>)'— hydrate when a media query matches, e.g.hydrate: 'media(max-width: 640px)'(mobile-only menu).'never'— never hydrate (zero client JS). Do not register a'never'island inhydrateIslands({...})— that pulls it into the bundle and defeats the point (the manual registry form only;@pyreon/vite-plugin's auto-registry already omits'never'islands).
In a zero app (zero-config)
@pyreon/zero re-exports island and hydration is per-component, no registry at all:
import { island } from '@pyreon/zero'
export const Counter = island(() => import('./Counter'), { name: 'Counter', hydrate: 'visible' })That's the whole setup — you don't call hydrateIslandsAuto() or write any registry in a zero app's entry-client.ts. island()'s client branch renders only the <pyreon-island> marker (zero children), so a hydrating host adopts the marker and never descends into it; onMount() then loads the chunk and mounts the component INTO the marker per its strategy. This design is deliberately host-agnostic — it works whether a route's server-rendered DOM is adopted in place or rebuilt client-side, because the island owns its own hydration lifecycle rather than depending on a one-shot external registry scan that could race an async route mount.
@pyreon/vite-plugin's islands: true (default) is still worth having: it's what makes name optional — the plugin derives a collision-free name from the const X = island(...) binding at build time, in both the compiled marker and the registry prescan, so they can never disagree.
The hydrateIslandsAuto(registry) + virtual:pyreon/islands-registry path shown in Island Architecture is for a standalone @pyreon/server app (no @pyreon/zero router) that writes its own entry-client.ts — see examples/islands-showcase. 'never'-hydrate islands are omitted from that auto-generated registry so their components never reach the client bundle.
Common pitfalls
Importing
islandfrom@pyreon/server. Use@pyreon/server/client; the barrel pulls innode:fsetc. and breaks the client build.Passing children to an island. Island props are JSON-serialized for the SSR→client transit — children, functions, and symbols are stripped. Have the island render its own content from string props.
Duplicate
name. The registry keys byname; only the first loader fires. Use distinct names. (pyreon doctor --check-islandscatches this.)'interaction'for above-the-fold content. It only hydrates on first interaction — use'load'for content the user interacts with immediately.