Skip to content
forma.
v3.0
☰
Browse documentation
Start here
Build interfaces
Native rendering
Tooling
Security and operations
Architecture
Application guides
API reference

Docs/Native rendering

FORMA 3.0

Islands and activation

Declare interactive regions, pass server props and adopt existing HTML safely.

View source ↗Markdown ↓Source 753be2a
On this page

Registered components receive one declared props object. The compiler binds each literal activateIslands({ Counter }) registry entry to its schema, mode and identity digest; applications do not construct this metadata themselves.

tsx
import { activateIslands, createSignal, h } from "@getforma/core";

function Counter(props: { initial: number }) {
  const [count, setCount] = createSignal(props.initial);
  return <div>
    <span>{() => count()}</span>
    <button onClick={() => setCount(count() + 1)}>+1</button>
  </div>;
}

activateIslands({ Counter });

The server takes a bounded snapshot of each executed island's declared inputs from the same values used to render its body. The browser validates every requested host before arming any trigger or invoking any component. Each accepted component then runs in an isolated reactive root; a failed sibling does not prevent another valid island from activating.

activateIslands() returns a Promise<void> for the requested load islands. Preflight and claim reservation remain synchronous. Load activation starts in a browser timer task unless first input promotes it sooner. Each timer drain starts a cooperative 12 ms slice, takes at most four direct queue entries, and yields between whole islands. Input promotion can activate additional work synchronously. These limits are not a hard duration bound on synchronous preflight, an indivisible island, or application code. Await the result when subsequent code requires load activation to finish. The promise resolves after those islands activate, fail in isolation, or are disposed, including requested load work already pending from another call. Visible, idle and interaction islands complete their part when their triggers are armed; the promise does not wait for those future triggers. Synchronous preflight exceptions still throw, and isolated component errors still use the existing diagnostics rather than rejecting the promise.

Queued load islands listen for pointerdown, focusin and click so their first input can promote that island synchronously before normal event handling. This priority path preserves the first event for retained SSR targets and leaves native default actions intact. Browser-only replacement creates a new target, which cannot receive an event already dispatching on the old target; callers that need its handlers ready must await activation. No event is replayed and no island transaction is split. Input promotion can exceed the background slice. Other islands continue in FIFO order, and disposal releases pending activation and its temporary listeners.

This is the shipped 3.0 contract. Legacy (element, props) registrations, data-forma-props and #__forma_islands no longer provide SSR state. Rebuild the page and browser entry together. An uncompiled SSR callback is refused; a Browser-shell route remains an ordinary browser mount without this preflight.

Deployment mismatch#

Before state preflight or any trigger, activateIslands compares the consuming bundle's build identity with data-forma-build on forma-server's actual page root: the direct body child div#app. It does not search arbitrary stamped elements or descendants. A requested subtree uses its original Document's root, and the first verdict is retained for that Document. Either identity absent or empty means unchecked, preserving older servers and bundles built without Forma.

On a mismatch, no island component, observer, timer or interaction listener runs. Served content and node identity remain intact; island hosts receive data-forma-status="skew". Core dispatches a cancelable forma:skew CustomEvent on the Document with detail: { page, bundle, reload }. reload is eligibility for automatic reload: readable session storage has no record for that full page identity. Cancel the event to supply application UI instead. Cancellation keeps the page inert and does not consume eligibility.

Otherwise core records the identity before reloading once. A storage access, read or write failure leaves the page inert without navigation. The reloaded Document can report the mismatch again, but cannot automatically reload for the same identity in that tab. Different identities have separate records.

mount() applies the same Document guard before component execution or DOM replacement and returns an inert disposal callback on mismatch. Public direct hydrateIsland is caller owned and is not gated. Core does not import island chunks; failures of application-owned import() calls remain the application's to handle. See src/dom/__tests__/skew.test.ts and the three-engine e2e/skew.spec.ts checks. The rolling-deployment product drill is a separate release qualification.

SSR with server data#

Seed local signals from the declared props argument. A server value of 1001 must reach the signal before its first effect; a bare browser default of 42 would start from different state. Distinct local signals seeded from the same prop remain distinct. Imported or lexical shared getters keep their actual identity and are not recreated from a snapshot.

Getting props to the island#

Every SSR response contains exactly one non-executable <script type="application/json" data-island-state> in its head, including when there are no islands. The envelope is {"v":1,"s":[],"i":[...]}; each occurrence contains exactly h (the compiled digest) and p (projected declared props). The matching original host carries data-forma-state and data-forma-h. This is compiler/server output, not a separate manual serialization API.

