The boundary model
A boundary is a component the renderer treats specially: it decides how that component's HTML reaches the page and when the component wakes up on the client. It's the strategy-agnostic core of SignalX SSR — islands are one pack built on it.
Two orthogonal axes
Every boundary is described by two independent choices. Keeping them separate is the whole point of the model — a server-flush decision never dictates a client-hydration decision.
flush — a server concern: how the component's HTML gets to the page.
flush | Meaning |
|---|---|
inline | Await the component's async work in place, even in a streaming render. |
stream | Stream the HTML when there is pending async work and the render is streaming; otherwise degrade to inline. |
skip | Don't run setup on the server at all — emit a placeholder wrapper and let the client mount fresh. |
hydrate — a client concern: when the component becomes interactive.
hydrate | Wakes on |
|---|---|
load | As soon as the bundle loads. |
idle | The next idle callback. |
visible | Scrolled into view. |
media | A media query matches (see media). |
interaction | The first pointer/keyboard/touch/focus event on the element. |
never | Never — server HTML only, no client component. |
A static marketing section might be { flush: 'inline', hydrate: 'never' }; a below-the-fold widget { flush: 'stream', hydrate: 'visible' }; a client-only chart { flush: 'skip', hydrate: 'load' }.
Describing a boundary
SSRBoundary is the resolved description the renderer works from:
interface SSRBoundary {
id: number; // renderer-assigned, from core's component-id scheme
flush: BoundaryFlush; // 'inline' | 'stream' | 'skip'
hydrate: BoundaryHydrate; // 'load' | 'idle' | 'visible' | 'media' | 'interaction' | 'never'
media?: string; // required when hydrate is 'media'
fallback?: () => JSXElement; // server-only placeholder for 'stream' / 'skip'; never serialized
chunk?: { url: string; export?: string };
props?: Record<string, unknown>;
}
You rarely write this by hand — a pack (like islands) produces it from something friendlier, such as a client:* directive.
Resolving a boundary
Boundaries come from a plugin's resolveBoundary hook. The renderer calls it once per component — after it allocates the component's id, before setup runs — and the first plugin to return an object wins:
const myBoundaryPlugin: SSRPlugin = {
server: {
resolveBoundary(vnode, ctx) {
if (vnode.type?.__deferred) {
return { flush: 'stream', hydrate: 'visible' };
}
// return nothing → this component is not a boundary
},
},
};
The returned object is a ResolvedBoundary — a partial of the flush / hydrate / media / fallback / chunk / props / component fields. Anything omitted falls back to the app's defaults.
component is the record's registry name — how the client resolves the component, trying the eager registry, then the lazy registry, then the chunk URL, in that order. Core derives it from __islandId || __name, which is right for packs that use core's stamp; a pack with its own naming vocabulary sets it here instead. An anonymous record — no component and no chunk — is refused by the client loader rather than silently skipped.
Because the hook runs before setup, a flush: 'skip' decision means setup never runs on the server: the renderer emits a <div data-boundary="ID" style="display:contents"> wrapper around the optional fallback and the client mounts into it fresh. For stream and inline, setup runs normally and the boundary's HTML is produced server-side.
The client's view: __SIGX_BOUNDARIES__
Every recorded boundary is serialized into a per-request table emitted as window.__SIGX_BOUNDARIES__. Each entry carries only what the client needs — the resolved hydrate strategy, any props or transferred signal state, the chunk reference for a lazy boundary, and an errorScope marker if the server caught an error there:
interface SSRBoundaryRecord {
flush?: BoundaryFlush; // present only when 'skip'
hydrate?: BoundaryHydrate; // omitted = inherit the app default
media?: string;
props?: Record<string, unknown>;
state?: Record<string, unknown>; // transferred island signal snapshot
chunk?: { url: string; export?: string };
component?: string;
errorScope?: { message: string };
}
The table shares one serializer (and one dev-time serializability warning) with window.__SIGX_ASYNC__, so the same escaping and custom-type rules apply to both. That serializer is codec-aware: alongside JSON primitives, top-level and nested Date / Map / Set / bigint / URL / RegExp / explicit undefined values — plus any registered custom type handlers — round-trip; only functions and circular references are rejected.
Custom types
The codec ships with handlers for Date, Map, Set, bigint, URL, RegExp and undefined. To carry a type of your own across the boundary — the SSR state blob, the boundary table, and server-function arguments and results — register a handler with @sigx/serialize's defineTypeHandler, most easily through the @sigx/server/plugin app plugin's types option:
import { serverPlugin } from '@sigx/server/plugin';
app.use(serverPlugin({ types: [moneyHandler] }));
See Serialize → Usage for writing the handler (its test type guard drives the serialize / revive types) and the other registration paths.
Selective hydration is the hydrator
On the client, hydrate() reads the boundary table and schedules work per strategy — this is the hydrator, not an add-on. Its behavior is governed by a per-app hydrate default:
explicit— only entries in the boundary table are scheduled; there is no root walk. A page with no table pays nothing.auto— the client walks the tree and intercepts recorded boundaries as it finds them.
Installing a pack is what selects the mode. app.use(islandsPlugin()) provides the explicit default and registers the client hooks — so installing the package is what turns on islands semantics, never merely importing it.
Where to go next
- Islands & client directives — the reference pack that maps
client:*onto these axes - Request lifecycle —
useResponse, the error seam, and the./nodeentry - Building a full SSR app — wiring it all together
