Hydration & head
After the server sends HTML, the client re-attaches reactivity to that existing DOM. This guide covers the hydration entry point, head management, and the plugin SPI that advanced strategies build on.
The client hydration entry
The high-level entry is the ssrClientPlugin. It adds hydrate() to your App:
import { defineApp } from 'sigx';
import { ssrClientPlugin } from '@sigx/server-renderer/client';
import { App } from './App';
defineApp(<App />)
.use(ssrClientPlugin)
.hydrate('#root');
hydrate() accepts a CSS selector string or an Element. It finds the root
component plus AppContext, then hydrates the existing SSR DOM. If the container
has no SSR content, it falls back to a fresh client render — so the same entry
works for both pre-rendered and client-only pages.
Gate hydration on the completion script
The document renderer emits a trailing script — __SIGX_STREAMING_COMPLETE__ plus
a sigx:ready event — in both 'blocking' and 'stream' modes, so a
blocking-rendered page hydrates the same way a streamed one does. The inline
script runs during HTML parse, before any deferred module script, so your entry
can safely check the flag and otherwise wait for the event before hydrating:
// src/entry-client.tsx
import { defineApp } from 'sigx';
import { ssrClientPlugin } from '@sigx/server-renderer/client';
import { App } from './App';
function start() {
defineApp(<App />).use(ssrClientPlugin).hydrate('#root');
}
if ((window as any).__SIGX_STREAMING_COMPLETE__) {
start(); // document already complete
} else {
window.addEventListener('sigx:ready', start, { once: true });
}
Self-healing on a structural mismatch
Hydration is resilient to SSR/client drift. For a small cursor offset the walk scans forward for the matching node (dev-warns about skipped siblings). For a genuine structural mismatch — the server emitted a different element than the client VNode expects — it self-heals: it creates the expected element fresh, mounts the subtree into it, and advances past the orphaned server node. The page stays correct and reactive instead of duplicating content or leaving a dead subtree. These recoveries warn in dev so you can fix the underlying drift.
Low-level hydration
For custom setups you can call the core hydrate function directly. It normalizes
the element to a VNode, sets the current AppContext for DI, runs client plugin
beforeHydrate hooks, walks the DOM via hydrateNode, then runs afterHydrate:
import { hydrate } from '@sigx/server-renderer/client';
import { App } from './App';
const container = document.getElementById('root')!;
hydrate(<App />, container);
hydrateNode does the per-VNode work: in the happy path it creates no DOM, just
attaches events/props/directives/refs, skips SSR comment markers
(<!--t-->, <!--$c:N-->), recovers from minor SSR drift by scanning forward
(with a dev warning), and returns the next sibling.
Head management
Call useHead() — exported from core sigx — inside any component to manage
<head> elements. During SSR the configs are collected onto the per-request
context; on the client useHead() mutates the DOM directly and registers cleanup
on unmount:
import { component, useHead } from 'sigx';
export const ArticlePage = component((props) => {
useHead({
title: props.title,
titleTemplate: '%s — My Site',
meta: [
{ name: 'description', content: props.summary },
{ property: 'og:title', content: props.title },
],
link: [{ rel: 'canonical', href: props.url }],
htmlAttrs: { lang: 'en' },
});
return () => <article>{props.body}</article>;
});
renderDocument* collects these configs during render and injects the rendered
head HTML before </head> in your template automatically — there is nothing to
wire up. titleTemplate uses %s as the title placeholder, and the renderer
dedupes meta by name/property/http-equiv/charset.
renderDocument injects the collected head into the document for you, so a
useHead() call anywhere in the tree just works — no manual wiring.
Hydrating a server-resolved Defer subtree
The server resolves lazy() components inline (and streams a <Defer>
fallback then swaps in one replacement — see
Rendering & streaming). To hydrate
that server-resolved subtree, the lazy component must be available synchronously
during the hydration walk — otherwise the client would render the fallback again
and mismatch the server output.
Preload the lazy chunk before calling hydrate(). Every lazy() factory exposes
a .preload() promise for exactly this:
// src/entry-client.tsx
import { defineApp, lazy } from 'sigx';
import { ssrClientPlugin } from '@sigx/server-renderer/client';
import { App } from './App';
import { HeavyChart } from './HeavyChart'; // a lazy() component used under <Defer>
// Resolve the chunk first, then hydrate — the component is ready when the
// hydration walk reaches it, so it matches the server-rendered DOM.
await HeavyChart.preload();
defineApp(<App />).use(ssrClientPlugin).hydrate('#root');
For selective hydration where each island owns its own lazy chunk, the islands
plugin handles this for you — see
@sigx/ssr-islands.
The scheduler / core split
Selective hydration is deliberately in two halves, so a page pays for triggers before it pays for a renderer.
| Half | Entry | What it costs |
|---|---|---|
| Eager scheduler | @sigx/server-renderer/client/scheduler | ~2 kB. Reads __SIGX_BOUNDARIES__ and wires each boundary's trigger. Value-imports nothing from the sigx family, so no framework code executes at load. |
| Hydration core | loaded via loadHydrationCore() | The renderer, hydrateComponent, and the mount/hydrate primitives. Dynamically imported on the first strategy that actually fires. |
A page whose strategies never fire — everything below the fold, everything
hydrate: 'never' — never executes any framework JavaScript at all.
import { scheduleTableBoundaries } from '@sigx/server-renderer/client/scheduler';
scheduleTableBoundaries(); // triggers only; the executor arrives when one fires
The entry also exports the pieces a strategy pack builds on without pulling the
core in: scheduleByStrategy, getBoundaryTable / getBoundaryRecord,
findBoundaryMarker / hydrateTableBoundary, the component registry
(registerComponent, resolveComponent, registerComponentChunk),
loadBoundaryComponent / prefetchBoundaryChunks, and
seedBoundaryState / consumeBoundaryState.
loadHydrationCore() caches its promise, and a failed load clears the cache so
the next trigger retries.
The one behavioural consequence: a hydrate: 'load' boundary now hydrates after
one dynamic-import round trip rather than synchronously. That import is
preloadable — a pack keeps it off the critical path with the
assets hook.
Lazy client plugins
Because the core loads late, a pack's client hooks can ride in the same chunk.
registerClientPlugin takes either a resolved plugin or a lazy source:
import { registerClientPlugin } from '@sigx/server-renderer/client/scheduler';
registerClientPlugin({
name: 'my-strategy',
load: () => import('./my-strategy-client'), // resolved with the hydration core
});
resolveClientPlugins() imports every lazy source once, before the first
component hydrates, so the synchronous client hooks always see a resolved plugin.
Registrations dedupe by name, first wins — registering the same name again in
either form is a no-op.
The plugin SPI
The core renderer and hydrator are strategy-agnostic. Every advanced hydration
strategy — selective, islands, resumable, Defer — is an SSRPlugin with
optional server and client hook sets.
- Register a hand-written
SSRPlugin's server hooks by passing it tocreateSSR({ plugins: [myPlugin] }). (Published packs likeislandsPlugin()areSSRPacks — anSSRPluginplus aninstall(app)method — so you install those on the app withapp.use().) - Register client hooks with
registerClientPlugin(plugin).
import { createSSR, type SSRPlugin } from '@sigx/server-renderer';
const myPlugin: SSRPlugin = {
name: 'my-strategy',
server: {
// decide how this component flushes and hydrates — the per-component seam
resolveBoundary(vnode, ctx) {
return; // no opinion; the next plugin (or the default) decides
},
// mutate/replace a component's context after it's built, before setup()
transformComponentContext(ctx, vnode, componentCtx) {
return; // accept as-is
},
// append-only: capture per-boundary state, emit markup after a component
afterRenderComponent(id, vnode, html, ctx) {
return; // append nothing
},
// contribute modulepreload hints for chunks core won't schedule itself
assets(ctx) {
return; // nothing to preload
},
},
client: {
// return false to skip the default DOM walk (resumable SSR)
beforeHydrate(container) {
return; // run normal hydration
},
// hydration-time mirror of server.transformComponentContext
transformComponentContext(vnode, componentCtx) {
return; // accept as-is
},
// return a Node to "claim" a component during the hydration walk
hydrateComponent(vnode, dom, parent, regionEnd) {
return undefined; // let core hydrate it
},
},
};
const html = await createSSR({ plugins: [myPlugin] }).render(<App />);
Key hook semantics:
server.transformComponentContextruns after a component's context is built and beforesetup(), letting a plugin mutate or replace it — e.g. swapctx.signalfor a state-capturing variant.client.transformComponentContextis its hydration-time mirror (same timing, noSSRContextargument), so a strategy can swapctx.signalfor a state-restoring variant. The pair keeps render and hydration symmetric while core stays strategy-agnostic.server.resolveBoundaryruns before the context is built and beforesetup(), once per component, and the first plugin to return an object wins. Itsflushaxis decides whether the component renders on the server at all —flush: 'skip'suppresses setup entirely and emits the<div data-boundary="ID" style="display:contents;">wrapper around an optionalfallback— and itshydrateaxis is recorded in the boundary table for the client. This is how islands makeclient:onlyship no server HTML. See The boundary model.client.beforeHydratereturningfalseskips the default DOM walk — the basis for resumable SSR.client.hydrateComponentreturning aNodeclaims that component — the hook islands use to interceptclient:*props and schedule deferred hydration. Its fourth argument,regionEnd, is the exclusive end of the sibling range the component may own; a pack that locates trailing markers itself must bound the search by it, or a component followed by sibling content latches a child's marker and duplicates server-rendered content after a bail.server.afterRenderComponentis append-only (thehtmlargument is always''),server.assetscontributes modulepreload hints, andgetInjectedHTML/getStreamingChunksemit extra markup or streamed chunks.
You usually don't write this by hand. The islands strategy —
client:*directives, deferred hydration, signal-state transfer and per-island code splitting — is already implemented as a plugin in@sigx/ssr-islands. Reach for the raw SPI only when building a new strategy.
