implement el-ui SSR engine with renderToString and hydration markers

Adds runtime/src/ssr.js: a Node.js SSR engine that parses .el component
source and renders to HTML strings without a DOM. Covers the full template
AST -- elements, text, interpolations, {#if}, {#each}, {#activate}, and
child component recursion.

Every component root element receives hydration markers:
  data-el-component, data-el-id, data-el-state

Event bindings (on:click etc.) are preserved as data-el-* attributes so
the client hydration pass can bind them without re-rendering.

Updates Component base class with:
  - initialState constructor param for SSR state recovery
  - _hydrate(root): binds events on existing DOM, no re-render
  - onHydrate() lifecycle hook

Updates dist/el-ui.js bundle to match.
This commit is contained in:
Will Anderson
2026-05-04 12:45:27 -05:00
parent a9adea3b96
commit d24bb29817
3 changed files with 1095 additions and 7 deletions
+35 -3
View File
@@ -351,15 +351,16 @@ export class Router {
}
}
// ── Component base class & mount() ───────────────────────────────────────────
// ── Component base class, mount(), and hydrate() ─────────────────────────────
export class Component {
constructor(props = {}) {
constructor(props = {}, initialState = {}) {
this._graph = new Graph();
this._stateNodes = {};
this._state = {};
this.props = props;
this._renderer = null;
this._ssrState = initialState;
}
setState(name, value) {
@@ -382,6 +383,20 @@ export class Component {
render() { return ''; }
onMount() {}
onHydrate() {}
_hydrate(root) {
for (const [key, nodeId] of Object.entries(this._stateNodes)) {
this._graph.subscribe(nodeId, (node) => {
this._state[key] = node.content;
if (this._renderer) this._renderer.patch();
});
}
this._renderer = new Renderer(root, this);
this._renderer._currentHtml = root.innerHTML;
this._renderer._bindEvents();
if (typeof this.onHydrate === 'function') this.onHydrate();
}
}
export function mount(ComponentClass, selector, props = {}) {
@@ -394,4 +409,21 @@ export function mount(ComponentClass, selector, props = {}) {
return component;
}
export default { mount, Component, Graph, Renderer, Router };
export function hydrate(selector, ComponentClass, props = {}) {
const el = document.querySelector(selector);
if (!el) throw new Error(`el-ui hydrate: element not found: ${selector}`);
const stateJson = el.getAttribute('data-el-state');
const initialState = stateJson ? JSON.parse(stateJson) : {};
const instance = new ComponentClass(props, initialState);
for (const [key, value] of Object.entries(initialState)) {
if (instance._stateNodes[key] !== undefined) {
const node = instance._graph.get(instance._stateNodes[key]);
if (node) node.content = value;
instance._state[key] = value;
}
}
instance._hydrate(el);
return instance;
}
export default { mount, hydrate, Component, Graph, Renderer, Router };