Islands
Activate a reachable Island boundary targeting a use-client export; adopt server-rendered DOM in the browser.
zfb pages render to static HTML by default. Islands are the escape hatch — small components that ship JavaScript to the browser and hydrate on the client, while the rest of the page stays as plain HTML.
The mental model is straightforward: most of your page is a static document. A few interactive bits — a counter, a search box, a theme toggle — are islands embedded inside that document, hydrated in the browser from a single shared bundle.
What an island is
Add the "use client" directive at the top of a .tsx file:
"use client";
import { signal } from "@takazudo/zfb/zudo-react";
export default function Counter() {
const count = signal(0);
return (
<button on:click={() => { count.value += 1; }}>
Count: {count}
</button>
);
}"use client" makes a module's exports eligible as island targets. A function is registered for hydration only when a reachable page uses it as the single component child of the SDK's <Island> boundary. A client module may re-export a function defined elsewhere; imports and resources used by a registered function still reach the browser through the normal bundle graph.
Boundary discovery and migration
Only concrete targets of reachable <Island> boundaries receive public markers. Exporting a function from a client module, capitalizing its name, or returning JSX does not register it by itself. Unused helpers can keep the same name in multiple modules; they do not need renaming.
The scanner follows named/default imports, local aliases, namespace members, explicit and star re-exports, and installed packages. It recognizes JSX children and children attributes, direct calls such as Island({ children: h(Counter, props) }), and the owned h, jsx, jsxs, and jsxDEV factories, including their aliased packed output. Direct fixed-target and unchanged-child forwarding wrappers are supported when their behavior can be resolved statically. For component maps, keep the documented Fragment-wrapped fixed-target shape; bare-return wrappers stored in opaque maps are rejected. A bounded factory-member target is also supported when a read-only parameter has a closed set of direct call sites that pass object literals, including safe one-level spreads from same-module const object literals, and all select the same function definition. A wrapper's own name is not registered. Only boundary targets occupy registry entries: a helper used by a live target and its imports stay in the bundle, while unused helpers do not need unique names.
The scanner also resolves edges registered by plugin setup: exact addAlias rewrites and addVirtualModule specifiers. A tsconfig.json compilerOptions.paths mapping takes priority when it resolves a matching specifier; if every mapped target misses, lookup falls through to the exact plugin alias, then a registered virtual module, followed by baseUrl and package lookup.
A virtual module can statically import or re-export a real module, and the scanner follows that file. The module carrying "use client" must be a real file, though it may re-export a function defined in another real file. The exported function still needs to be the one child of a reachable <Island> boundary; the directive by itself does not register a target. A "use client" directive or import.meta.glob directly inside a virtual source is warned about and skipped. Put those constructs in real files and reach them through the virtual module. Bare imports from inside a virtual source that resolve to an installed or workspace package, or to another virtual module, are also skipped. See Plugins for the setup registration details.
For a target, export the function from a module with a "use client" directive and pass that function as the boundary's one child. A fresh consumer imports Island from @takazudo/zfb and zudo-react helpers from @takazudo/. Set jsxImportSource to @takazudo/ so JSX uses the owned @takazudo/ and @takazudo/ entrypoints. The existing bare alias may be used when it resolves to these package entrypoints; see issue #3932:
import { Island } from "@takazudo/zfb";
import { Counter } from "./counter";
<Island><Counter /></Island>;Dynamic child selection is rejected during a production build. Put the condition around separate concrete boundaries instead of choosing one function inside a boundary:
{showCounter
? <Island><Counter /></Island>
: <Island><Summary /></Island>}Factory-provided targets
A factory can receive a component through a plain object parameter and use it as an island target when zfb can prove the parameter and every direct call site. This is a syntactic proof: zfb never executes the factory to discover a component. The existing binding forms remain supported:
| Binding form | Accepted spelling |
|---|---|
| Member access | const Target = deps.Counter or const Target = deps["Counter"] |
| Object pattern | const { Counter: Target } = deps or const { Counter } = deps |
| Parameter destructuring | function render({ Counter: Target }) or function render({ Counter }) |
The member parameter must be a plain identifier, not a rest parameter, and it must have no default value. TypeScript-only wrappers around the member expression are unwrapped. Parameter destructuring may rename a property or use shorthand, but cannot use rest, defaults, or nested patterns. The parameter may only be read as the object of a static member access or as the initializer of a const object pattern. A write, delete, alias, pass, return, spread, or any other use of the parameter (including inside a nested closure) fails the proof. A destructured target is never assigned. let and var targets keep the mutable target binding diagnostic. Other reads of Target—including !Target, typeof Target, or passing it to a non-Island helper—do not affect registration; only its use as an <Island> child matters.
The function that owns the parameter must be a named function or a const function/arrow binding. Every reference to that function in the scanned module graph, including import and re-export chains, must be the callee of a direct call: renderPanel(...), (0, renderPanel)(...), or namespace.renderPanel(...). Passing it as a value, storing or returning it, binding it, using .call, .bind, or .apply, or using it as JSX is an escape. The proof covers the scanner's discovered module graph; it cannot account for runtime-generated or otherwise undiscovered callers.
The call-site set must be non-empty. Each call must pass the parameter at its declared position with no positional spread at or before that position. The outer argument must remain an object literal. For its selected target, every call site must resolve to the same function definition. A function passed as renderPanel(deps) is not made static just because deps is a local constant.
For example, a target can be read through the existing member form while the caller supplies a local static spread:
import { Island } from "@takazudo/zfb";
import { DesignTokenPanelBootstrap } from "./design-token-panel-bootstrap";
function renderDesignTokenPanel(deps) {
const Target = deps.DesignTokenPanelBootstrap;
return <Island when="load"><Target /></Island>;
}
const defaults = { DesignTokenPanelBootstrap };
export default function Page() {
return renderDesignTokenPanel({ ...defaults, enabled: true });
}Static local dependency spreads
The object passed at the factory call may use a one-level spread when each spread operand is an identifier (with existing TypeScript-only wrappers and parentheses unwrapped) that resolves by binding identity to a const object literal in the same module. Function-local constants are allowed. The source initializer itself has no spread. This is a bounded syntax proof, not recursive object evaluation: createBoundary({ ...defaults }) can be accepted, while createBoundary(deps) stays rejected even when deps is a local constant.
The argument and each spread source contain only shorthand or ordinary key/value data properties with static, non-computed keys. Numeric and bigint keys keep their existing handling. Nested object values for unrelated settings are data; they are not recursively expanded. Getters, setters, methods, computed keys, nested source spreads, and unsupported spreads reject the whole argument even if a later property would hide them. A top-level __proto__ key is rejected in the argument or any source, including quoted and shorthand spellings.
The top-level __proto__ spelling is excluded because __proto__: value sets a prototype. Shorthand is excluded too, even though it can create an own data property; this proof does not infer inherited properties, invoke getters, or model prototype construction. The existing initializer reason 8 remains in force.
A spread source is safe only when its binding has no assignment, update, delete, member mutation, alias, destructuring read, member read, call argument, return, export, JSX spread, array/positional spread, container storage, or other value escape. Its only value references may be operands of object-literal spreads. This also applies inside nested closures. Even a write that appears textually after a call rejects the source; this is not a control-flow or call-order proof. Repeated safe spreads are allowed; the proof does not reuse the factory parameter's static-member-read exception for a spread source.
Traverse the outer argument from left to right. A collision across spreads, or between a spread and an explicit property, is allowed; the last occurrence supplies the effective target. Empty or unrelated spreads do not erase an earlier target. The same source may be spread more than once. Assume Counter and Other are distinct client functions. Each line is a separate successful build, not a set of calls to one factory with different winners:
const defaults = { Counter };
const override = { Counter: Other };
createBoundary({ Counter }); // Direct literal control remains valid
createBoundary({ ...defaults }); // Counter
createBoundary({ ...defaults, Counter: Other }); // Other
createBoundary({ Counter: Other, ...defaults }); // Counter
createBoundary({ ...defaults, ...override }); // Other
createBoundary({ ...override, ...defaults }); // Counter
createBoundary({ ...defaults, ...defaults }); // CounterA selected key explicitly declared twice in one object literal is rejected, even when the values are identical or a later spread would overwrite it. This rule applies independently to the argument's explicit properties and to each source literal. Duplicate nonselected data keys remain allowed; if a later boundary selects that key, the duplicate makes that proof fail.
Validate the complete argument and source grammar before resolving the winning selected value. Only the final value is resolved and checked for conditional selection. An earlier conditional value overwritten by a proven concrete value is accepted; a winning host.Counter ?? Counter, ternary, or Counter || Other still fails. Unsupported syntax is never hidden by an override, and an overwritten function is not registered by this proof.
Each call to one proven factory must still select the same function definition, not merely the same marker spelling. Aliases and re-exports use the existing resolver; distinct definitions with the same marker still reach the existing marker-collision diagnostic.
The factory keeps the same outer diagnostic prefix, target Target has unsupported initializer:. These are the reason strings for the relevant rejected shapes:
| Rejected shape | Diagnostic reason |
|---|---|
createBoundary(deps), unknown spread source, let source, imported/parameter/aliased source, nested source spread, direct object-expression spread (...{ Counter }), call result, member or conditional operand | call site at <loc> passes a non-literal argument |
Forbidden source reference/write/export, computed key, method, accessor, __proto__ key, or selected duplicate in the argument/source | call site at <loc> passes a non-literal argument |
Safe expansion without selected Counter | property Counter missing at call site <loc> |
Winning host.Counter ?? Counter, ternary, or Counter || Other | call site at <loc> selects Counter conditionally (??, ternary, ||) |
Winning host.Counter from a runtime/opaque object receiver (not a namespace import) | property Counter at <loc> is not a function binding (an existing resolver reason may follow) |
| Two valid calls select different definitions | call sites disagree: <loc> passes Counter, <loc> passes Other |
Forward a factory parameter as inner({ ...deps }) or inner(deps) | Existing parameter deps is written or escapes at <loc> takes precedence when that parameter's proof is demanded; spreads do not add interprocedural forwarding. |
Store a direct-return fixed wrapper as { Panel: BarePanelIsland } or a generic forwarding boundary as { Island: ForwardBoundary } | boundary wrapper escapes into an opaque container |
Store the generic wrapper with shorthand, { ForwardBoundary } | boundary wrapper escapes into an opaque object |
Pass a component function as an Island JSON prop, with both functions valid client exports and PropsReceiver a fixed target | SSR error: ZR_ISLAND_PROPS PropsReceiver: ZR_PROPS_FUNCTION at props.component |
The first applicable factory reason is reported, with source locations and the existing generic rewrite suffix. See Troubleshooting for the full symptom and fix list.
Composition seam for customisable package islands
A package that needs a host override must expose a public composition seam. Keep the default target fixed in one server boundary wrapper, let the host provide its own fixed target wrapper, and make the package's server slot forward already rendered children unchanged. The caller chooses which boundary description to render. Do not send a component function or a rendered server child as an Island JSON prop.
Nested factories, settings gates, and host overrides
Two ordinary nested server factories can keep the feature settings and gate. The composition site owns the host/default selection:
// components/default-panel.tsx
"use client";
import { signal } from "@takazudo/zfb/zudo-react";
export function DefaultPanel() {
const count = signal(0);
return <button on:click={() => { count.value += 1; }}>Default panel: {count}</button>;
}
// components/host-panel.tsx
"use client";
import { signal } from "@takazudo/zfb/zudo-react";
export function HostPanel() {
const count = signal(0);
return <button on:click={() => { count.value += 1; }}>Host panel: {count}</button>;
}
// package-islands.tsx (ordinary server slot and nested settings factories)
export function PanelSlot({ children }) {
return <>{children}</>;
}
export function createBodyEnd(settings) {
return function BodyEnd({ children }) {
if (!settings.enabled) return null;
return <PanelSlot>{children}</PanelSlot>;
};
}
export function createChrome(settings) {
const BodyEnd = createBodyEnd(settings);
return function Chrome({ children }) {
return <BodyEnd>{children}</BodyEnd>;
};
}
// pages/page.tsx (server composition site)
import { Island } from "@takazudo/zfb";
import { DefaultPanel } from "../components/default-panel";
import { HostPanel } from "../components/host-panel";
import { createChrome } from "../package-islands";
function DefaultPanelIsland() {
return <><Island when="load"><DefaultPanel /></Island></>;
}
function HostPanelIsland() {
return <><Island when="load"><HostPanel /></Island></>;
}
const settings = { enabled: true };
const Chrome = createChrome(settings);
const hasHost = true; // A concrete host implementation is installed here.
const suppressDefault = false;
export default function Page() {
return (
<Chrome>
{hasHost
? <HostPanelIsland />
: suppressDefault ? null : <DefaultPanelIsland />}
</Chrome>
);
}When enabled with no host, render DefaultPanelIsland. When the host is installed, it wins and HostPanelIsland renders. When the feature setting is disabled, the nested factory returns null even if a host exists: there is no page marker, mounted attribute, button, activation, or package pre-hydration feature shim. With the feature enabled and default suppression set, no host means no boundary; an installed host still renders because the explicit host override takes precedence over suppression.
The downstream zudo-doc change is a public composition-seam migration: move its host/default choice to a caller-owned server composition site while retaining settings in its nested factories. This is a consumer source/API migration and needs verification in that consumer. This documentation does not claim downstream browser parity.
Fixed-target component maps
For a map-based migration, replace both bare-return fixed wrappers and generic forwarding boundary entries. A bare-return fixed wrapper such as function BarePanelIsland() { return <Island><DefaultPanel /></Island>; } stored in { Panel: BarePanelIsland } is rejected with boundary wrapper escapes into an opaque container. A generic ForwardBoundary stored as { Island: ForwardBoundary } has the same rejection; shorthand { ForwardBoundary } reports boundary wrapper escapes into an opaque object.
Keep each target fixed and use the Fragment-wrapped shape:
// components/panel-components.tsx
import { Island } from "@takazudo/zfb";
import { DefaultPanel } from "./default-panel";
import { HostPanel } from "./host-panel";
function DefaultPanelIsland() {
return <><Island when="load"><DefaultPanel /></Island></>;
}
function HostPanelIsland() {
return <><Island when="load"><HostPanel /></Island></>;
}
export const panelComponents = {
DefaultPanel: DefaultPanelIsland,
HostPanel: HostPanelIsland,
};Import this server-only map into a page and pass it to the MDX entry:
// pages/blog/[slug].tsx
import { panelComponents } from "../../components/panel-components";
<post.Content components={panelComponents} />The map module must be reachable from a page. The Fragment adds no DOM element and leaves the concrete Island visible to the scanner. A plain SSR passthrough such as function IslandWrapper({ children }) { return children ?? null; } is ordinary server rendering; a map key named Island does not make that function an SDK Island boundary. Maps and wrappers stay on the server and are never serialized as island props.
The locked acceptance fixtures cover the complete migration. Active rows have one marker and a working stateful button; suppressed rows have no page marker or activation:
| Fixture | Expected page marker and interaction |
|---|---|
factory-static-spread | FactoryCounter once; counter 0 → 1 |
factory-composition-default | DefaultPanel once; default panel 0 → 1 |
factory-composition-host-override | HostPanel once, no default; host panel 0 → 1 |
factory-composition-settings-disabled | no marker or feature button, mounted attribute, activation, or feature shim |
factory-composition-default-suppressed | no marker or activation when no host is installed |
factory-composition-host-with-default-suppressed | HostPanel once, no default; host panel 0 → 1 |
factory-map-default | DefaultPanel once; default panel 0 → 1 |
factory-map-host-override | HostPanel once, no default; host panel 0 → 1 |
factory-map-settings-disabled | no marker or feature button, mounted attribute, activation, or feature shim |
The negative scanner and SSR fixtures keep these names: factory-spread, factory-nullish, factory-escape, dynamic-child, factory-spread-mutated, factory-spread-nested, factory-spread-proto, factory-function-json-prop, factory-map-opaque, factory-map-forwarding-opaque, factory-map-opaque-shorthand. The first four preserve existing rejection controls; the new spread and map cases cover mutation, nested spread, prototype keys, function JSON props, bare-return wrappers, generic forwarding, and shorthand storage. The JSON-prop case fails during SSR after scanner success.
A statically discovered target can remain in the build-wide registry when settings disable it. Page marker assertions describe the rendered route; they do not assert that the registry omits a statically discovered target. Keep the existing root and / route checks when adding these cases.
If a collision diagnostic identifies two different target functions, rename one defining function, or fix an import or package export that resolves to the wrong definition. Import and re-export aliases do not change the marker: the scanner uses the defining function's own name, not displayName. At server render time, displayName must be absent or equal to that name; otherwise zfb fails with ZR_ISLAND_IDENTITY instead of renaming the target. For a default target that needs a stable name, use export default function Counter() { ... }.
Since v3.2.0, two definitions in the same package, or a package file and a byte-identical staged copy of it, are also distinct targets and fail with this diagnostic. See Same-package duplicate island definitions for the migration.
components/ in the bundled basic-blog template is the canonical real-world example — and the template's only island. It reads localStorage and matchMedia, manages its own state with a signal, and mirrors the active theme to document.documentElement.dataset.theme. The setup starts from a deterministic value for both server rendering and client adoption; browser-only reads happen after the DOM has been committed. This shortened illustration shows the pattern; the shipped template also uses Show for its icon and includes utility classes and SVG markup:
"use client";
import { computed, getScope, signal } from "@takazudo/zfb/zudo-react";
type Theme = "light" | "dark";
export default function ThemeToggle() {
const theme = signal<Theme>("light");
const scope = getScope();
const isDark = computed(() => theme.value === "dark");
const next = computed(() => isDark.value ? "light" : "dark");
const ariaLabel = computed(() => `Switch to ${next.value} mode`);
scope.onActivate(() => {
try {
const saved = window.localStorage.getItem("theme");
if (saved === "light" || saved === "dark") {
theme.value = saved;
return;
}
} catch {
// Storage may be unavailable; use the system preference.
}
if (typeof window.matchMedia === "function") {
theme.value = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
});
scope.effect(() => {
const activeTheme = theme.value;
document.documentElement.dataset.theme = activeTheme;
try {
window.localStorage.setItem("theme", activeTheme);
} catch {
// The document theme still updates when storage is unavailable.
}
});
return (
<button
type="button"
aria-pressed={isDark}
aria-label={ariaLabel}
on:click={() => { theme.value = next.value; }}
>
{next} mode
</button>
);
}What the bundler produces for N islands
For a project with three islands (Counter, ThemeToggle, SearchBox), the islands build step emits one shared bundle:
dist/assets/islands.js All islands are statically imported into a single bundler entry point (Rolldown by default, or esbuild with ZFB_BUNDLER=esbuild), so the shared bundle contains every island component. ProductionAssetPipeline then renames it to a content-hashed filename before writing the final output:
dist/assets/islands-<hash>.js If any island uses a dynamic import() internally, the bundler may also emit code-split chunks alongside it:
dist/assets/islands-chunk-<hash>.js The bundle registers each island by its static marker name and calls mountIslands() at the end of the module. The runtime checks each wrapper's identity and schedules the corresponding client entry to adopt its child DOM.
The ProductionAssetPipeline is the single source of truth for hashing — the bundler writes a stable islands.js first, and the pipeline performs the hash-rename and rewrites the injected script URL in the emitted HTML. Nothing downstream has to guess the filename.
How islands are loaded
After render, each island's server-rendered HTML is wrapped in a <div> that carries its component identity and transport metadata. The wrapper and data-zfb-island* boundary attributes remain part of zfb's island contract; runtime comment regions are confined to the child subtree:
<div data-zfb-island="ThemeToggle"
data-when="load"
data-zfb-transport="json/1"
data-zfb-protocol="zudo-react/1"
data-zfb-build="build-identity"
data-props="{}">
<!-- zudo-react's child-subtree markers live here -->
<button type="button" aria-pressed="false" aria-label="Switch to dark mode">Dark mode</button>
</div>The data-when attribute controls when the client entry runs. It is always present and defaults to "load"; the other strategies are "visible", "idle", and "media". The JSX API is the <Island> wrapper; for media-query adoption, pass both when="media" and a media query:
import { Island } from "@takazudo/zfb";
import MobileMenu from "./MobileMenu";
export default function Header() {
return (
<Island when="media" media="(max-width: 768px)">
<MobileMenu />
</Island>
);
}The "visible", "idle", and "media" strategies each emit a data-when attribute; "media" also emits a companion data-media attribute carrying the CSS query string:
<!-- visible timing -->
<div data-zfb-island="SearchBox" data-when="visible" data-zfb-transport="json/1" data-zfb-protocol="zudo-react/1" data-zfb-build="build-identity" data-props="{}">…</div>
<!-- idle timing -->
<div data-zfb-island="Counter" data-when="idle" data-zfb-transport="json/1" data-zfb-protocol="zudo-react/1" data-zfb-build="build-identity" data-props="{}">…</div>
<!-- media timing — hydrates when (max-width: 768px) first matches -->
<div data-zfb-island="MobileMenu" data-when="media" data-media="(max-width: 768px)" data-zfb-transport="json/1" data-zfb-protocol="zudo-react/1" data-zfb-build="build-identity" data-props="{}">…</div>On builds that include at least one island, one <script> tag is injected project-wide into <head>:
<script type="module" src="/assets/islands-<hash>.js"></script>The shared islands bundle (islands-<hash>.js) contains registered island targets and their required bundle dependencies. It registers each target by the static marker name the scanner discovered (data-zfb-island attribute value), then calls mountIslands(), which checks wrapper metadata, reads the JSON data-props, and adopts the existing child DOM when the scheduled entry runs.
Observing mount state
The runtime exposes a post-mount marker, data-zfb-island-mounted, on both kinds of island element: the hydrated data-zfb-island marker and the SSR-skip data-zfb-island-skip-ssr marker. It appears only after the generated mount() function returns. A delayed island therefore has no mounted marker while its data-when condition is waiting, so a consumer can gate its pre-hydration styling with the selector it would write in CSS:
[data-zfb-island]:not([data-zfb-island-mounted]) {
opacity: 0.7;
}The marker means "the client root was successfully mounted". It is observational; the live root handle is the runtime's duplicate-mount guard. A mounted marker does not mean that later asynchronous application work has completed.
The marker is removed when an island is unmounted. During the real client-router body-swap sequence, an unchanged persisted island keeps its marker and mounted instance when its data-zfb-transition-persist id is present in the incoming body; a discarded island loses its marker and is mounted again from the incoming markup. If a persisted island's props change, the runtime removes the marker while it tears down the old instance and adds it again after the forced remount returns. A fresh runtime module also strips a stale marker from an element that it has not mounted before, then writes its own marker after mounting.
Consequences of the shared-bundle model
Because all islands are bundled together into a single file:
Every page with any island loads all islands' code. The single
islands-<hash>.jsbundle contains every island component in the project. A page that only usesThemeTogglestill downloads the code forCounterandSearchBox. This trades per-page load granularity for a simpler build pipeline and better cache efficiency across pages.Pages with no islands get no islands script. If a page contains no island markers, the build pipeline skips the shared islands
<script>injection for that page. Other client-script mechanisms, if used, can still add JavaScript.Any island change rehashes the single bundle. Adding or modifying any island component produces a new content hash, which invalidates the browser's cached bundle for all pages. The tradeoff is that one cache entry covers all islands — once the bundle is cached, every page in the project benefits from the same cache hit.
Measured shared-bundle baseline
The controlled v3 fixture study found no JavaScript reduction; it preserves the current bundle as a regression baseline. It exercised eight real consumer pages in workspace and packed-package resolution on Darwin arm64 and Linux x64: no island, event-only, scalar signal, Show/For, modelValue, blog menu plus theme toggle, JSON API, and a two-island page. Each platform's contract has zero raw and gzip allowance.
| Fixture | Darwin workspace raw / gzip | Darwin packed raw / gzip | Linux workspace raw / gzip | Linux packed raw / gzip |
|---|---|---|---|---|
| no-island | 0 / 0 | 0 / 0 | 0 / 0 | 0 / 0 |
| event-only | 49,830 / 17,004 | 49,892 / 17,027 | 49,830 / 17,003 | 49,892 / 17,030 |
| scalar-signal | 49,838 / 17,009 | 49,900 / 17,037 | 49,838 / 17,005 | 49,900 / 17,036 |
| Show/For | 49,985 / 17,082 | 50,047 / 17,111 | 49,985 / 17,081 | 50,047 / 17,111 |
| modelValue | 49,829 / 17,012 | 49,891 / 17,045 | 49,829 / 17,014 | 49,891 / 17,046 |
| blog menu plus theme toggle | 50,180 / 17,138 | 50,242 / 17,166 | 50,180 / 17,137 | 50,242 / 17,167 |
| JSON API | 49,984 / 17,096 | 50,046 / 17,127 | 49,984 / 17,098 | 50,046 / 17,123 |
| two islands | 50,106 / 17,103 | 50,168 / 17,132 | 50,106 / 17,098 | 50,168 / 17,134 |
Each platform and mode built every fixture twice and compared the complete emitted dist inventory. The recorded non-empty fixtures each produced one entry and no later chunk; the size contract still counts every emitted entry, shared, and lazy JavaScript file if splitting produces them. Raw size is the sum of file bytes. Gzip is computed separately for each file with node:zlib gzipSync at level 9 and mtime 0, then summed; HTTP transfer compression may differ.
The study pinned @takazudo/zfb 3.0.0, Node v24.14.0, zlib 1.3.1-e00f703, esbuild 0.25.12, and lockfile Hono 4.12.25. The Darwin baseline source was 897f288c013e24ff4fde01483a55d8a1945fc964. The Linux x64 probe ran in GitHub Actions health run 36831966429 at pull-request merge ref afa523768b3de897859521e1bf940179191f50ab (PR head 99726c74a48df1a0658cd6d9f5c494d06d4f2d40), not at the historical Darwin commit. Linux raw totals match Darwin, but gzip totals differ and every real island JavaScript file has a different SHA-256. This is a pair of platform-specific size baselines, not a claim of byte-identical output or semantic equivalence across platforms. The Linux result covers one pinned GitHub Actions run on x64; it does not establish behavior for other Linux architectures or future toolchains.
The live contracts have moved on since the study. They now pin @takazudo/zfb 4.5.4 with the same Node, zlib, esbuild and Hono versions, and their exact ceilings are in research/ (Darwin arm64) and decision-linux-x64.json (Linux x64). For example, the one-island event-only page is capped at 64,535 raw / 22,195 gzip bytes in workspace mode on both platforms. The allowance is still zero.
A contract changes only through a reviewed transition. When Cargo.lock changes in a way the contracts have not admitted, the island-size check in health.yml fails until fresh native evidence is collected and the affected contracts are re-pinned (--stage, then --apply). Linux x64 evidence comes from a GitHub Actions artifact. Darwin arm64 evidence comes from the native island-size-darwin.yml workflow, or from an owner-attested run on a local Mac that scripts/ accepts for two reviewed reasons only: internal Cargo rewires and the rustls security lock transition. Neither route changes a ceiling or the allowance by itself.
Use the repository's measurement and replay commands to reproduce the two real matrices. The guard selects decision.json on Darwin arm64 and decision-linux-x64.json on Linux x64, and rejects unsupported hosts or platform-mismatched artifacts. After both runs, check the saved files against the selected contract:
node research/v3-island-size/check-budget.mjs --workspace "$ZFB_SIZE_OUT/real-workspace/measurement.json" --packed "$ZFB_SIZE_OUT/real-packed/measurement.json"One runtime
Every island uses zudo-react, the runtime shipped with @takazudo/zfb. Its components create signals during setup, and the client adopts the island's existing child DOM after checking the wrapper identity and strict JSON props. There is no per-project framework setting or compatibility alias. Start with the zudo-react overview for its JSX dialect and component model.
For the server/client boundary details, see The zudo-react runtime.
Embedding a third-party widget
A self-mounting widget can own an empty element inside a zudo-react island. Its own framework (including Preact or React) stays in the widget's bundle from node_modules; install the framework as the widget library's peer dependency. The v3 restriction on Preact/React JSX runtime imports applies to components rendered by zudo-react, not to an opaque widget mounted inside its own host. Do not pass the widget's VNodes to zudo-react or let it mutate a region containing zudo-react children.
This example uses a hypothetical adapter module. Its mount(host) returns an instance with a synchronous destroy() method. Replace these names with the library's actual mount and teardown APIs. The adapter must roll back any listeners, timers, or other resources it allocates if mounting throws before it returns an instance; clearing the host only removes DOM nodes.
"use client";
import { getScope, h, signal, type Ref } from "@takazudo/zfb/zudo-react";
type WidgetInstance = { destroy(): void };
type WidgetModule = { mount(host: Element): WidgetInstance };
type WidgetLoader = () => Promise<WidgetModule>;
const loadWidget: WidgetLoader = () => import("./widget-adapter");
export function ThirdPartyWidget() {
const host: Ref<Element> = { current: null };
const status = signal("Loading widget…");
const scope = getScope();
const abortSignal = scope.abortSignal;
scope.onActivate(() => {
let instance: WidgetInstance | undefined;
void loadWidget().then((widget) => {
if (abortSignal.aborted) return;
const element = host.current;
if (!element) return;
try {
instance = widget.mount(element);
status.value = "";
} catch {
element.replaceChildren();
status.value = "Widget unavailable.";
}
}).catch(() => {
if (!abortSignal.aborted) status.value = "Widget unavailable.";
});
return () => {
const mounted = instance;
instance = undefined;
try {
mounted?.destroy();
} finally {
host.current?.replaceChildren();
}
};
});
return h("section", null,
h("div", { ref: host }),
h("p", { role: "status", children: status }),
);
}// widget-adapter.ts — illustrative library-specific adapter
import * as thirdParty from "some-widget";
export function mount(host: Element) {
const onResize = () => { /* update adapter-owned measurements */ };
window.addEventListener("resize", onResize);
const timer = window.setInterval(() => { /* adapter-owned polling */ }, 1000);
let released = false;
const release = () => {
if (released) return;
released = true;
window.removeEventListener("resize", onResize);
window.clearInterval(timer);
};
try {
const widget = thirdParty.mount(host); // The library must roll back its own partial work on failure.
return { destroy() { try { widget.destroy(); } finally { release(); } } };
} catch (error) {
release(); // Roll back allocations made before mount returned.
throw error;
}
}onActivate itself must stay synchronous: onActivate(async () => ...) returns a promise and activation rejects it. Start import() inside the callback, never at module top level where SSR could execute it. The abort check handles navigation while the import is pending. Return cleanup synchronously from activation (or register it during setup); calling setup-only onCleanup after an await fails. The adapter's mount must either return a complete instance or undo its own partial work before throwing. The island destroys a returned instance exactly once on disposal.
Browser bundle mode and configuration
Island code uses the same compile-time mode values as client scripts, module workers, and the page/SSR bundle:
| Command | import.meta.env.DEV | import.meta.env.PROD | process.env.NODE_ENV |
|---|---|---|---|
zfb dev | true | false | "development" |
zfb build | false | true | "production" |
The mode is independent of minification. Additional bundle.loaders and bundle.define settings are also shared by islands and the workers they start. Define values are raw, public code substitutions, so they must never contain secrets.
TypeScript does not infer these bundler contracts. Add the matching ambient declarations so mode values, custom-loader imports, raw imports, and define names in island code pass zfb check and tsc.
Importing text with ?raw
An island or a first-party module in its import graph can load any project-local file as text with one exact form:
"use client";
import shaderSource from "./shaders/noise.frag?raw";The target's extension does not matter and bundle.loaders does not change the result. The target is read as valid UTF-8, default-exported as a string, and treated as a terminal dependency — a .js?raw file is text, not code. Only a static default import with a literal relative specifier and the exact ?raw suffix is supported. Named, namespace, side-effect, type-only, dynamic, and re-export forms, other queries, extra parameters, non-literal paths, and targets outside the project root fail with a message that names the supported form.
During zfb dev, raw targets in island and client-script graphs are added to their live watch sets. Editing, deleting, or recreating the text file therefore invalidates the browser bundle even when its importer is unchanged. The same text transform works in the page/SSR pass, where raw edges also participate in route dependency tracking, but that does not add new filesystem watch roots. An SSR-only raw target outside the default watched roots (for example, under lib/) must be covered by an absolute extraWatchPaths, or its edits will not produce a dev filesystem event.
Module workers
Use the literal module-worker constructor that zfb can discover before the bundler runs:
"use client";
const searchWorker = new Worker(
new URL("./workers/search.worker.ts", import.meta.url),
{ type: "module" },
);The first URL argument must be a string literal naming an exact, project-local relative JS/TS file with no query or fragment, and the worker options must select type: "module". zfb leaves worker sources out of the server graph, bundles each worker as a self-contained browser entry, and rewrites the URL to:
./worker-<encoded-project-relative-path>.js?v=<graph-hash>Island-owned workers are emitted flat under / (with the configured base prefix, if any). The stable filename is a reversible encoding of the complete project-relative source path: separators become -s-, dots -d-, literal hyphens -h-, and other bytes -xHH-. For example, src/ becomes worker-src-s-search-s-index-d-worker-d-ts.js. The ?v= query is exactly eight lowercase hexadecimal characters and changes when the first-party graph or output-affecting bundle/resolver inputs change, including transitive imports, nested workers, raw files, and relevant TypeScript configuration. Dev watches that closure and removes stale companions after worker edges disappear.
The predictable prefix can anchor a per-worker response policy. For example, a Cloudflare-style public/_headers file can start with:
/assets/worker-*.js
Content-Security-Policy: default-src 'none'; script-src 'self'; connect-src 'self'Tailor the directives to what your worker actually accesses. A path-style base prefixes the _headers route: for example, base: "/docs/" needs /. An absolute-URL base such as https: instead needs the equivalent / header rule on that asset origin; the site's _headers file cannot configure another origin. Workers owned by client scripts live in the separate / directory and need a corresponding rule under the same base/origin policy.
The pre-pass does not traverse installed node_modules; third-party transitive worker constructors are left to their package tooling. A SharedWorker using the same literal new URL(..., import.meta.url) shape is a named hard error. Other non-literal or non-module constructor shapes are not part of this contract and should not be expected to be rewritten.
import.meta.glob support
zfb supports Vite's import.meta.glob(...) macro — importing every file matching a pattern in one call, handy for auto-registering a directory of sub-components or data files. Support is intentionally narrow: zfb expands this Vite-only macro Rust-side, at build time, before the islands bundler ever sees the file, so the result does not depend on which bundler runs. Only the one shape that expansion understands is accepted; anything else is a build error rather than a silent miscompile.
Supported form
"use client";
// Eager + string-literal pattern, anchored at this file's own directory.
const pages = import.meta.glob("./items/*.tsx", { eager: true });This expands, at build time, into a plain object literal mapping each matched relative path to that module's namespace object — the same shape Vite produces:
{
"./items/a.tsx": /* module namespace */,
"./items/b.tsx": /* module namespace */,
}Requirements for the supported form:
The second argument must be exactly
{ eager: true }— Vite's default (no second argument) is the lazy form, which is not supported here.The pattern must be a string literal — a variable, a template literal, or an array of patterns is not supported.
The pattern must resolve under the importing file's own directory — a
.-escaping pattern is rejected outright.. / No
import,query, orasoptions — only the bare{ eager: true }object is recognised; any other key fails the build.
Where it works
| Location | Supported |
|---|---|
A server-only page, layout, or component (never reachable from a "use client" island) | Yes — always has been, via the general SSR shadow-copy bundler |
A "use client" island's own file | Yes, since issue #1404 |
A module transitively imported from a "use client" island | Yes, since #1404, same restrictions as above, when the module is part of the island import graph and is written as a real expanded shadow-copy file |
| A file reachable only because it is matched by another island-reachable glob (a raw-mirrored glob target or subtree companion) | No — see "Unsupported forms" below |
A *.client.{ts,tsx,js,jsx} client script (or anything it imports) | No — see "Client scripts" below |
Before #1404, any import.meta.glob reachable from an island — even the supported eager + string-literal form — was a hard build error (a stopgap from issue #1387). The islands build now materialises a temporary shadow copy of the island-reachable source tree with each supported glob call already expanded, the same trick the SSR bundler has used for server-only modules since issues #665/#670.
There is one important boundary: the shadow also raw-mirrors files under each island-reachable glob module's directory so matched target files exist for the bundler, but those raw-mirrored target/subtree files are not recursively expanded. If one of those files contains its own import.meta.glob(...), zfb rejects it instead of shipping an unexpanded Vite-only macro to the browser. If the same file is also imported from the island graph in the normal way, it is a real expanded shadow copy and its supported glob keeps working.
Unsupported forms fail the build (or, in dev, warn and skip)
Any of the following fails zfb build with a message naming the offending file. During zfb dev the same message is logged as a warning and that rebundle tick is skipped, so the dev server stays up while you fix the file and save again:
the default lazy form —
import.with no optionsmeta. glob(". / items/ *. tsx") { eager: false }a non-literal pattern (a variable, a template literal, a computed expression)
the
import,query, orasoptionsa pattern that escapes the file's own directory (
.). / a glob module that lives outside the project root, or under
node_modules, while still being reachable from an island (nothing to mirror into the shadow copy)an
import.meta.glob(...)call inside a JS-like file that is reachable only as a raw-mirrored glob target or subtree companion
The raw-mirrored check is intentionally conservative. An unused JS-like sibling (.js, .jsx, .ts, .tsx, .mjs, .cjs, .mts, or .cts) under a globbed subtree is still flagged if it contains a real glob call, because the shadow cannot prove whether the bundler will read that raw file later. Hoist the glob into an island-reachable module, move it out of the globbed subtree, or replace it with explicit static imports.
Client scripts: not supported, and not caught at build time
import.meta.glob is not supported in client scripts (*.client.{ts,tsx,js,jsx}, or any module they import). A graph that needs ?raw, module-worker, or plugin preprocessing may use a temporary mirror, but that stage deliberately does not expand globs. The call therefore reaches the browser semantically unexpanded. Unlike the island case above, this is not caught at build time: zfb build succeeds, and the browser throws when the script actually runs, because import.meta.glob is undefined outside of Vite. If you need a glob-like file list inside a client script, use one of the alternatives below.
Alternatives
A plugin virtual module — compute the file list yourself in Node (e.g. with
node:fsor a glob library) inside a plugin'ssetuphook, and expose it viaaddVirtualModule. See Plugins for the full hook contract and a worked virtual-module example.Explicit static imports — write the
importstatements out by hand, or generate a real.ts/.tsxfile with a small pre-build script that zfb then bundles normally.
Escape hatches for non-island client JS
Islands cover stateful UI components. For other client-side JavaScript needs, use the standard HTML mechanisms directly.
Inline scripts — write a <script> tag directly in your page TSX or layout:
export default function Layout({ children }) {
return (
<html>
<head>
<script rawHtml={`document.documentElement.dataset.theme = localStorage.getItem('theme') ?? 'light';`} />
</head>
<body>{children}</body>
</html>
);
}This is the right tool for synchronous pre-hydration work that must run before the stylesheet parses (FOUC prevention, theme init, analytics setup).
External scripts — reference any .js file from public/ or a CDN:
Standard script attributes such as defer, async, and nonce are supported directly in JSX. Keep external scripts as ordinary elements; rawHtml is for trusted inline script text.
<script src="/scripts/analytics.js" defer />
<script src="https://cdn.example.com/lib.js" defer />Client scripts or custom build steps — zfb automatically bundles *.client.* entries as a second browser-entry pipeline. For any other TypeScript module, add a separate esbuild, Rolldown or Rollup step and reference its output from a <script src>. Modules that are not reachable from a "use client" graph or a .client.* entry remain server-only.
What you cannot do: import a regular (non-"use client") .ts or .tsx module from a page and expect its browser-side code to reach the client. Modules without the directive are server-only — SWC compiles and evaluates them at build time, and none of their bytes are included in the output.
When not to use islands
Islands ship JavaScript. That has a cost. Before adding one, ask whether you can solve the problem without it.
DOM class-swap toggles — accordions, disclosure menus, show/hide panels — are often solved with a few lines of plain CSS or a small inline <script>. The native <details> / <summary> element handles accordion behaviour with no JavaScript at all:
<details>
<summary>Frequently asked question</summary>
<p>The answer goes here.</p>
</details>CSS-only approaches (:target, :checked + <label>, @starting-style) handle many interactive patterns that previously required JavaScript.
Islands are the right tool when:
The component has state that must survive beyond a single interaction (e.g., a cart, a user session, a multi-step form).
The component relies on browser APIs not available at build time (
canvas,WebGL,getUserMedia, real-time data).You would otherwise write the component twice — once for the server render, once for the client — and keep them in sync manually.
If the honest answer is "I just want a class toggled on click", reach for CSS or a tiny inline script first. Islands for "stateful UI you'd build twice" is the right heuristic.
For more on the JSX wrapper and hydration options, see <Island>. For more on the pipeline shape, see Build pipeline and Build engine.