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 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: {
        // mutate/replace a component's context after it's built, before setup()
        transformComponentContext(ctx, vnode, componentCtx) {
            return; // accept as-is
        },
        // skip a component's setup/render and emit a placeholder string
        suppressComponentRender(id, vnode, ctx) {
            return; // render normally
        },
        // choose block | stream | skip for async components, inject HTML, etc.
        handleAsyncSetup(id, ssrLoads, renderFn, ctx) {
            return { mode: 'stream' };
        },
    },
    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) {
            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.suppressComponentRender runs after transformComponentContext and before setup(); returning an object skips the component's setup/render and emits the (optional) placeholder string in its place. This is how islands make client:only ship no server HTML — an empty <div data-island> placeholder. Works in both string and streaming modes.
  • 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.
  • server.handleAsyncSetup chooses block, stream, or skip per async component; getInjectedHTML and getStreamingChunks let a plugin 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#