Files
ujsx/docs/research/signals-ujsx-reactive-pipeline.md

372 lines
14 KiB
Markdown

# Signals + UJSX: Reactive Transform Pipeline
## The Core Idea
Preact Signals' `computed` provides exactly what UJSX needs for efficient rendering: a component whose output is automatically cached and only recomputed when its input signals change.
In the context of an agent HUD:
- HUD **state** lives in `signal()` values (task description, decisions, notes, context %)
- HUD **components** are `computed()` signals that read their input signals and produce UElements
- The **root render** is a `computed()` that traverses the component tree and produces markdown
- Changing one signal (e.g., context percentage from 70% to 85%) automatically marks only the downstream components as outdated — not the entire tree
This is fundamentally different from the current approach where the entire HUD is re-rendered from scratch every turn.
## How Signals Work (from source)
Key behaviors from `/workspace/signals/packages/core/src/index.ts`:
### `signal(value)` — The Reactive Primitive
```typescript
const count = signal(0)
count.value // read (tracks deps if in computed/effect)
count.value = 1 // write (notifies dependents, batches updates)
count.peek() // read WITHOUT tracking (untracked)
```
### `computed(fn)` — Derived, cached computation
```typescript
const doubled = computed(() => count.value * 2)
```
- Lazily evaluated: fn only runs when `.value` is read AND deps have changed
- Cached: if deps haven't changed, returns cached value without running fn
- Version tracking: uses `_version` numbers on signals instead of storing values
- Dependency graph: automatically tracks which signals were read during evaluation
### `effect(fn)` — Side effects
```typescript
effect(() => {
console.log(doubled.value) // re-runs when count changes
})
```
- Returns a dispose function
- fn can return a cleanup function
- Batches: multiple signal writes within `batch()` only trigger one effect run
### `batch(fn)` — Batching
```typescript
batch(() => {
a.value = 1 // doesn't trigger effects yet
b.value = 2 // still batched
}) // effects run once after batch completes
```
### `createModel(factory)` — Model pattern
```typescript
const Counter = createModel((initial: number) => ({
count: signal(initial),
increment: action(() => { Counter.count.value++ }),
doubled: computed(() => Counter.count.value * 2),
}))
const c = new Counter(0)
c.count.value // 0
c.increment() // batched, untracked write
c.doubled.value // 2
c[Symbol.dispose]() // cleans up all effects
```
### Key Implementation Details
1. **Version numbers, not value storage**: Computed signals store `_version` per dependency node, not values. When checking if recomputation is needed, it compares version numbers. This is memory-efficient.
2. **Global version counter**: `globalVersion` increments when ANY signal changes. Computed signals track `_globalVersion` — if global hasn't changed since last evaluation, skip entirely (fast path).
3. **Lazy evaluation**: Computed values are NOT computed until someone reads `.value`. No eager re-computation.
4. **Batch snapshots**: When writing multiple signals in a batch, `_batchSnapshotVersion` tracks which signals were written. On batch end, `reconcileBatchSnapshots()` restores version numbers for signals that were written but ended up with the same value (avoiding spurious notifications).
5. **Auto-dispose**: Effects created within `createModel` are captured and disposed when the model's `[Symbol.dispose]()` is called.
6. **Untracked reads**: `signal.peek()` reads without tracking. `untracked(fn)` runs fn without tracking. `action(fn)` wraps fn in batch+untracked.
## Application to UJSX Transform Pipeline
### The Problem
In the HUD, every agent turn re-renders the entire HUD from the root. If the task description hasn't changed, the task section still re-renders. If context went from 67% to 68%, the entire tree is walked even though only `ContextBar` changed.
With signals:
- Each component whose input hasn't changed **skips re-evaluation entirely**
- Only the `computed` signals that depend on changed `signal` values re-compute
- The render pipeline walks the tree, but `computed.value` returns cached results for unchanged components
### Proposed Architecture
```typescript
import { signal, computed, batch, effect } from "@preact/signals-core"
// --- State Layer (signals) ---
const hudState = createModel(() => ({
task: signal<{ description: string; updatedAt: number } | null>(null),
contextPercent: signal(0),
contextStatus: computed(() => {
const p = hudState.contextPercent.value
if (p < 70) return 'green' as const
if (p < 85) return 'yellow' as const
return 'red' as const
}),
decisions: signal<Array<{ id: string; summary: string }>>([]),
notes: signal<Array<{ id: string; content: string }>>([]),
nextSteps: signal<Array<{ id: string; description: string; completed: boolean }>>([]),
activeFiles: signal<Array<{ path: string; status: string }>>([]),
density: computed(() => {
const p = hudState.contextPercent.value
if (p < 70) return 'full' as const
if (p < 85) return 'compact' as const
return 'minimal' as const
}),
}))
// --- Component Layer (computed signals producing UElements) ---
function TaskSection() {
return computed(() => {
const task = hudState.task.value
const density = hudState.density.value
if (!task) return null
if (density === 'minimal') {
return h('p', null, task.description)
}
return h('div', null,
h('h2', null, 'Task'),
h('p', null, task.description),
)
})
}
function ContextBar() {
return computed(() => {
const percent = hudState.contextPercent.value
const status = hudState.contextStatus.value
const density = hudState.density.value
if (density === 'minimal') {
return h('p', null, `${Math.round(percent)}% ${status === 'red' ? '⚠' : ''}`)
}
return h('div', null,
h('h2', null, 'Context'),
h('p', null, `${Math.round(percent)}% used (${status})`),
)
})
}
function DecisionsList() {
return computed(() => {
const density = hudState.density.value
const decisions = hudState.decisions.value
if (density === 'minimal') return null
if (density === 'compact') {
return h('p', null, `Decisions (${decisions.length})`)
}
return h('div', null,
h('h2', null, 'Decisions'),
h('ul', null, ...decisions.map(d => h('li', null, d.summary))),
)
})
}
// --- Root Render (computed signal) ---
const hudRender = computed(() => {
const task = TaskSection()
const ctx = ContextBar()
const decisions = DecisionsList()
const children = [task, ctx, decisions].filter(Boolean)
return createRoot('hud', ...children)
})
// --- Markdown Output (effect, or manual) ---
// Option A: Reactive — auto-renders when any signal changes
const dispose = effect(() => {
const tree = hudRender.value // only recomputes what changed
const markdown = renderMarkdown(tree)
sendToSystemPrompt(markdown)
})
// Option B: Manual — called when we know we need a new render
function renderHUD() {
return hudRender.value // triggers only necessary computed() evaluations
}
// --- Updates are batched ---
function updateHUD(event: HudEvent) {
batch(() => {
switch (event.type) {
case 'task.set':
hudState.task.value = { description: event.description, updatedAt: Date.now() }
break
case 'context.snapshot':
hudState.contextPercent.value = event.percent
break
case 'decision.record':
hudState.decisions.value = [...hudState.decisions.value, event.decision]
break
}
})
// Only one effect run after the batch, even if multiple signals changed
}
```
### What This Buys Us
1. **Skip unchanged components**: If `task` signal hasn't changed, `TaskSection()` returns the cached UElement without calling the component function.
2. **Density changes propagate efficiently**: Changing `contextPercent` from 67% to 68% (green→green) doesn't trigger `ContextBar` re-render because the computed `contextStatus` still returns 'green'. From 69%→71% (green→yellow) DOES trigger `ContextBar` and anything that reads `density`.
3. **Batched updates**: A multi-field update (task + decisions + context) in one `batch()` only triggers one render pass.
4. **No global dirty tracking needed**: The existing POC's `dirty` bitmask on graphology nodes is a manual approximation. Signals do this automatically and correctly.
5. **Separation of state and rendering**: State is in `signal()` values. Rendering is `computed()` derivations. Effects wire rendering to side effects (injecting into system prompt).
6. **`peek()` for untracked reads**: During transformation (walking the tree to produce markdown), we can `peek()` at values without subscribing — useful when the transform doesn't need to be reactive.
7. **Disposal**: `hudState[Symbol.dispose]()` cleans up all effects created within the model. Clean teardown when a session ends.
### What This Does NOT Buy Us (Yet)
1. **Structural changes still need full walk**: Adding/removing a list item still requires re-walking that part of the tree. Signals help with value changes, not structural changes. For structural changes, we'd need a keyed list diff (like React's reconciliation).
2. **The transform (UJSX → mdast → markdown) is always full**: Even if only one component re-computes, the entire tree still gets walked to produce markdown. To skip unchanged subtrees in the transform, we'd need to memoize transform results per node (using `computed` at the transform level too).
3. **No framework dependency yet**: We're using signals-core standalone, not via Preact/React integration. The Preact adapter (`@preact/signals`) adds `useSignal()` hook, React adapter (`@preact/signals-react`) adds `useSignalValue()`. We don't need either — we use `signal`/`computed` directly.
### Computed Transforms (Memoization at Every Level)
The real power: make the transform pipeline itself reactive:
```typescript
// State signal
const taskText = signal("Implement auth module")
// Component produces a UElement (computed — cached)
const taskElement = computed(() =>
h('h2', null, taskText.value)
)
// Transform produces mdast (computed — cached)
const taskMdast = computed(() =>
transformToMdast(taskElement.value, { direction: 'ujsx->mdast' })
)
// Serialize to markdown (computed — cached)
const taskMarkdown = computed(() =>
toMarkdown(taskMdast.value)
)
// Only runs when taskText changes: taskElement → taskMdast → taskMarkdown
// If taskText hasn't changed, reading taskMarkdown.value returns cached string
```
This means the ENTIRE pipeline is memoized. If you change `contextPercent` from 67% to 68% (green→green, no density change), `taskMarkdown.value` returns the cached string instantly. Only `contextBarMarkdown.value` recomputes.
### The Final Composition
```typescript
// Each section is its own computed pipeline
const taskMd = computed(() => renderSection(TaskSection(), 'ujsx->mdast'))
const ctxMd = computed(() => renderSection(ContextBar(), 'ujsx->mdast'))
const decisionsMd = computed(() => renderSection(DecisionsList(), 'ujsx->mdast'))
// Root markdown is computed from sections
const fullMarkdown = computed(() =>
[taskMd.value, ctxMd.value, decisionsMd.value]
.filter(Boolean)
.join('\n\n')
)
// Only the sections whose signals changed recompute their markdown
// fullMarkdown recomputes its string join (cheap) but doesn't re-walk unchanged sections
```
### Integration with opencode Plugin
The `effect()` that injects into the system prompt would be:
```typescript
const dispose = effect(() => {
const markdown = fullMarkdown.value
// This runs whenever any upstream signal changes
// But only the changed computed() evaluations actually ran
systemPromptInjector.push(markdown)
})
```
Or more pragmatically, since opencode calls the plugin hook on every LLM turn:
```typescript
// In the plugin's system.transform hook:
"experimental.chat.system.transform": async (input, output) => {
// Just read .value — it's cached, no work if unchanged
const markdown = fullMarkdown.value
output.system.push(markdown)
}
```
This is the simplest integration: no effect, no subscription. Just read `.value` when asked. If nothing changed, it's O(1). If something changed, only the minimal recomputation happens.
### Signals + TypeBox Module Interaction
The signals hold runtime state. The TypeBox Module defines the schema for that state.
```typescript
import { Type, Static } from "@alkdev/typebox"
const HudStateModule = Type.Module({
Task: Type.Object({
description: Type.String(),
updatedAt: Type.Number(),
}),
Decision: Type.Object({
id: Type.String(),
summary: Type.String(),
details: Type.Optional(Type.String()),
}),
ContextInfo: Type.Object({
percent: Type.Number(),
status: Type.Union([Type.Literal('green'), Type.Literal('yellow'), Type.Literal('red')]),
model: Type.String(),
}),
})
// Import for validation
const Task = HudStateModule.Import("Task")
const Decision = HudStateModule.Import("Decision")
// Validate before setting signal values
function updateHUD(event: HudEvent) {
batch(() => {
if (event.type === 'task.set') {
if (Value.Check(Task, event.task)) {
hudState.task.value = event.task
}
}
// ...
})
}
```
The TypeBox Module validates incoming events. The signals hold the validated state. The `computed` components derive UElements from the state. The transform pipeline serializes to markdown.
### File Structure Addition
```
@alkdev/ujsx/
core/
schema.ts # TypeBox Module for UNode/UElement/URoot
h.ts # h(), createRoot(), Fragment factory
context.ts # Signal-based context (density, target)
signals.ts # createHudModel() — signals for HUD state
host/
config.ts # HostConfig interface (preserved)
markdown.ts # Markdown host (new)
transform/
registry.ts # Enhanced TransformRegistry with direction + schema
rule.ts # Rule type with TypeBox schema
...
```
The signals integration is an **addition** to the TypeBox-driven rewrite, not a replacement. The TypeBox Module defines the shape. The signals hold the runtime values. The computed signals produce the rendered output.