hypen-engine 0.4.94

A Rust implementation of the Hypen engine
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
# Architecture Internals

This document describes the internal architecture of the Hypen Engine, including the reactive system, reconciliation algorithm, IR expansion, and patch generation.

## Table of Contents

- [System Overview]#system-overview
- [IR System]#ir-system
- [Reactive System]#reactive-system
- [Reconciliation Algorithm]#reconciliation-algorithm
- [Patch Generation]#patch-generation
- [Instance Tree]#instance-tree
- [Component Resolution]#component-resolution
- [State Management]#state-management

---

## System Overview

The engine follows a unidirectional data flow:

```
Hypen DSL Source
┌─────────────┐
│   Parser    │  Chumsky combinator parser
│  (AST)      │  ComponentSpecification → arguments, applicators, children
└──────┬──────┘
       │ ast_to_ir_node()
┌─────────────┐
│   IR        │  Element / IRNode (ForEach, Conditional, Router)
│  Expansion  │  Component registry, slot resolution, Tailwind expansion
└──────┬──────┘
┌─────────────┐
│  Reactive   │  DependencyGraph: path → NodeId mapping
│  System     │  Prefix index for O(log n) affected-node lookup
└──────┬──────┘
┌─────────────┐
│  Reconciler │  Keyed diffing with LIS algorithm
│  (diff.rs)  │  InstanceTree management
└──────┬──────┘
┌─────────────┐
│  Patches    │  Create, SetProp, SetText, Insert, Move, Remove
│  (output)   │  Platform-agnostic mutation instructions
└──────┬──────┘
┌─────────────┐
│  Renderer   │  DOM, Canvas, iOS, Android, Remote UI
│  (platform) │  Applies patches to platform objects
└─────────────┘
```

## IR System

### Source Files

- `src/ir/node.rs` — Core types: `IRNode`, `Element`, `Value`, `Props`
- `src/ir/expand.rs` — AST-to-IR lowering, Tailwind expansion, binding extraction
- `src/ir/component.rs` — Component registry, resolution, passthrough/lazy components

### IRNode Enum

Control flow is represented as first-class IR nodes:

```rust
pub enum IRNode {
    Element(Element),           // Regular UI element
    ForEach { ... },            // Iteration construct
    Conditional { ... },        // When/If construct
    Router { ... },             // URL-based routing with Route children
}
```

This design enables exhaustive pattern matching in the reconciler, so the compiler catches any missing control flow handling.

### Value Types

Props can hold five types of values:

```rust
pub enum Value {
    Static(serde_json::Value),       // Literal: "hello", 42, true
    Binding(Binding),                 // @{state.user.name}
    TemplateString {                  // "Count: @{state.count}"
        template: String,
        bindings: Vec<Binding>,
    },
    Action(String),                   // @actions.signIn
    Resource(String),                 // @resources.heart (resolved via ResourceRegistry)
}
```

### Props (Arc-Wrapped)

Props use `Arc<IndexMap<String, Value>>` for O(1) cloning during reconciliation:

```rust
pub struct Props(Arc<PropsMap>);
```

Copy-on-write semantics: calling `make_mut()` clones the inner map only if there are multiple references. This means cloning an element during reconciliation is nearly free, while mutations only pay the cost when necessary.

### AST-to-IR Conversion

The `ast_to_ir_node()` function detects control flow components by name:

```
"ForEach"    → IRNode::ForEach { source, item_name, key_path, template, props }
"When"       → IRNode::Conditional { value, branches, fallback }
"If"         → IRNode::Conditional { value, [true-branch], fallback }
"Router"     → IRNode::Router { location, routes, fallback }
"List"/"Grid" → IRNode::Element wrapping an IRNode::ForEach child (so the
                renderer keeps the container element type for native list
                virtualization while iteration goes through ForEach semantics)
other        → IRNode::Element(element)
```

During conversion:
1. Arguments are mapped to props (named or positional)
2. Applicators become props with dotted keys (e.g., `.fontSize(18)``fontSize.0: 18`)
3. `.tw()` applicators are expanded to individual CSS properties via `hypen-tailwind-parse`
4. `@{state.xxx}` strings are parsed into `Value::Binding`
5. `@actions.xxx` strings become `Value::Action`
6. Template strings with mixed bindings become `Value::TemplateString`

---

