Archived
update spec: mark ServerBackend SSR implemented, document hydration
Bumps spec version to 1.2.0. Documents: - SSR engine in runtime/src/ssr.js (Node.js, fully implemented) - Hydration markers (data-el-component, data-el-id, data-el-state) - hydrate() vs mount() and when to use each - Full SSR + hydration lifecycle with code examples - elc-ui CLI --target=server and --target=web flags - Section 11: complete SSR + hydration walkthrough - Section 12: versioning roadmap updated to reflect v0.1 actual state - Section 9: clarifies that SSR is JS-implemented, Rust PlatformBackend is planned
This commit is contained in:
+203
-13
@@ -1,6 +1,6 @@
|
||||
# el-ui Framework Specification
|
||||
|
||||
Version 1.1.0 — April 30, 2026
|
||||
Version 1.2.0 — May 4, 2026
|
||||
|
||||
---
|
||||
|
||||
@@ -516,11 +516,13 @@ For each component:
|
||||
7. Emits event handlers as `data-el-{event}` attributes for the renderer to bind.
|
||||
8. Emits `${__self._child(ComponentClass, props)}` for component references.
|
||||
|
||||
#### `CodegenTarget::Server` — Rust SSR
|
||||
#### `CodegenTarget::Server` — Node.js SSR
|
||||
|
||||
Emits a Rust module where each component is a struct implementing `render_to_html(&self) -> String` via `el_platform::ServerBackend`.
|
||||
Emits a Node.js CommonJS module (the "render script") that exports `renderToString(componentName, props)` and `renderToDocument(componentName, props, shellOpts)`. The render script uses `runtime/src/ssr.js` to parse the component source at module load time and render to HTML strings at call time.
|
||||
|
||||
**Note:** The `build_node_tree()` implementation in this target is a stub — it returns a placeholder `PlatformNode::element("div")`. Full template-to-PlatformNode codegen is not yet implemented for the server target.
|
||||
The `build_node_tree()` / template-to-HTML pipeline is implemented in `runtime/src/ssr.js`. It covers all TemplateNode variants: `Element`, `Text`, `Interpolation`, `If`, `Each`, `Activate`, `Component`, and `RawHtml`. The `{#activate}` block renders empty on the server (no Engram server available at SSR time); the client runtime re-runs semantic queries after hydration.
|
||||
|
||||
The `ServerBackend` target is the only target where hydration markers are injected: `data-el-component`, `data-el-id`, and `data-el-state` on each component root element.
|
||||
|
||||
#### `CodegenTarget::Native(Platform)` — Rust Native / WASM
|
||||
|
||||
@@ -578,12 +580,26 @@ Appearance is inferred from explicit `appearance="..."` attributes, CSS class na
|
||||
|
||||
### 7.6 CLI
|
||||
|
||||
The `elc-ui` Node.js script in `vessels/el-ui-compiler/elc-ui` exposes the compiler as a command-line tool:
|
||||
|
||||
```bash
|
||||
el-ui-compiler App.el # compiles to App.js (Web target, default)
|
||||
el-ui-compiler App.el -o app.js # explicit output path
|
||||
elc-ui App.el # compile to App.js (web target, default)
|
||||
elc-ui App.el -o app.js # explicit output path
|
||||
elc-ui App.el --target=server # emit Node.js render script
|
||||
elc-ui App.el --target=server -o render.js
|
||||
elc-ui App.el --runtime=../el-ui.js # custom runtime path for web target
|
||||
```
|
||||
|
||||
The CLI always uses `CodegenTarget::Web`. Target selection (`--target server`, `--target ios`, etc.) is available as a `CodegenTarget` enum in the library API but is not exposed as a CLI flag in the current implementation.
|
||||
**Web target** emits an ES2022 JavaScript module — the same class-per-component format as the hand-compiled examples in `examples/counter/app.js`. Requires the el-ui runtime (`el-ui.js` or `dist/el-ui.js`) to be available at the path specified by `--runtime`.
|
||||
|
||||
**Server target** emits a Node.js CJS module that:
|
||||
- Embeds the component source at compile time (base64-encoded)
|
||||
- Exports `renderToString(componentName, props)` → HTML string
|
||||
- Exports `renderToDocument(componentName, props, shellOpts)` → full HTML document
|
||||
- Exports `componentNames` → array of component names found in the source
|
||||
- Supports CLI invocation: `node render.js ComponentName '{"prop":"val"}'`
|
||||
|
||||
The server render script is self-contained: it does not require re-reading the `.el` source file at render time. The El web server calls it via `exec()` and captures stdout as the rendered HTML string.
|
||||
|
||||
---
|
||||
|
||||
@@ -597,15 +613,26 @@ The CLI always uses `CodegenTarget::Web`. Target selection (`--target server`, `
|
||||
| `activation.js` | Standalone activation utilities: `spreadActivation`, `activationStrength`, `reachableNodes` |
|
||||
| `renderer.js` | DOM patching, event binding |
|
||||
| `router.js` | Graph-based routing |
|
||||
| `index.js` | `Component` base class, `mount()`, re-exports |
|
||||
| `index.js` | `Component` base class, `mount()`, `hydrate()`, re-exports |
|
||||
| `ssr.js` | Server-side rendering engine (Node.js only, not a browser module) |
|
||||
|
||||
### 8.2 Component Lifecycle
|
||||
|
||||
**Client-side (mount path):**
|
||||
|
||||
| Lifecycle event | Description |
|
||||
|-----------------|-------------|
|
||||
| `constructor(props)` | Seeds state graph, subscribes to activation |
|
||||
| `onMount()` | Called after first render and DOM attachment |
|
||||
| `constructor(props, initialState?)` | Seeds state graph, subscribes to activation. `initialState` is used by `hydrate()` to pre-load server state. |
|
||||
| `render()` | Returns HTML string; called on activation |
|
||||
| `onMount()` | Called after first render and DOM attachment |
|
||||
|
||||
**SSR + hydration path:**
|
||||
|
||||
| Lifecycle event | Description |
|
||||
|-----------------|-------------|
|
||||
| `constructor(props, initialState)` | Called by `hydrate()` with recovered server state |
|
||||
| `_hydrate(root)` | Subscribes state nodes, creates renderer pointing at existing DOM, binds events. Does NOT call `render()`. |
|
||||
| `onHydrate()` | Called after hydration completes |
|
||||
|
||||
**Note:** `onDestroy()` is documented in prior drafts but is not implemented in the base `Component` class or the `Renderer`. It will not be called by the runtime.
|
||||
|
||||
@@ -621,6 +648,73 @@ const component = mount(App, '#app', { optionalProp: 'value' });
|
||||
|
||||
`mount` instantiates the root component, creates a `Renderer`, calls `renderer.mount()` which calls `render()`, sets `root.innerHTML`, binds events, then calls `onMount()`.
|
||||
|
||||
### 8.5 Hydration
|
||||
|
||||
```javascript
|
||||
import { hydrate } from './el-ui.js';
|
||||
import { Counter } from './app.js';
|
||||
|
||||
// Hydrate a server-rendered Counter without replacing the DOM
|
||||
const component = hydrate('[data-el-component="Counter"]', Counter);
|
||||
// or with a stable ID for multi-instance pages:
|
||||
const component = hydrate('[data-el-id="el-counter-1"]', Counter);
|
||||
```
|
||||
|
||||
`hydrate` finds the SSR root element, reads `data-el-state` to recover the server's initial state, constructs the component with that state pre-loaded, then calls `_hydrate(root)` which binds all event handlers without re-rendering. The existing server-rendered DOM is preserved; spreading activation begins on the first `setState()` call.
|
||||
|
||||
**SSR lifecycle:**
|
||||
|
||||
```
|
||||
Server: renderToString(src, 'App', props) → HTML with markers
|
||||
Network: HTML delivered, painted immediately (zero JS latency)
|
||||
Client: import { hydrate } from './el-ui.js'
|
||||
hydrate('[data-el-component="App"]', App)
|
||||
→ events bound, spreading activation active
|
||||
→ subsequent setState() calls patch the DOM normally
|
||||
```
|
||||
|
||||
### 8.6 Server-Side Rendering
|
||||
|
||||
The SSR engine in `runtime/src/ssr.js` is a Node.js module that parses `.el` source files and renders components to HTML strings:
|
||||
|
||||
```javascript
|
||||
const { renderToString, renderToDocument, parseComponents } = require('./runtime/src/ssr.js');
|
||||
const fs = require('fs');
|
||||
|
||||
const src = fs.readFileSync('App.el', 'utf8');
|
||||
|
||||
// Render to HTML fragment
|
||||
const html = renderToString(src, 'App', { title: 'Hello' });
|
||||
|
||||
// Render to full document
|
||||
const doc = renderToDocument(src, 'App', { title: 'Hello' }, {
|
||||
title: 'My App',
|
||||
appScriptPath: './hydrate.js',
|
||||
styles: 'body { background: #000; }',
|
||||
});
|
||||
```
|
||||
|
||||
**Hydration markers** emitted on every component root element:
|
||||
|
||||
| Attribute | Value | Purpose |
|
||||
|-----------|-------|---------|
|
||||
| `data-el-component` | component name | identifies component type for `hydrate()` |
|
||||
| `data-el-id` | stable position-based ID | locates the exact instance when multiple components of the same type exist |
|
||||
| `data-el-state` | JSON string | server's initial state snapshot; `hydrate()` reads this to initialize the Engram graph |
|
||||
|
||||
Event bindings are preserved as `data-el-*` attributes (e.g., `data-el-click="() => increment()"`). The client hydration pass binds these handlers via `new Function('__self', ...)` exactly as the standard mount path does.
|
||||
|
||||
**Server/client bridge:**
|
||||
|
||||
The El web server calls the render script as a subprocess:
|
||||
|
||||
```bash
|
||||
node render.js ComponentName '{"prop":"value"}'
|
||||
# stdout: the rendered HTML string
|
||||
```
|
||||
|
||||
The render script is generated by `elc-ui --target=server`. It embeds the component source at compile time, so no `.el` file is needed at render time.
|
||||
|
||||
### 8.4 DOM Patching Strategy (v0.1)
|
||||
|
||||
The renderer uses full string re-render on every state change: `root.innerHTML = component.render()`. The activated node set argument to `patch()` is accepted but unused — targeted patching is planned for v0.2.
|
||||
@@ -653,7 +747,7 @@ pub trait PlatformBackend: Send + Sync {
|
||||
}
|
||||
```
|
||||
|
||||
The `ServerBackend` is the only backend where `supports_ssr()` returns `true` and `render_to_string()` produces real HTML output. All other backends are platform-native.
|
||||
The `ServerBackend` is the only backend where `supports_ssr()` returns `true`. In v0.1, the server rendering implementation is in `runtime/src/ssr.js` (Node.js), not in a Rust crate. The `PlatformBackend` interface above describes the planned Rust abstraction; the current implementation fulfills the same contract via the JavaScript SSR engine, which is the authoritative server rendering path. All other backends are platform-native.
|
||||
|
||||
Platform is selected via `manifest.el`:
|
||||
|
||||
@@ -691,11 +785,107 @@ No prior framework uses Hebbian spreading activation as the re-render decision m
|
||||
|
||||
---
|
||||
|
||||
## 11. Versioning Roadmap
|
||||
## 11. SSR + Hydration Example
|
||||
|
||||
A complete SSR + hydration example lives in `examples/ssr-counter/`.
|
||||
|
||||
### 11.1 Counter.el
|
||||
|
||||
The component definition with state, methods, `{#if}` conditionals, and child composition:
|
||||
|
||||
```
|
||||
component Counter {
|
||||
props {
|
||||
label: String = "Counter"
|
||||
}
|
||||
state {
|
||||
count: Int = 0
|
||||
history: String = ""
|
||||
}
|
||||
fn increment() -> Void {
|
||||
count = count + 1
|
||||
history = history + "+" + count + " "
|
||||
}
|
||||
template {
|
||||
<div class="counter">
|
||||
<h2>{label}</h2>
|
||||
<span class={count > 0 ? "count positive" : "count zero"}>{count}</span>
|
||||
<button on:click={() => increment()}>+</button>
|
||||
{#if history != ""}
|
||||
<code>{history}</code>
|
||||
{/if}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11.2 Compile
|
||||
|
||||
```bash
|
||||
# Server render script (embed source, export renderToString)
|
||||
elc-ui Counter.el --target=server -o render.js
|
||||
|
||||
# Browser module (class-per-component, for hydration)
|
||||
elc-ui Counter.el --target=web --runtime=../../dist/el-ui.js -o app.js
|
||||
```
|
||||
|
||||
### 11.3 Server Render
|
||||
|
||||
```bash
|
||||
# Render to HTML fragment
|
||||
node render.js Counter
|
||||
# => <div class="counter" data-el-component="Counter" data-el-id="el-counter-1"
|
||||
# data-el-state='{"count":0,"history":""}'>
|
||||
# <h2>Counter</h2>
|
||||
# <span class="count zero">0</span>
|
||||
# <button data-el-click="() => increment()">+</button>
|
||||
# <p class="hint">Press + or - to begin.</p>
|
||||
# </div>
|
||||
```
|
||||
|
||||
Or from Node.js:
|
||||
|
||||
```javascript
|
||||
const { renderToString } = require('./render.js');
|
||||
const html = renderToString('Counter', { label: 'Score' });
|
||||
```
|
||||
|
||||
### 11.4 Client Hydration
|
||||
|
||||
```javascript
|
||||
// hydrate.js — runs in browser after SSR HTML is painted
|
||||
import { hydrate } from '../../dist/el-ui.js';
|
||||
import { Counter } from './app.js';
|
||||
|
||||
// Find each server-rendered Counter and attach the runtime
|
||||
document.querySelectorAll('[data-el-component="Counter"]').forEach(el => {
|
||||
if (el.dataset.elHydrated) return;
|
||||
hydrate(`[data-el-id="${el.dataset.elId}"]`, Counter);
|
||||
el.dataset.elHydrated = 'true';
|
||||
});
|
||||
```
|
||||
|
||||
### 11.5 Full Lifecycle
|
||||
|
||||
```
|
||||
1. Server calls: node render.js App
|
||||
2. Server injects: rendered HTML into <div id="app">
|
||||
3. Browser: HTML painted immediately (no JS needed for first paint)
|
||||
4. Browser: <script type="module" src="./hydrate.js"> executes
|
||||
5. hydrate.js: reads data-el-state, constructs Counter with server state
|
||||
6. hydrate.js: binds data-el-click handlers without re-rendering DOM
|
||||
7. User clicks +: count = count + 1 → setState → spreading activation → patch
|
||||
```
|
||||
|
||||
The DOM is never blank. The page is interactive as soon as hydrate.js finishes executing. State is continuous across the SSR/hydration boundary because `data-el-state` carries the exact initial values the server used.
|
||||
|
||||
---
|
||||
|
||||
## 12. Versioning Roadmap
|
||||
|
||||
| Version | Key changes |
|
||||
|---------|-------------|
|
||||
| v0.1.x | Current. Full re-render on state change. String-based `{#activate}` search. JS (Web) target only via CLI. Native targets available via library API but `build_node_tree()` is stub. |
|
||||
| v0.1.x | Current. Full re-render on state change. String-based `{#activate}` search. Web and Server targets via `elc-ui` CLI. SSR engine implemented (`runtime/src/ssr.js`). `hydrate()` function in client runtime. Native targets available via library API but `build_node_tree()` stub. |
|
||||
| v0.2.x | Targeted DOM patching (only nodes in activation surface). `{#activate}` with embedding-based semantic search. CLI `--target` flag for native targets. Full AST→semantic→PlatformNode lowering. |
|
||||
| v0.3.x | WASM-compiled Engram core replacing JavaScript graph. Sealed artifact support. `onDestroy()` lifecycle. |
|
||||
| v1.0.0 | Stable API. Full production sealing. LSP integration with spreading activation autocomplete. |
|
||||
|
||||
Reference in New Issue
Block a user