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

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 .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

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

  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

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:

// 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.