rogue

SSR + Hydration

The server renders your pages to HTML, the browser parses that HTML into a DOM tree, and the framework adopts the existing tree on the client — it doesn't rebuild it. Subsequent updates mutate individual text nodes in place.

Why this matters

Most "SSR with hydration" frameworks render HTML on the server, ship it to the browser, then run the same component code again on the client just to build a parallel virtual-DOM tree they can diff against future renders. That second render is the part users feel as "the page froze for half a second after it loaded."

rogue skips the second render. The DOM the server produced is the working DOM. When a signal changes, the framework finds the specific text node or attribute that depends on it and updates that one node. There's no shadow tree being maintained on the side, no diff to compute, no client-side render to wait for.

Server side

For development, the rogueSsr() Vite plugin handles SSR inside the dev server — npm run dev renders pages server-side automatically. For production, you call render() directly:

import { createServer } from 'vite'
import { render } from '@jjordy/rogue/server'

const vite = await createServer({
  server: { middlewareMode: true },
  appType: 'custom',
})

const { html, status } = await render('/blog/hello', vite)

render() does the obvious thing: finds the matching route, runs its loader(), applies the layout chain, renders the JSX to a real DOM (via linkedom), then serializes it. The serialized output looks like normal HTML with one twist: each shadow root is emitted as a Declarative Shadow DOM template.

Declarative Shadow DOM

DSD is a relatively new browser feature: a inside a custom element tells the parser "attach this as a shadow root to the parent before any JS runs." That means:

  • The browser paints styled, fully-structured content from the first response byte. No FOUC, no unstyled flash while JS catches up.
  • Your custom-element scoping (the whole point of shadow DOM) is in effect during SSR — the browser doesn't need to wait for attachShadow() to run before respecting your styles.

Production: skipping Vite at runtime

For deployed apps you don't want Vite in the request path. The loadProdRenderer() helper boots SSR + actions from a pre-built bundle:

import { loadProdRenderer } from '@jjordy/rogue/server/prod'

const { render, handleAction } = await loadProdRenderer({
  clientDir: 'dist/client',   // output of: vite build
  serverDir: 'dist/server',   // output of: vite build --ssr src/entry-server.ts
})

Two vite build invocations produce both bundles, and loadProdRenderer reads the manifests once at startup. No bundler is loaded per request.

Hydration: how the client adopts the server's DOM

The trick is matching the JSX template (which the compiler knows the shape of) to the SSR'd DOM (which has the slot contents filled in). To do this, the compiler emits a pair of comment anchors around every dynamic slot:

<span>Count: 0span>

The anchors and bracket the slot content. The runtime walks the template with a slot-aware navigation function that treats each anchor pair as a single virtual child — so a path like "third child of root, second child of that" lines up the same way against the empty template (where the slot is empty) and against the SSR'd DOM (where the slot contains "0").

What "real hydration" means here

When a component boots client-side, instead of cloning a fresh template, it calls a function that returns the SSR'd root if it exists. The outermost component adopts the existing DOM — every node already in the page becomes the component's working tree.

The first time a reactive slot needs to display a value, it collects whatever sits between the open and close anchors as its current content. From then on, signal updates that resolve to the same primitive type (text → text) mutate the existing text node's nodeValue in place. No replaceChild, no DOM reconstruction.

Why not just innerHTML?

Setting innerHTML on the client throws away the DOM the parser produced and rebuilds it. Even if the rebuilt tree looks identical, you lose: input cursor positions, scroll state on scrollable children, in-progress CSS animations,

open/closed state. Adopting the parsed tree avoids that entire class of problems.

The hydration logic is covered by a smoke test that runs on every change: SSR a page, parse the HTML into a DOM, attach the framework, click a button, assert the right text node mutated instead of being replaced.

See it visualized

The showcase page has a live hydration inspector — toggle it to highlight the slot anchors in the rendered DOM and watch nodes flash green when they mutate in place. Click a counter; only one text node lights up.