## Reactive System

### Source Files

- `src/reactive/binding.rs` — Binding parsing and representation
- `src/reactive/graph.rs` — Dependency graph with prefix index

### Binding Model

Bindings represent reactive references to state paths:

```rust
pub struct Binding {
    pub source: BindingSource,  // State, Item, or DataSource(provider)
    pub path: Vec<String>,      // ["user", "name"]
}
```

Three sources:
- `BindingSource::State``@{state.user.name}` references module state
- `BindingSource::Item``@{item.name}` references the current iteration item in a ForEach
- `BindingSource::DataSource(provider)``@spacetime.messages` references a registered data-source context, populated by the host via `set_context(name, data)`. Note: data-source bindings use the `@provider.path` syntax (no `@{...}` wrapper); the parser rejects `@{provider.path}` to prevent typos from silently becoming data-source references.

Module scope is **not** a binding source — it's an orthogonal `module_scope: Option<String>` field on `Element` and control-flow nodes, applied at reconcile time. See ENGINE_CONTRACT.md §11 for the full namespacing rules.

### Dependency Graph

The `DependencyGraph` maps state paths to the nodes that depend on them:

```rust
pub struct DependencyGraph {
    dependencies: IndexMap<String, IndexSet<NodeId>>,     // path → nodes
    node_bindings: IndexMap<NodeId, IndexSet<String>>,    // node → paths
    prefix_index: BTreeMap<String, IndexSet<String>>,     // prefix → full paths
}
```

#### Registration

When a node with bindings is created, each binding's path is registered:

```
Text("@{state.user.name}")
  → registers dependency: "user.name" → node_42
  → prefix index entries: "user" → {"user.name"}, "user.name" → {"user.name"}
```

#### Affected Node Lookup

When a state path changes, `get_affected_nodes(changed_path)` finds all nodes that need re-rendering using three strategies:

1. **Exact match**: Nodes depending on exactly `changed_path`
2. **Parent paths**: Nodes depending on any prefix of `changed_path`
   - If `"user.name"` changes, nodes depending on `"user"` are affected
3. **Child paths**: Nodes depending on any path starting with `changed_path`
   - If `"user"` changes, nodes depending on `"user.name"` and `"user.email"` are affected

The prefix index (`BTreeMap`) makes child-path lookups O(log n) instead of O(n).

#### Example

```
Dependencies registered:
  node_1 → "user"
  node_2 → "user.name"
  node_3 → "user.email"
  node_4 → "posts"

State change: "user" changed
  → Exact match: node_1
  → Child paths: node_2, node_3 (via prefix index)
  → Result: {node_1, node_2, node_3}

State change: "user.name" changed
  → Exact match: node_2
  → Parent paths: node_1 (depends on "user", a prefix of "user.name")
  → Result: {node_1, node_2}

State change: "user.email" changed
  → Exact match: node_3
  → Parent paths: node_1
  → Result: {node_1, node_3}
```

### Path-Based (Not Value-Based)

The engine tracks **which paths changed**, not **what values changed**. The dependency graph maps paths to nodes, and once a path is in the dirty set the engine re-resolves every dependent binding regardless of whether the new value differs from the old one.

How that interacts with value-deduplication depends on which API the host uses:

- **`Engine::update_state(scope, patch)`** — the engine merges the patch into the slot's state, then compares the new state to the previous one (`engine_core.rs::update_state`) and returns `false` (no-op, no render) if nothing actually changed. Setting `state.count = 5` when it's already 5 via this path is a no-op.
- **`Engine::notify_state_change(&change)`** — the host has already mutated its observable state and pre-computed which paths it touched. The engine trusts the path list and dirties whatever's bound to it; there is no value comparison. Setting `state.count = 5` when it's already 5 via this path **does** trigger a re-render. This is correct for hosts whose proxy/observer may fire multiple times for the same write.
- **`Engine::update_state_sparse(scope, paths, values)`** — same value-comparison semantics as `update_state`: returns `false` if the merged state matches the previous state.

Hosts that need strict no-op behavior should either use the `update_state` family or implement their own write-coalescing layer above `notify_state_change`. See ENGINE_CONTRACT.md §5 for the full method contracts.

---

## Reconciliation Algorithm

### Source Files