The complete escaped state envelope is capped at 1 MiB. Every literal < in state strings is escaped. Finite numbers retain their IEEE-754 value, with negative zero canonicalized to zero; signed epoch milliseconds become Date objects in the browser. Declared arrays, objects and enums are checked before activation. Optional null object fields are omitted; optional array positions retain null. Undeclared and forbidden fields never reach the component.

The decoder reads only the first matching head block once per Document. Malformed envelope data refuses the document; a malformed individual entry leaves unrelated valid entries eligible. Missing state, mismatched digests, duplicate occurrence claims, unknown registrations and schema mismatches log stable FORMA_E_ISLAND_* codes and leave the host's content and status unchanged. They run no component, effect or trigger. Later injected hosts cannot acquire an original occurrence claim, even through a subtree activation request.

See the decoder contract and the reviewed wire grammar. sanitizePropsDeep remains a standalone utility for other data paths; SSR activation uses declared-schema projection rather than the old generic sanitizer. The utility mutates and returns the same input. It throws TypeError when an own __proto__, constructor or prototype key cannot be deleted, including inside frozen or sealed data. Earlier deletions are not rolled back on refusal; discard that partially sanitized input rather than passing it to a merge or store. Frozen data without forbidden own keys remains accepted.

Verified by src/dom/__tests__/island-state.test.ts and src/dom/__tests__/island-state-activation.test.ts.

Strict activation#

After state preflight, a compiled SSR component describes one owned Element matching its original host. Preparation reads and validates the existing nodes, markers, attributes, text and control defaults before publication. Success makes zero initial DOM writes: no node replacement, text normalization, SSR-marker removal or lifecycle-status write. The runtime tracks activation privately; an unchanged data-forma-status="pending" is not evidence that activation failed. Effects, refs and listeners become live only after the complete plan validates. Later updates use their owned reactive bindings.

An empty SSR host does not select a browser fallback. Returning an imperative DOM Node, an appended fragment or no value is not a description and is refused. Compiled Fragment/Show/List roots are not a substitute for the owned Element. Keep those ranges inside the component's root. browserOnly is an explicit separate mode, described below.

An initial mismatch leaves the original content and node identity intact. It performs only the required data-forma-status="error" write and emits one bubbling forma:error event for the failed owner. The frozen detail contains island, occurrence, binding (or null), code, and an optional deterministic path; it contains no props or payload. Reporting also reaches onError with source 'hydration' in development and production. A valid neighbor still runs. State/identity preflight refusals happen earlier and make no DOM writes, including no status change or activation event.

CodeRefusal
FORMA_E_HYDRATE_NODEMissing or mismatching original node/tag
FORMA_E_HYDRATE_MARKERMissing, malformed or mismatching marker range
FORMA_E_HYDRATE_STRUCTUREUnsupported description or changed ownership/topology
FORMA_E_HYDRATE_VALUEOriginal text, attribute or control default differs
FORMA_E_HYDRATE_LIST_COUNTInitial unkeyed list cannot consume the original range
FORMA_E_HYDRATE_KEYA keyed row's identity is missing, repeated, outside the key domain or on one side only
FORMA_E_HYDRATE_INPUT_TARGETAn early edit has no proven reversible local binding
FORMA_E_HYDRATE_INPUT_CONFLICTMultiple edits disagree for one local signal
FORMA_E_HYDRATE_EFFECT, FORMA_E_HYDRATE_REF, FORMA_E_HYDRATE_COMPONENTOwned callback/component failure

Later failures revoke the owner's callbacks, listeners and reactive work and report once; they do not undo application writes already made by a callback. Components themselves are not sandboxed: arbitrary imperative application DOM writes cannot be rolled back by this contract. Return a supported description.

Verified by src/dom/__tests__/activation-transaction.test.ts > "records successful SSR activation internally with zero writes and no duplicate component call" Verified by src/dom/__tests__/activation-transaction.test.ts > "emits one bubbling coded mismatch with only one status write and keeps its neighbor live" Verified by src/dom/__tests__/activation-transaction.test.ts > "preserves write-free identity refusal before execution" Verified by src/dom/__tests__/activation-transaction.test.ts > "never selects a client fallback for an empty SSR host" Verified by src/dom/__tests__/activation-transaction.test.ts > "isolates a postcommit callback failure and emits one production-shaped diagnostic" Verified by src/dom/__tests__/hydrate-fragment.test.ts > "an imperative island returning its own element refuses without running its deferred effect"

Server-rendered lists#

