// Signal SSR — render a signal component to an HTML string on the server, for
// hydration on the client.
//
// Unlike the legacy SSR path, signal hydration does NOT reuse server nodes via
// claim-markers: hydration rebuilds the (deterministic) tree client-side and
// atomically swaps it in (see `hydrateSignalApp`). So the serializer emits plain
// HTML with no `data-llui-hydrate` markers — the server pass exists for first
// paint / SEO; the client owns reconciliation from there.
import { renderSignalTree } from './mount.js'
import type { SignalDoc } from './build-context.js'
import { pathHandle } from './handle.js'
import { normalizeUpdateResult, type SignalComponentDef } from './component.js'
/** A server DOM document: the node-factory subset the build needs. A `DomEnv`
* from `@llui/dom/ssr/jsdom` or `@llui/dom/ssr/linkedom` satisfies it. */
export type ServerDoc = SignalDoc
const VOID_ELEMENTS = new Set([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr',
])
function escapeHtml(s: string): string {
return s.replace(/&/g, '&').replace(//g, '>')
}
// Raw-text elements: the HTML parser reads their content verbatim (no character
// references), so running `escapeHtml` over inline CSS/JS would corrupt it —
// `a > b { }` must NOT become `a > b { }`. (`textarea`/`title` are *escapable*
// raw text and DO use normal escaping, so they stay on the default text path.)
const RAW_TEXT_ELEMENTS = new Set(['script', 'style'])
/** Emit raw-text-element content verbatim, but neutralize any literal close
* sequence (`` // comment
if (node.nodeType === 11) {
// document fragment — serialize its children (portals/structural slots)
let out = ''
for (let i = 0; i < node.childNodes.length; i++) out += nodeToString(node.childNodes[i]!)
return out
}
if (node.nodeType !== 1) return ''
const el = node as Element
const tag = el.tagName.toLowerCase()
let attrs = ''
for (let i = 0; i < el.attributes.length; i++) {
const attr = el.attributes[i]!
// Skip only GENUINE inline event-handler attributes (onclick, oninput, …) —
// the DOM exposes a matching `on*` property slot for those. A bare
// `startsWith('on')` also dropped legitimate custom attributes (a web
// component's `on-theme`, `ontology`, …); this keeps them.
if (/^on[a-z]/.test(attr.name) && attr.name in el) continue
attrs += ` ${attr.name}="${escapeAttr(attr.value)}"`
}
// Form-control state lives on IDL PROPERTIES, not content attributes:
// `applyAttr` routes `value`/`checked`/`selected` to `node.value` / `.checked` /
// `.selected` (element.ts), which do NOT reflect to attributes — and the loop
// above reads only attributes. Without this, a server-rendered form paints empty
// (blank inputs, unchecked boxes, no selected option) until the client hydrate
// swaps it in. Serialize the IDL state into the equivalent attribute/text so the
// first paint (and the no-JS / SEO output) is correct.
attrs += serializeFormState(el, tag)
if (VOID_ELEMENTS.has(tag)) return `<${tag}${attrs} />`
if (tag === 'textarea') {
// A textarea's value is its child TEXT, but `applyAttr('value')` set the IDL
// `.value` (no child node). Emit the current value as escaped text content.
const value = (el as unknown as { value?: string }).value ?? el.textContent ?? ''
return ``
}
if (RAW_TEXT_ELEMENTS.has(tag)) {
// Content is verbatim (see RAW_TEXT_ELEMENTS): concatenate raw text, guarded.
return `<${tag}${attrs}>${guardRawText(el.textContent ?? '', tag)}${tag}>`
}
let children = ''
for (let i = 0; i < el.childNodes.length; i++) children += nodeToString(el.childNodes[i]!)
return `<${tag}${attrs}>${children}${tag}>`
}
/** Serialize an array of (already-built) DOM nodes to an HTML string. Used by
* adapters (`@llui/vike`) that compose layout + page node trees before one final
* serialization pass. */
export function serializeNodes(nodes: readonly Node[]): string {
let html = ''
for (const node of nodes) html += nodeToString(node)
return html
}
/**
* Build a signal component's DOM tree on the server, returning the (detached)
* nodes plus a `dispose` that runs the build's teardowns. The caller composes /
* serializes the nodes; effects are NOT dispatched (server render is pure).
*
* For persistent layouts, compose multiple `renderNodes` results before
* `serializeNodes` so the layout/page trees are stitched at the slot position.
*/
export function renderNodes(
def: SignalComponentDef,
initialState: S | undefined,
env: ServerDoc,
// Adapter seed: context values to expose at the root of this build (see
// `renderSignalTree`). `@llui/vike` replays a layout's in-scope contexts here
// so a nested page renders against providers that live above its slot.
contexts?: ReadonlyMap,
): { nodes: readonly Node[]; dispose: () => void } {
const [seed] = normalizeInit(def, initialState)
const handle = pathHandle(() => seed, '')
const noopSend = (): void => {}
// SSR has no live document to reconcile; `batch` just runs its body (whose sends
// are no-ops here), matching the no-op send.
const noopBatch = (fn: () => void): void => fn()
const tree = renderSignalTree(
env,
() => def.view({ state: handle, send: noopSend, batch: noopBatch }),
contexts,
true, // ssr: skip the mount lifecycle (onMount) at every depth — see BuildCtx.ssr
)
// Mount on the detached tree to bake initial values into the serialized HTML.
// The `ssr` flag above means onMount callbacks are never registered (a DOM-less
// server render can't run them, and a browser-global in the body would throw);
// portals with no explicit target are already client-only (see `buildPortal`).
tree.mount(seed)
return {
nodes: tree.nodes,
dispose: () => {
for (const t of tree.teardowns.splice(0)) t()
},
}
}
/**
* Render a signal component to an HTML string against the initial state (or a
* provided override). `env` is a server `DomEnv` from `@llui/dom/ssr/jsdom` or
* `@llui/dom/ssr/linkedom`.
*/
export function renderToString(
def: SignalComponentDef,
initialState: S | undefined,
env: ServerDoc,
): string {
const { nodes, dispose } = renderNodes(def, initialState, env)
try {
return serializeNodes(nodes)
} finally {
dispose()
}
}
function normalizeInit(def: SignalComponentDef, override: S | undefined): [S] {
if (override !== undefined) return [override]
return [normalizeUpdateResult(def.init())[0]]
}