- `src/reconcile/diff.rs` — Core diffing algorithm (~1900 lines)
- `src/reconcile/tree.rs` — Instance tree data structure
- `src/reconcile/patch.rs` — Patch type definitions

### Overview

Reconciliation compares the new IR tree against the existing instance tree and generates the minimal set of patches to bring the instance tree up to date.

Single entry point: `reconcile_ir(tree, ir_node, parent_id, state, deps)` for
`IRNode`-based trees (first-class control flow). The legacy `Element`-based
`reconcile()` was deleted; tests that need to reconcile a bare `Element` must
wrap it in `IRNode::Element(...)` and call `reconcile_ir`. See `reconcile/diff.rs`.

### Keyed Children Diffing

The algorithm for reconciling children with keys:

**Phase 1: Match children**
```
Old children: [A, B, C, D, E]  (by key)
New children: [B, D, A, F]     (by key)

Build maps:
  old_keyed: { A→node1, B→node2, C→node3, D→node4, E→node5 }

For each new child:
  B → found in old_keyed → reconcile node2, remove from map
  D → found in old_keyed → reconcile node4, remove from map
  A → found in old_keyed → reconcile node1, remove from map
  F → not found → create new node
```

**Phase 2: Minimize moves with LIS**

The Longest Increasing Subsequence algorithm determines which nodes are already in correct relative order and don't need Move patches.

```
New children: [B, D, A, F]
Old positions: [1, 3, 0, None]  (None = newly created)

LIS of old positions: [1, 3]  → indices 0, 1 (B, D are in order)

Nodes NOT in LIS need Move patches:
  A (index 2) → Move
  F (index 3) → Insert (new)
```

**Phase 3: Cleanup**

Unused old children (C, E) are removed with `Remove` patches and their dependencies are cleaned from the graph.

### Control Flow Reconciliation

ForEach and Conditional nodes have special reconciliation paths:

**ForEach:**
1. Re-evaluate the source binding to get the current array
2. For each item, replace `@{item.*}` bindings with actual values
3. Generate keys using `key_path` or default `{item_name}-{index}`
4. Reconcile generated children against existing children using keyed diffing

**Conditional:**
1. Re-evaluate the condition value
2. Match against branch patterns
3. If the matching branch changed, remove old branch children and create new ones
4. If the same branch matches, reconcile children in place

### Transparent Control Flow Nodes

Control flow nodes (`__ForEach`, `__Conditional`) exist in the instance tree but are **transparent** to the DOM. Their children render directly into the control flow node's parent. This means:

```hypen
Column {
    ForEach(items: @{state.items}) {
        Text(@{item.name})
    }
}
```

Results in the DOM structure:
```
Column
├── Text("Alice")   ← direct child of Column, not of __ForEach
├── Text("Bob")
└── Text("Charlie")
```

---

## Patch Generation

### Patch Types

```rust
pub enum Patch {
    Create { id, element_type, props },   // Create a new node
    SetProp { id, name, value },          // Update a single property
    RemoveProp { id, name },              // Drop a property from a node
    SetText { id, text },                 // Update text content (reserved; not currently emitted)
    Insert { parent_id, id, before_id },  // Insert into parent
    Move { parent_id, id, before_id },    // Move to new position
    Remove { id },                        // Remove from tree
}
```

Note: text changes are emitted as `SetProp { name: "0", … }` on the
primary path; `SetText` is reserved for renderers that distinguish
text-nodes from prop updates and is not currently emitted by the
reconciler. See ENGINE_CONTRACT.md §10.1 for the wire-format details.

All patches use string IDs (serialized `NodeId`). `before_id` is `Option<String>` — `None` means append to end.

### Serialization

Patches are serialized as JSON with `camelCase` tag names:

```json
[
    { "type": "create", "id": "n1", "elementType": "Text", "props": { "text": "Hello" } },
    { "type": "insert", "parentId": "root", "id": "n1", "beforeId": null },
    { "type": "setProp", "id": "n1", "name": "text", "value": "World" }
]
```

### Event Handling

Event handling (click, input, etc.) is **not** part of the patch system. Events are managed at the renderer level. The engine passes event-related props (e.g., `onClick: @actions.increment`) as regular props, and the renderer is responsible for attaching event listeners.

---

## Instance Tree

### Source File

- `src/reconcile/tree.rs`

### Structure

The instance tree is a SlotMap-based tree of `InstanceNode` values:

