Server/Packages/Server Renderer/Hydration & head
@sigx/server-renderer · Stable

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:

TSX
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:

TSX
// 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:

TSX
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:

TSX
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:

TSX
// 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.

HalfEntryWhat 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 coreloaded 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.

TypeScript
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:

TypeScript
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 to createSSR({ plugins: [myPlugin] }). (Published packs like islandsPlugin() are SSRPacks — an SSRPlugin plus an install(app) method — so you install those on the app with app.use().)
  • Register client hooks with registerClientPlugin(plugin).
TSX
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.transformComponentContext runs after a component's context is built and before setup(), letting a plugin mutate or replace it — e.g. swap ctx.signal for a state-capturing variant. client.transformComponentContext is its hydration-time mirror (same timing, no SSRContext argument), so a strategy can swap ctx.signal for a state-restoring variant. The pair keeps render and hydration symmetric while core stays strategy-agnostic.
  • server.resolveBoundary runs before the context is built and before setup(), once per component, and the first plugin to return an object wins. Its flush axis 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 optional fallback — and its hydrate axis is recorded in the boundary table for the client. This is how islands make client:only ship no server HTML. See The boundary model.
  • client.beforeHydrate returning false skips the default DOM walk — the basis for resumable SSR.
  • client.hydrateComponent returning a Node claims that component — the hook islands use to intercept client:* 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.afterRenderComponent is append-only (the html argument is always ''), server.assets contributes modulepreload hints, and getInjectedHTML / getStreamingChunks emit 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.

Next steps#