Island
Wrap one registered component as a client island with a checked JSON props boundary.
The <Island> wrapper
Import Island from zfb or @takazudo/zfb and wrap one component that should run in the browser. The page and the rest of the tree are rendered as ordinary HTML. The examples use the shorter zfb form.
import { Island } from "zfb";
import Counter from "./Counter";
export default function Page() {
return (
<main>
<h1>My Page</h1>
<Island when="visible">
<Counter label="Clicks" />
</Island>
</main>
);
}The wrapper remains a <div> with the existing data-zfb-island* boundary attributes. A normal island carries data-zfb-island; a skip-SSR island carries data-zfb-island-skip-ssr. The wrapper also carries its scheduling value, data-zfb-transport="json/1", data-zfb-protocol="zudo-react/1", a nonempty data-zfb-build identity, and data-props even when the props are {}. The client checks this metadata before it runs component setup.
Island is an SDK boundary helper. The runtime itself exposes a branded description and child model from its core entry:
import type {
Child,
Component,
Description,
ElementType,
} from "zfb/zudo-react";Description exposes type, props, and key with a $$zudo brand. Its factories copy props and do not run the component. Child is a synchronous scalar, description, reactive scalar, or nested array of child values. Copying a description with ordinary object spread is supported; constructing an unbranded lookalike is not.
The island boundary
After empty JSX children are flattened away, the wrapper accepts exactly one description whose type is a registered synchronous function component. A raw string, host element, multiple component descriptions, unregistered function, or nested island boundary fails during server rendering. The child component cannot receive nonempty props.children, because children do not cross the island boundary implicitly. Null, undefined, booleans, and recursively empty arrays count as empty and are omitted.
The scanner's marker name is the defining function's own name (type.name); the scanner does not read displayName, and import or re-export aliases do not change the marker. At server render time, displayName must be absent or equal to that function name. A different value fails with ZR_ISLAND_IDENTITY instead of renaming the target. Anonymous and unregistered names also fail rather than guessing which client entry to load. The client verifies the same component name and nonempty build identity from the wrapper before setup. For the static discovery rules, see the island guide.
The build identity and registered names come from metadata that zfb's server bundle sets. To render Island with another bundler or plain Node, see Rendering Islands outside a zfb build.
Only JSON data crosses in data-props. The top-level value must be a plain record; ordinary and null-prototype records are accepted. Nested values may be null, booleans, strings, finite numbers, dense arrays, and plain records. Own enumerable string data properties on records whose value is undefined are omitted recursively. The same normalized props go to the server component and the JSON transport, so an omitted optional key is absent after parsing and during hydration. This applies to records inside arrays too; it does not remove or shift array elements. Repeated references serialize by value. Getters and custom toJSON methods are never invoked.
An explicit null remains present as an own property. Undefined array entries and array holes are rejected, as are functions, symbols, bigints, non-finite numbers, cycles, class instances, dates, maps, sets, DOM nodes, promises, descriptions, and signals. The serializer also rejects accessors, non-enumerable or symbol properties, and the own keys __proto__, prototype, and constructor. Nested properties named children are ordinary JSON data; only the wrapper's top-level empty children is omitted. A bad server payload fails the page render; an invalid client payload fails that island alone.
The wrapper element and the data-zfb-island* names stay stable for selectors and persistence. Hydration comment regions are emitted only inside the island subtree. A hand-authored persisted wrapper remains supported when it includes the complete marker, scheduling, transport, protocol, build, and props metadata, and its inner HTML was rendered with renderToString(child, { island: identity }). Partial legacy wrappers do not receive inferred metadata. The existing data-zfb-transition-persist and data-zfb-transition-persist-props attributes remain available on that wrapper.
Hydration strategies
The when prop accepts four values. The default is "load", and the emitted wrapper always has a data-when value.
| Value | Behaviour |
|---|---|
"load" | Adopt the server DOM as soon as the client bundle runs. |
"visible" | Adopt when the wrapper enters the viewport. |
"idle" | Adopt during the browser's next idle callback. |
"media" | Adopt when the required media query first matches. |
For media scheduling, pass both the strategy and a nonempty query:
<Island when="media" media="(max-width: 768px)">
<MobileMenu />
</Island>The browser can cancel a pending trigger if the root is removed. If a required scheduling API is unavailable, the runtime uses its immediate fallback. The same scheduling rules apply to skip-SSR islands.
Skip server rendering
Pass ssrFallback when the child must not run on the server. The server checks its identity and props, then renders the fallback without hydration markers. When the client trigger fires, mount builds the child off-DOM and replaces the fallback only after setup and validation succeed:
import { Island } from "zfb";
import HeavyChart from "./HeavyChart";
export default function Page() {
return (
<Island ssrFallback={<div>Loading chart…</div>} when="visible">
<HeavyChart data={chartData} />
</Island>
);
}If setup fails or activation is cancelled, the fallback stays in place.
Persisting an island across navigation
Pass persist to give the island wrapper a stable key. Use the same nonempty, trimmed key on each page where the island should survive client-router navigation. Keys must be unique within each document. The SDK emits the key on its owned wrapper; it does not expose the reserved transport or identity attributes as caller props.
<Island persist="desktop-sidebar" persistProps>
<SidebarTree {...props} />
</Island>By default, changed props dispose the old root once and mount a fresh root with the incoming props. Identical props keep the current root and its state. Set persistProps to true to retain the old props and state even when the incoming page has different props. persistProps defaults to false and requires persist. A changed component, build, transport, protocol, or root kind still recreates the root.
If a persisted island has not mounted yet, changed props keep its first mount deferred according to when. When the condition fires, it renders the latest props over the retained DOM. A previously mounted island remounts immediately.
Mount state and persisted roots
The runtime writes data-zfb-island-mounted only after a root is mounted successfully. The marker is observational; the live handle stored on the wrapper is the duplicate-mount guard. It does not certify that arbitrary asynchronous work started by the component has finished.
dispose() releases signals, effects, and listeners but leaves the child DOM in place. unmount() disposes and removes the nodes owned by that root while retaining the wrapper. During navigation, an unchanged persisted island keeps its live root. A changed component identity or refreshed props disposes the old root and invokes render, never hydrate, for the replacement.
The "use client" directive
As an alternative to wrapping a component at a call site, mark its .tsx file with a first-statement "use client" directive. The build scanner registers that component as an island entry.
"use client";
import { signal } from "zfb/zudo-react";
export default function Counter() {
const count = signal(0);
return (
<button on:click={() => { count.value += 1; }}>
Count: {count}
</button>
);
}The generated client manifest receives the checked JSON props and wrapper. Its mount function selects "hydrate" to adopt existing child DOM or "render" to create a new subtree. A failed preflight returns null and leaves the server DOM inert. It does not fall back to a destructive client render.
For the broader story on island boundaries and when to use them, see Islands. For the runtime component model, start with the zudo-react overview.
Footgun: do not self-wrap an island
Do not place an <Island> boundary inside the component that already owns the island. Nested roots are rejected because two independent owners cannot safely adopt the same DOM subtree.
// Page.tsx — place the boundary at the call site.
<Island when="idle">
<Counter />
</Island>