add ssr-counter example demonstrating SSR + hydration end-to-end

Counter.el defines Counter, CounterPair, and App components with {#if}
conditionals, event handlers, and child component composition.

render.js (generated by elc-ui --target=server) is the Node.js render
script the server calls. app.js (generated by elc-ui --target=web) is the
compiled browser module. hydrate.js attaches the runtime to the
server-rendered DOM without replacing it.

index.html shows the integration point with instructions for injecting
server output and the two CLI commands needed to regenerate both targets.
This commit is contained in:
Will Anderson
2026-05-04 12:49:19 -05:00
parent f8e32e7719
commit 143137e2b4
6 changed files with 632 additions and 10 deletions
+297
View File
@@ -0,0 +1,297 @@
<!DOCTYPE html>
<!--
ssr-counter/index.html — el-ui SSR + Hydration Example
In production, the content of <div id="app"> is generated by the server
calling:
const { renderToString } = require('./render.js');
const html = renderToString('App', {});
This static file simulates that by inlining the rendered HTML directly.
The client hydration script (hydrate.js) attaches to the existing DOM
without replacing it.
To see server rendering live:
node render.js App > /tmp/rendered.html
# then inline that output into the #app div below
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ssr-counter — el-ui SSR + Hydration</title>
<style>
:root {
--bg: #080b0f;
--bg2: #0d1117;
--bg3: #111820;
--text: #e8edf3;
--text2: #7a8a9a;
--accent: #38bdf8;
--accent-dim: rgba(56,189,248,0.12);
--accent-glow: rgba(56,189,248,0.4);
--green: #4ade80;
--red: #f87171;
--border: rgba(255,255,255,0.08);
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'DM Mono', 'JetBrains Mono', 'Fira Code', monospace;
min-height: 100vh;
}
.app {
max-width: 900px;
margin: 0 auto;
padding: 48px 24px;
}
.app-header {
text-align: center;
margin-bottom: 48px;
}
.app-header h1 {
font-size: 32px;
font-weight: 700;
color: var(--accent);
letter-spacing: -0.03em;
margin-bottom: 8px;
}
.subtitle {
color: var(--text2);
font-size: 14px;
}
.app-main {
display: flex;
flex-direction: column;
gap: 32px;
align-items: center;
}
.counter {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 32px 40px;
min-width: 280px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.counter-label {
font-size: 14px;
color: var(--text2);
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 600;
}
.count-display {
display: flex;
align-items: center;
justify-content: center;
}
.count {
font-size: 72px;
font-weight: 700;
font-variant-numeric: tabular-nums;
min-width: 120px;
text-align: center;
letter-spacing: -0.03em;
transition: color 0.15s;
}
.count.zero { color: var(--text2); }
.count.positive { color: var(--accent); text-shadow: 0 0 40px var(--accent-glow); }
.count.negative { color: var(--red); text-shadow: 0 0 40px rgba(248,113,113,0.4); }
.buttons {
display: flex;
gap: 12px;
}
.btn {
background: var(--accent-dim);
border: 1px solid var(--accent);
color: var(--accent);
font-size: 18px;
font-family: inherit;
padding: 0 20px;
height: 48px;
border-radius: var(--radius);
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.btn:hover {
background: rgba(56,189,248,0.22);
box-shadow: 0 0 16px var(--accent-glow);
}
.btn:active { transform: scale(0.95); }
.btn-reset {
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
background: rgba(255,255,255,0.04);
border-color: var(--border);
color: var(--text2);
}
.btn-reset:hover {
background: rgba(255,255,255,0.08);
box-shadow: none;
}
.btn-decrement {
color: var(--red);
border-color: var(--red);
background: rgba(248,113,113,0.08);
}
.btn-decrement:hover {
background: rgba(248,113,113,0.18);
box-shadow: 0 0 16px rgba(248,113,113,0.3);
}
.history {
font-size: 12px;
color: var(--text2);
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
justify-content: center;
}
.history-label {
color: var(--text2);
opacity: 0.6;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
code {
color: var(--accent);
font-family: inherit;
}
.hint {
font-size: 12px;
color: var(--text2);
opacity: 0.5;
}
.counter-pair {
display: flex;
gap: 24px;
flex-wrap: wrap;
justify-content: center;
}
.el-badge {
position: fixed;
bottom: 16px;
right: 16px;
font-size: 10px;
color: var(--text2);
opacity: 0.5;
letter-spacing: 0.1em;
text-transform: uppercase;
}
/* SSR indicator — shows before hydration completes */
.ssr-note {
text-align: center;
font-size: 11px;
color: var(--text2);
opacity: 0.4;
margin-bottom: 24px;
letter-spacing: 0.06em;
}
</style>
</head>
<body>
<!--
In a real server setup, this div's content is injected by the server:
const { renderToString } = require('./render.js');
res.send(`<div id="app">${renderToString('App', {})}</div>`);
To preview server output:
node render.js App
Below is the static simulation of that output.
The data-el-* attributes are the hydration markers written by the SSR engine.
-->
<div id="app">
<!-- SSR output placeholder. Run: node render.js App and paste here. -->
<!-- Or open this file with a server that injects the SSR output above. -->
<!--
Example of what the server renders (simplified for readability):
<div class="app"
data-el-component="App"
data-el-id="el-app-1"
data-el-state="{}">
<header class="app-header">
<h1>el-ui SSR + Hydration</h1>
<p class="subtitle">Server-rendered, client-hydrated with spreading activation</p>
</header>
<main class="app-main">
<div class="counter"
data-el-component="Counter"
data-el-id="el-counter-1"
data-el-state='{"count":0,"history":""}'>
...
</div>
</main>
</div>
-->
</div>
<p class="ssr-note">el-ui SSR + hydration · no DOM replacement on hydrate</p>
<div class="el-badge">el-ui · spreading activation</div>
<!--
Hydration script.
- app.js: the compiled component classes (web target, for render() and setState())
- hydrate.js: attaches the runtime to the server-rendered DOM
Both are type="module" so they run after the document is parsed.
The SSR HTML is already painted before these scripts execute.
-->
<script type="module" src="./hydrate.js"></script>
<!--
Integration note for the El web server:
===========================================
The El web server (C binary) renders pages by calling:
node render.js App '{}'
and capturing stdout as the HTML to inject into the template above.
This is the simplest bridge: no Node.js server needed, just exec().
A tighter integration embeds Node.js (via libnode or napi) and calls
renderToString() directly without subprocess overhead — appropriate for
high-traffic pages. The render script API is identical either way.
-->
</body>
</html>