```rust
pub struct InstanceTree {
    nodes: SlotMap<NodeId, InstanceNode>,
    root: Option<NodeId>,
}

pub struct InstanceNode {
    pub id: NodeId,
    pub element_type: String,
    pub props: ResolvedProps,                            // Arc<IndexMap<String, Value>> — O(1) clone into Create patches
    pub raw_props: Props,                                // Unresolved (with bindings)
    pub ir_node_template: Option<Arc<IRNode>>,           // For ForEach/Conditional re-rendering
    pub control_flow: Option<ControlFlowKind>,
    pub key: Option<String>,
    pub parent: Option<NodeId>,
    pub children: im::Vector<NodeId>,
}
```

Key properties:
- `props` contains **resolved** values (bindings evaluated against state)
- `raw_props` contains **unresolved** values (with `Binding` variants intact) for change detection
- `ir_node_template` stores the original IRNode for control flow nodes so they can be re-rendered on state change
- `children` uses `im::Vector` for O(1) structural sharing during clones

### NodeId

`NodeId` is a SlotMap key, providing:
- Stable identity across re-renders
- O(1) lookup
- Automatic reuse of removed slots
- Type-safe (can't mix up with other IDs)

---

## Component Resolution

### Source File

- `src/ir/component.rs`

### Component Types

| Type | Behavior |
|------|----------|
| **Regular** | Template function expands props into an element tree |
| **Passthrough** | Props preserved, children expanded, no template transform |
| **Lazy** | Children not expanded until explicitly requested |

### Resolution Flow

```
Component name ("ProfileCard")
  ComponentRegistry.resolve()
      ├── Found in registry? → Use registered component
      └── Not found? → Call ComponentResolver callback
            ├── Resolver returns source code → Parse & expand
            └── Resolver returns None → Keep as-is (primitive)
```

The `ComponentResolver` is a callback set by the host that resolves component names to source code. This enables file-based component discovery and lazy loading:

```typescript
engine.setComponentResolver((name, context) => {
    const source = readComponentFile(name);
    return source ? { source, path: `/components/${name}`, passthrough: false, lazy: false } : null;
});
```

---

## State Management

### Source Files

- `src/state.rs` — StateChange type and tracking
- `src/engine.rs` — State update orchestration

### Update Flow

```
Host calls engine.updateState({ count: 42 })
  Merge state patch into current state
  Extract changed paths: ["count"]
  DependencyGraph.get_affected_nodes("count")
  Re-reconcile affected nodes
  Generate and emit patches
```

### Multi-Module with Engine-Native Scoping

The engine supports multiple named modules alongside a primary module. When the engine renders a `module Search { ... }` component, it automatically scopes `@{state.xxx}` bindings inside that subtree to the Search module's state.

State, the instance tree, and the dependency graph all live on `EngineCore` (`engine_core.rs`), which is the shared core wrapped by every binding (native `Engine`, `WasmEngine`, `WasiEngine`, UniFFI `HypenEngine`):

```rust
pub(crate) struct EngineCore {
    pub module: Option<ModuleInstance>,                  // Primary slot
    pub modules: IndexMap<String, ModuleInstance>,       // Named slots (lowercased keys)
    pub action_module_map: IndexMap<String, Option<String>>,  // action → owning scope
    // tree, dependencies, scheduler, data_sources, registries, …
}
```

`action_module_map` records which slot owns each action: `None` for primary-slot actions (installed via `set_module`), `Some(name)` for named-slot actions (installed via `register_module`). Both methods evict their previous entries on re-installation to prevent stale routing. The shared `action_scope_for(name)` lookup flattens both "primary slot" and "unknown action" to `None`, which is the right default for follow-up `update_state` calls.

Register named modules via `engine.register_module("search", instance)`. The component expansion detects `module X { ... }` declarations and propagates `module_scope` to all descendant elements. During reconciliation, the engine swaps the state context for scoped elements. Cross-module communication uses `GlobalContext` at the SDK layer. See ENGINE_CONTRACT.md §4 for the full slot semantics.

### Revision Tracking

Each state update increments a revision counter. This is used by the Remote UI protocol to ensure patches are applied in order:

```rust
pub fn revision(&self) -> u64
```

The serialization layer can include revision numbers and optional integrity hashes for clients to verify patch ordering.