Hydration
Adopt existing island DOM through identity checks, preflight, and a fail-closed commit.
Hydrate the island contents
The client entry is @takazudo/zfb/zudo-react/client. hydrate adopts the existing child DOM inside the supplied island wrapper. It receives the child description, the wrapper element, and the component/build identity expected by the client bundle. For an island emitted by zfb, the wrapper itself belongs to zfb.
import { hydrate, parseProps } from "@takazudo/zfb/zudo-react/client";
function Counter({ label }: { label: string }) {
return <p>{label}</p>;
}
const wrapper = document.querySelector("[data-zfb-island='Counter']");
if (wrapper instanceof Element) {
const component = wrapper.getAttribute("data-zfb-island");
const build = wrapper.getAttribute("data-zfb-build");
const json = wrapper.getAttribute("data-props");
if (component === "Counter" && build && json !== null) {
const identity = { component, build };
const props = parseProps(json);
if (typeof props.label === "string") {
hydrate(<Counter label={props.label} />, wrapper, { identity });
}
}
}In an integrated zfb application, the island boundary reads and checks this metadata for you. Use direct hydrate or mount calls when you own the client boundary; see the zfb island guide for scheduling and persistence.
Adoption has a preflight phase
Hydration validates the connected wrapper, identity, transport, cancellation state, and duplicate-root state before component setup. It creates one provisional scope tree and calls each component once. The runtime then checks existing nodes without changing the DOM, assigning refs, or attaching listeners.
After preflight succeeds, the runtime checks cancellation and connection again, attaches bindings to the existing nodes, assigns refs, reconciles live form values, and starts activation. It does not re-run components to reconcile updates.
The protocol uses balanced local comment regions for dynamic text, component ranges, fragments, conditions, lists, and opaque raw HTML. These markers exist only within an island subtree. In a zfb-generated island, zfb owns the wrapper element and its data-zfb-island attributes; a caller-owned mount keeps its wrapper under the host's ownership. Authors should preserve the markers produced by server rendering.
Identity checks and server-rendered props
The component name, build identity, transport version, and runtime protocol are checked before setup. The RootOptions identity supplied to hydrate or mount must match the wrapper. A stale bundle or mismatched wrapper fails closed before activating the island or mounted root.
For hydration of an island emitted by zfb, data-props must be present and contain a valid JSON record. parseProps rejects malformed JSON, top-level arrays or scalars, and recursively forbidden keys. The transport does not revive descriptions, signals, or class instances. Recreate reactive state inside component setup. Direct client mounts receive props in memory and do not read data-props; see Mount from a host you own.
The zfb island boundary requires one registered synchronous function component. It rejects multiple children, host-element children, nested islands, and nonempty children props because they cannot be transported as island props. Recursively empty arrays and empty values are allowed and omitted.
Mismatches fail closed
Preflight compares parsed structure, namespaces, range identity, expected text, and form-control shape. It does not compare the raw HTML source, so attribute order, quote style, and equivalent entity spellings do not matter after parsing. Ordinary attributes are preserved during adoption and are not mismatch inputs; later reactive writes apply the value owned by the binding. Wrapper identity and form-control shape remain strict, including type, multiple, radio name/value, option value, and form association.
Ordinary text comparison normalizes HTML line endings, then collapses runs of ASCII HTML whitespace and trims ASCII spaces for matching only. NBSP and other Unicode whitespace are preserved. Preformatted text is compared exactly after HTML newline normalization. Whitespace-only text nodes outside dynamic text regions are ignored. Raw HTML contents are opaque. Outside an explicit ErrorBoundary region, existing elements and nonempty text are never recreated just to make their spelling match.
Form controls are read before any model is written. Dirty values, checked state, selection, and focus are not hydration mismatches. The forms guide explains how the browser's current value wins at activation.
Failures such as ZR_HYDRATION_MISMATCH, ZR_IDENTITY, ZR_PROPS, ZR_UNSUPPORTED_POSITION, ZR_DUPLICATE_ROOT, and ZR_CANCELLED report a diagnostic and return null. A preflight failure leaves the server DOM and user state unchanged. Hydration does not silently fall back to a root-wide mount. The affected island stays inert; other roots are isolated and may continue.
Diagnostics include the component, DOM path, phase, expected and actual summaries, protocol, and build. They do not include complete props, raw HTML, input values, or secrets by default.
Boundary hydration
ErrorBoundary is an explicit local replacement region. During hydration the client makes a fresh attempt and stages replacement of only the boundary's interior. Even when both server and client succeed, that interior is recreated; its server DOM identity, focus, selection, and edits made before hydration are not retained. Elements and state outside the region follow ordinary adoption.
| Server result | Client attempt | Hydrated boundary |
|---|---|---|
| Healthy subtree | Fails | Client fallback |
| Fallback | Fails | Client fallback |
| Fallback | Succeeds | Fresh healthy client subtree |
| Healthy subtree | Succeeds | Fresh healthy client subtree |
The boundary's balanced markers have a local numbering namespace. The number of descendants in a failed or successful attempt cannot shift following sibling markers. Hydration validates marker grammar, nesting, namespace ownership, and duplicate identities before discarding server content. Malformed markers and nested islands remain root failures. Replacement waits until the whole root has passed preflight; a later sibling mismatch or cancellation leaves the server DOM intact.
Fresh boundary form controls initialize from their client models. Retained sibling controls keep ordinary hydration semantics, including dirty values. When a model is shared between fresh and retained controls, the retained controls' live values win; in a mixed radio group, a checked retained option wins, otherwise a model-selected fresh option remains selected. Form-group validation still runs across the root and remains diagnostic rather than being attributed to one boundary.
Use ErrorBoundary only in flow positions that preserve comment regions. As with Show and For, it is not supported inside select, option, title, or table-structural contexts. See Local subtree recovery for setup, reset, and error phases.
Respect HTML parser contexts
The table and select structure rules also apply to static server rendering without islands: parser-sensitive parents require allowed intrinsic children. For example, tbody requires tr children, select requires option or optgroup children, and optgroup requires option children. A component or Fragment cannot stand in one of those positions. Keep table structure explicit with tbody and option labels as static scalar text. Inside islands, comment-delimited dynamic regions are supported only where parsed HTML preserves their positions, so they are also unsupported at table-structural levels such as table, tbody, or tr, and inside select, optgroup, or option.
Title content is static scalar text. Textarea content belongs to its form adapter. Inside an island, template is unsupported. Script and style accept only static rawHtml under the restrictions in Components and JSX. Hydrated content and rawHtml remain rejected in parser-sensitive noscript, xmp, noembed, noframes, and plaintext contexts. A childless iframe shell is supported, but its document from src or srcdoc belongs to the browser and is outside hydration; srcdoc is trusted application input and is not sanitized. Do not give an iframe JSX children or rawHtml, and do not put fallback children or hydration markers in the iframe element's fallback content. Unsupported structures fail with an element, island, and path diagnostic instead of relying on browser parser repair.
Mount is client rendering
mount uses the same child description and identity checks but creates new DOM rather than adopting server children. For a caller-owned boundary, mount the root component directly as described below. For zfb-generated skip-SSR islands, zfb applies the same load, idle, visible, and media scheduling and cancellation rules, then the runtime builds the new subtree off-DOM, validates it, and replaces the fallback in one step before activation. If setup or validation fails, the fallback stays in place; if a scheduling API is unavailable, the runtime uses its immediate fallback.
Mount from a host you own
Use this recipe when your application owns a client-only boundary and calls mount itself. The host creates and connects the wrapper before mounting. This direct call does not register a component with zfb's static island scanner; scanner-discovered server islands continue to use the zfb integration and its JSON/1 props transport.
For a client-only mount, set these four attributes on the wrapper. Choose any nonempty, stable build string for your host; zudo-react compares it for equality with RootOptions.identity.build and does not look it up in a scanner registry.
import { h, type Child } from "@takazudo/zfb/zudo-react";
import { mount, type RootHandle } from "@takazudo/zfb/zudo-react/client";
function PreviewChrome(props: { onSelect: () => void; slot: Child }) {
return h(
"nav",
null,
h("button", { "on:click": props.onSelect, children: "Select" }),
props.slot,
);
}
const identity = { component: PreviewChrome.name, build: "preview-frame-v1" };
const host = document.createElement("div");
host.setAttribute("data-zfb-island-skip-ssr", identity.component);
host.setAttribute("data-zfb-transport", "json/1");
host.setAttribute("data-zfb-protocol", "zudo-react/1");
host.setAttribute("data-zfb-build", identity.build);
document.body.append(host); // mount requires a connected root
const props = {
onSelect: () => console.log("selected"),
slot: h("span", { children: "Provided by the host" }),
};
function mountPreview(): RootHandle | null {
const root = mount(h(PreviewChrome, props), host, {
identity,
report(diagnostic) {
console.error(`[zudo-react] ${diagnostic.code}`, diagnostic);
},
});
if (root === null) {
host.replaceChildren(document.createTextNode("Preview unavailable."));
return null;
}
return root;
}
let root: RootHandle | null = null;
function resume() {
if (root && !root.disposed) return;
root = mountPreview();
}
function pause() {
root?.dispose(); // stop listeners and scopes; keep the current DOM
}
function stop() {
root?.unmount(); // dispose and remove the rendered subtree
root = null;
host.remove();
}
resume();
// The host may call pause() and resume() as its own lifecycle changes.
// Call stop() before removing the host permanently.The root child must be one named function component. Its displayName ?? name must equal identity.component. Props passed to h stay in memory, so callbacks and child descriptions such as slot do not go through data-props or JSON serialization. The report callback receives a structured diagnostic; a failed mount returns null, so handle that result before treating the root as active. dispose() leaves the current DOM in place while stopping its scopes and listeners. A later mount on the same connected wrapper replaces that subtree; unmount() also removes it.