The server delimits a list with <!--f:l0--> / <!--/f:l0-->. A list the compiler keys (createList(items, row => row.id, …)) has each row's data-forma-key on the row's root, written by the native renderer as the key's canonical text: a safe integer in decimal (-0 as 0), a string under value rule 1 (docs/parity-rules.md, "Keyed rows"). Adoption binds each client item to the served row whose decoded attribute equals the item's canonical key, in any order: numeric key 1 matches attribute "1", and a client key "a\r\nb" matches the served "a\nb". For example:

tsx
import { activateIslands, createSignal, createList, h } from "@getforma/core";

function TrackList(props: { items: { id: number; name: string }[] }) {
  const [items] = createSignal(props.items);
  return <div><ul>{createList(
    items,
    item => item.id,
    item => <li>{item.name}</li>,
  )}</ul></div>;
}

activateIslands({ TrackList });

Initial adoption never creates missing rows, removes extra rows or moves a row (FORMA-181). Each keyed row is walked through a cursor bounded to that row (its root, or the registered child's f:i envelope that wholly encloses it), so a row cannot consume its neighbour, and a registered child keeps its own served host, state and occurrence whichever position the client gives it. The served order is kept until the list source changes; the first real update then reconciles the client's order against it, moving rows without rebuilding them. A row's index starts at its position in the client's array. A key on one side only, a served key twice, a served row without a key beside keyed rows, two client keys with the same canonical text (1 and "1"), or a client key outside the domain (a fraction, null, an unpaired surrogate) refuses with FORMA_E_HYDRATE_KEY before any row runs; text or a range beside keyed rows is FORMA_E_HYDRATE_STRUCTURE, and a row of another shape is the ordinary node or value refusal. FORMA-123's row-count check is no longer the criterion for keyed rows. After successful adoption, ordinary keyed reconciliation can insert, remove and reorder rows.

An unkeyed range is consumed by the actual row descriptions. A logical row can contain multiple nodes, shared literal Text or no nodes, so its server row count cannot be inferred from child-element count. LIST_COUNT diagnostics report the known client item count and exact remaining/exhausted DOM extent instead. Its client key function still feeds the shared canonical cache: fractional numbers and lone surrogates refuse with FORMA_E_HYDRATE_KEY before any row callback or DOM write, even when the served range has no data-forma-key attributes. Safe integers and valid Unicode strings are admitted without adding those attributes. Matching empty ranges remain empty until a later array update; they never trigger an implicit browser mount. State/props must describe the same initial items used by the native renderer.

Verified by src/dom/__tests__/list-adoption-count.test.ts > "marks the island error with FORMA_E_HYDRATE_LIST_COUNT and removes or adds no row" Verified by src/dom/__tests__/list-adoption-count.test.ts > "keyed initial count mismatch refuses without removing or replacing any server row" Verified by src/dom/__tests__/adoption-keyed-list.test.ts > "binds reversed client keys to the same served nodes with no write through a microtask and a macrotask, then a real update reorders" Verified by src/dom/__tests__/adoption-keyed-list.test.ts > "compares the decoded attribute with the canonical key text: numbers, -0, line endings and NUL" Verified by src/dom/__tests__/adoption-keyed-list.test.ts > "refuses a key on one side only, a repeated key or a key outside the domain with FORMA_E_HYDRATE_KEY before any row runs" Verified by src/dom/__tests__/adoption-keyed-list.test.ts > "binds keyed registered children by key: each client row matches its own host, state and occurrence" Verified by src/dom/__tests__/adoption-keyed-list.test.ts > "a duplicate served key fails only its own island with FORMA_E_HYDRATE_KEY; the neighbouring keyed island activates" Verified by src/dom/__tests__/list-adoption-count.test.ts > "the coded message reports known client items and unconsumed DOM without inventing server row counts" Verified by src/dom/__tests__/list-adoption-count.test.ts > "equal counts adopt 40 unkeyed rows in place by index, as before, and keep reconciling" Verified by src/dom/__tests__/adoption-list.test.ts > "binds reversed served keys to their own rows without moving them" Verified by src/dom/__tests__/adoption-list.test.ts > "adopts multiple-element Fragment rows without wrappers and moves their complete spans later" Verified by src/dom/__tests__/adoption-list.test.ts > "adopts text-only and empty rows with no initial split or synthetic anchors" Verified by src/dom/__tests__/list-adoption-count.test.ts > "hydrateIsland() called directly keeps no binding it adopted before the list failed"

Early form edits and exact text#

Preparation first validates the decoded initial value against the server's normalized default, then checks any edit the user made before activation. An edit cannot excuse a wrong server default. Compiler hooks associate an admitted binding with its actual component-local default-equality signal tuple; only that proven binding can seed a reversible text, number/range, boolean or string-array edit. Captured globals, computed/read-only expressions, custom-equality setters and guessed setter names do not gain authority. Conflicting edits refuse. Seeding holds notifications until validation completes, preserving the live control, focus and selection; dependent updates caused by that earlier user edit are distinct from the zero-write initial adoption of an untouched page.

A literal initialization with no reactive reader preserves an early edit without seeding anything. An edited reactive reader without the compiled association refuses FORMA_E_HYDRATE_INPUT_TARGET. This includes public direct hydration. An edit returned to its original default is indistinguishable from no edit.

textarea and HTML title use exact RCDATA text, without hydration comments. The runtime does not strip marker-looking strings from older output. Literal marker text is valid only when it is the intended matching text. Later textarea updates control both the live value and reset default, as ordinary CSR does.

Verified by src/dom/__tests__/activation-transaction.test.ts > "uses actual compiled local authority to seed an early edit and later control the live value" Verified by src/dom/__tests__/adoption-controls.test.ts > "validates every default before considering an edited readonly binding" Verified by src/dom/__tests__/adoption-controls.test.ts > "does not let an early edit excuse a wrong decoded initial value" Verified by src/dom/__tests__/adoption-controls.test.ts > "preserves static value checked and textarea edits with explicit initial-only observations" Verified by src/dom/__tests__/form-control-adoption.test.ts > "refuses an edited public reactive input and leaves its later updates inert" Verified by src/dom/__tests__/form-control-adoption.test.ts > "binds a marker-free textarea by the element and writes nothing when it agrees" Verified by src/dom/__tests__/form-control-adoption.test.ts > "refuses older textarea marker text without stripping or binding it" Verified by src/dom/__tests__/form-control-adoption.test.ts > "binds matching title text by the element and refuses older marker-bearing output" Verified by src/dom/__tests__/form-control-adoption.test.ts > "after the user types, a textarea's reactive text controls its live and default values, client-rendered and adopted alike"

Existing namespaces, styles and typed markup#

Initial SVG/MathML adoption validates the original namespace and node shape; it does not repair a different tag, missing text marker or missing child. Matching attributes are not rewritten, including iframe src, which would reload even when assigned the same URL. Later fresh branches and rows resolve namespaces from each constructed parent. Nested HTML links inside SVG foreignObject and SVG titles inside MathML annotation-xml retain their intended namespaces. Fresh template children belong to template.content; later readers keep their own cleanup lifetime when the fragment moves into its live parent.

Styles are compared through an inert CSSOM model, without assigning a live style block. The direct facade accepts strings and own-data object fields with string/finite-number values; accessors, arbitrary coercions and cssText refuse. That facade capability does not extend the compiler's admitted style grammar.

Raw content and srcdoc retain the trustedHtml() boundary. Matching normalized raw content is opaque, not interpreted as compiler markers. Empty server content with a nonempty requested raw value refuses instead of filling it. An initial null/false raw value allows ordinary fallback children; later raw replacement tears down their owned work first. The private preparation helper refuses nonnull raw markup on HTML noscript with STRUCTURE because inert normalization cannot establish the document's scripting mode. Ordinary CSR is unchanged.

Verified by src/dom/__tests__/hydrate-svg.test.ts > "adopts a server-rendered inline SVG with no DOM write and no re-created node" Verified by src/dom/__tests__/hydrate-svg.test.ts > "adopts case-sensitive SVG tags such as clipPath and linearGradient in place" Verified by src/dom/__tests__/hydrate-svg.test.ts > "writes nothing where the server already holds the value, and still writes every change" Verified by src/dom/__tests__/adoption-materialize.test.ts > "constructs later nested namespace branches with the actual owned Show adapter" Verified by src/dom/__tests__/adoption-postcommit-materialize.test.ts > "keeps movable template readers and nested compiler conditional values live after insertion" Verified by src/dom/__tests__/adoption-content.test.ts > "refuses a missing or different served style rather than filling or repairing it" Verified by src/dom/__tests__/adoption-content.test.ts > "supports own string and finite-number object fields for the direct hydration facade" Verified by src/dom/__tests__/adopt-inner-html.test.ts > "adoption writes nothing where the server already holds the markup" Verified by src/dom/__tests__/adopt-inner-html.test.ts > "adoption aborts on untyped markup without writing while ordinary CSR can recover"

Browser mounting and public direct hydration#

mount() throws FORMA_E_MOUNT_SSR on a data-forma-ssr target, including an empty one, before component/root/effect/listener work or DOM writes. Use compiled activateIslands({ Counter }) for SSR interaction. Browser-shell mounting and explicit browserOnly remain separate supported paths.

Public hydrateIsland(component, target) uses strict preparation under an explicit caller-owned createRoot. It returns the same target. Coded mismatch reports through onError and returns; an initial user exception is cleaned up and rethrown unchanged. It adds no compiled state or early-edit setter authority. A marked Element target is the component root; an unmarked target contains it. Direct root Fragment/Show/List descriptions consume the target's exact children; this facade behavior does not change the compiled single-Element contract. The caller must dispose its root when finished.

Verified by src/dom/__tests__/mount.test.ts > "refuses a marked %s root by %s before component/root/effects/listeners or DOM writes" Verified by src/dom/__tests__/owned-adoption.test.ts > "forwards a thrown component error unchanged and disposes its caller root" Verified by src/dom/__tests__/hydrate-fragment.test.ts > "compiled island Fragment roots refuse while the public facade adopts independent ranges" Verified by src/dom/__tests__/hydrate-fragment.test.ts > "a root Fragment binds its click handler and reactive text under a caller-owned adoption root"

Browser-only components: browserOnly()#

Some components cannot render on the server: a chart that measures the viewport, a map, an editor. Wrap such a component in browserOnly(), the one marker for a subtree that renders in the browser alone (one name, so a search finds every use):

tsx
import { activateIslands, browserOnly, h } from "@getforma/core";

// chart.tsx: declared once, at the top level of a module.
export const Chart = browserOnly((props: { title: string }) =>
  <canvas aria-label={props.title} />,
);

// The page renders it like any component: <Chart title="Sales" />.
// The entry registers it under that name:
activateIslands({ Chart });

The compiler records mode browserOnly, the declared schema and ordered inputs. The server emits an empty host plus the same bounded head-state occurrence used by other islands. The compiled registry wrapper supplies one projected props argument to Chart; its browser implementation renders into the empty host. A client-rendered page calls the component directly. Without a document it renders nothing.

Browser-only props use the declared type contract, including objects, arrays, optional values and server-derived data. Unsupported expressions remain build diagnostics; they are not silently omitted from the state. A Browser-shell route (ssr: false) has no SSR state block and mounts through its own browser entry.

Verified by src/dom/__tests__/browser-only.test.ts > "registered in activateIslands, renders the component in its empty host, from the server props" Verified by src/dom/__tests__/browser-only.test.ts > "called as a component, renders it as any component in a client-rendered tree"


Historical qualification#

The earlier compiler 0.3.7 whole-page mount observations and repair behavior are preserved in the exact prior guide. They do not describe this strict activation path. Compiler subset completion, package-size limits and integrated template/packed gates remain separate development acceptance work; this guide is not a declaration that FORMA-104 or the complete 3.0 upgrade is finished.

Hydration triggers#

The compiler-generated host includes data-forma-hydrate to select when an already-loaded registration activates. This does not control bundle fetching:

TriggerWhen it hydratesUse case
load (default)First available slice, then queued browser tasksAbove-the-fold content
visibleWhen the island enters the viewportBelow-the-fold components
idleDuring browser idle timeNon-critical functionality
interactionOn first pointerdown or focusinSkeleton + skin pattern
html
<!-- Excerpt of a generated host; its occurrence/hash attributes are omitted. -->
<div data-forma-island="1" data-forma-component="Comments" data-forma-hydrate="visible">
  <!-- Activation waits until visible; this does not defer bundle loading. -->
</div>

Scoping to a subtree, and disposal#

activateIslands(registry, root?) accepts a container to request only its original document islands. The first call still freezes claims across the whole Document, so duplicate claims outside that subtree also refuse their owners. A shadow-only host or later injected host was never in that original document set and is refused. Deferred activation rechecks the frozen markers immediately before running the component.

ts
import { activateIslands, deactivateIsland, deactivateAllIslands } from "@getforma/core";

activateIslands(registry, originalContainer);
deactivateAllIslands(originalContainer);
deactivateIsland(islandElement);

Dispose islands before removing their content so their effects and deferred observers are released. Islands removed inside a createList row are disposed automatically. Disposal accepts any ParentNode; it does not grant a new state claim or make later inserted hosts eligible.

Verified by src/dom/__tests__/island-state-activation.test.ts and src/dom/__tests__/list-disposal.test.ts.


Where the wire format is specified#

The marker grammar (<!--f:t0-->, <!--f:s0-->, <!--f:l0-->, <!--f:i0-->), the data-forma-* attributes and the head-state envelope are shared contracts, not implementation details of this library: the Rust walker emits them and this runtime consumes them.

Find your next answer.

Search Forma 3.0 documentation, guides and examples.