14 KiB
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
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
const doubled = computed(() => count.value * 2)
- Lazily evaluated: fn only runs when
.valueis read AND deps have changed - Cached: if deps haven't changed, returns cached value without running fn
- Version tracking: uses
_versionnumbers on signals instead of storing values - Dependency graph: automatically tracks which signals were read during evaluation
effect(fn) — Side effects
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
batch(() => {
a.value = 1 // doesn't trigger effects yet
b.value = 2 // still batched
}) // effects run once after batch completes
createModel(factory) — Model pattern
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
-
Version numbers, not value storage: Computed signals store
_versionper dependency node, not values. When checking if recomputation is needed, it compares version numbers. This is memory-efficient. -
Global version counter:
globalVersionincrements when ANY signal changes. Computed signals track_globalVersion— if global hasn't changed since last evaluation, skip entirely (fast path). -
Lazy evaluation: Computed values are NOT computed until someone reads
.value. No eager re-computation. -
Batch snapshots: When writing multiple signals in a batch,
_batchSnapshotVersiontracks 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). -
Auto-dispose: Effects created within
createModelare captured and disposed when the model's[Symbol.dispose]()is called. -
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
computedsignals that depend on changedsignalvalues re-compute - The render pipeline walks the tree, but
computed.valuereturns cached results for unchanged components
Proposed Architecture
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
-
Skip unchanged components: If
tasksignal hasn't changed,TaskSection()returns the cached UElement without calling the component function. -
Density changes propagate efficiently: Changing
contextPercentfrom 67% to 68% (green→green) doesn't triggerContextBarre-render because the computedcontextStatusstill returns 'green'. From 69%→71% (green→yellow) DOES triggerContextBarand anything that readsdensity. -
Batched updates: A multi-field update (task + decisions + context) in one
batch()only triggers one render pass. -
No global dirty tracking needed: The existing POC's
dirtybitmask on graphology nodes is a manual approximation. Signals do this automatically and correctly. -
Separation of state and rendering: State is in
signal()values. Rendering iscomputed()derivations. Effects wire rendering to side effects (injecting into system prompt). -
peek()for untracked reads: During transformation (walking the tree to produce markdown), we canpeek()at values without subscribing — useful when the transform doesn't need to be reactive. -
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)
-
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).
-
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
computedat the transform level too). -
No framework dependency yet: We're using signals-core standalone, not via Preact/React integration. The Preact adapter (
@preact/signals) addsuseSignal()hook, React adapter (@preact/signals-react) addsuseSignalValue(). We don't need either — we usesignal/computeddirectly.
Computed Transforms (Memoization at Every Level)
The real power: make the transform pipeline itself reactive:
// 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
// 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:
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:
// 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.
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.