- README: Node.js 18+ → Node.js 18+, Deno, and Bun - package.json: add deno:true field - build-distribution.md: consolidate engine/platform sections
status, last_updated
| status | last_updated |
|---|---|
| stable | 2026-05-18 |
@alkdev/ujsx Architecture
Universal JSX — runtime-agnostic reactive tree primitives with TypeBox schemas. UJSX treats JSX as an intermediate representation for multi-target rendering.
Why This Exists
UJSX fills a specific niche: generic tree construction and rendering where the same declarative template can target different hosts (markdown, graph structures, UI frameworks, workflow engines). It borrows the JSX mental model — nested elements with props and children — but strips away all platform-specific assumptions.
No onClick, no className, no style. The tree is a pure data structure (UNode) validated by TypeBox schemas, and the rendering contract is a HostConfig that decides what "create", "update", and "remove" mean for its target.
This makes UJSX useful for:
- Workflow definitions — operations as elements, dependencies as parent-child structure, rendered to graphology DAGs or reactive execution engines
- Structured document generation — markdown, HTML, or any text format via transform rules
- Desktop UI — instanced glyphs, panels, layouts rendered to Three.js or similar (the spoke UI use case)
Core Principle
The tree is the truth. Hosts are interpreters. UJSX defines what a tree looks like (UNode, UElement, URoot), how it's constructed (h(), createComponent()), and how it can react to changes (signals). It does not dictate what the tree means — that's the host's job.
Current State
UJSX is functional but incomplete. The core primitives exist and are tested:
- Schema — TypeBox Module (
UJSX) definingUNode,UElement,URoot,UPrimitive,PropValue,UniversalProps; TypeScript typesComponentFnandUComponent; type guardsisUElement,isURoot,isUPrimitive - Element factory —
h(),createRoot(),createComponent(),Fragment, JSX runtime - Reactive layer —
ReactiveRoot,reactiveComponent,reactiveElement, signal/computed/effect/batch - HostConfig — generic host interface with
createRoot().render()for mount-only rendering - Transforms —
TransformRegistryfor bi-directional transforms - Events —
PubSubLikeandEventEnvelopefor decoupled event emission - Pointers —
ValuePointer,selectNode,setNodefor tree navigation and targeted mutation
Known gaps (documented in architecture docs, planned for reconciler implementation):
unmount()is a stub — no fiber tree teardown, no instance removal, no signal disposal. See host-config.md and lifecycle.md.render()is mount-only — no re-render, no diffing, noprepareUpdate/commitUpdatecalls. See reconciler.md.disposefunctions are no-ops — signal subscriptions leak. See lifecycle.md.- No
keyfield onUElement— positional matching only. See ADR-004.
Architecture Documents
| Document | Content |
|---|---|
| schema.md | TypeBox Module, UNode/UElement/URoot/UPrimitive types, type guards |
| element-factory.md | h(), createRoot(), createComponent(), Fragment, JSX runtime |
| reactive-layer.md | ReactiveRoot, reactiveComponent, reactiveElement, signals, disposal gaps |
| host-config.md | HostConfig interface, createRoot(), mount-only rendering, reconciler gap |
| reconciler.md | Fiber tree, reconciliation algorithm, update scheduling, TypeBox optimizations |
| lifecycle.md | Mount, update, unmount/dispose lifecycle, signal cleanup, partial tree teardown |
| transforms.md | TransformRegistry, TransformRule, TransformContext, bi-directional transforms |
| events.md | EventEnvelope, PubSubLike, UjsxEventMap |
| pointers.md | ValuePointer, selectNode, setNode, tree navigation |
| build-distribution.md | Package structure, exports map, dependencies, runtime targets |
Design Decisions
| ADR | Decision |
|---|---|
| 001 | HTML-agnostic core — no DOM-specific props |
| 002 | TypeBox Module IS the type registry |
| 003 | Preact signals-core for reactivity |
| 004 | key as a first-class field on UElement — not a prop |
| 005 | Signal-driven updates for props, reconciliation for structure |
Consumer Context
UJSX is designed as a library consumed by other projects, not an end-user application. Understanding these consumers shapes the API design:
Flowgraph (@alkdev/flowgraph)
Uses UJSX as a direct dependency. Workflow templates are UNode trees. Renders them to:
- graphology DAG — structural analysis, cycle detection, topological sort via a
HostConfig - Reactive execution engine — runtime workflow execution with signal-based status propagation
See ../research/reconciler/05-flowgraph-host-configs.md for the planned integration.
Desktop UI (Spoke HUD)
Uses UJSX to define instanced glyph layouts, panels, and adaptive-density content. Renders via a Three.js HostConfig. Signal-driven property updates flow through prepareUpdate/commitUpdate to GPU buffers.
OpenCode Plugin (future)
An OpenCode plugin that provides UJSX-based template operations. Would use TransformRegistry for bi-directional markdown↔UJSX conversion.
Reconciler Roadmap
The reconciler bridges the reactive layer to the host layer, enabling signal-driven updates and key-based children reconciliation. Architecture docs define the WHAT and WHY; research docs contain the detailed implementation plans.
| Phase | Description | Architecture | Research |
|---|---|---|---|
| 0 | key field on UElement |
ADR-004, schema.md | 00-KEY-FIELD-DESIGN.md |
| 1 | Reactive → Host bridge (fiber tree, signal-driven updates) | reconciler.md, ADR-005 | 01-reactive-host-bridge.md |
| 2 | Key-based children reconciliation (LIS algorithm) | reconciler.md | 02-key-based-children-reconciliation.md |
| 3 | Unmount & dispose support | lifecycle.md | 03-unmount-dispose-support.md |
| 4 | TypeBox value optimization layer | reconciler.md (TypeBox Optimization Layer section) | 04-typebox-optimization-layer.md |
| 5 | Flowgraph HostConfig implementations | Downstream consumer (@alkdev/flowgraph), not ujsx |
05-flowgraph-host-configs.md |
Document Lifecycle
Architecture documents use YAML frontmatter with status and last_updated fields:
---
status: draft | stable | deprecated
last_updated: YYYY-MM-DD
---
| Status | Meaning | Transitions |
|---|---|---|
draft |
Under active development. Content may change significantly. Implementation should not start until the document reaches stable. |
→ stable when implementation is complete and API contract is verified by tests. |
stable |
API contracts are locked. Changes require a review cycle and may warrant an ADR if they affect documented decisions. | → deprecated when superseded. → draft if a fundamental redesign is needed (rare). |
deprecated |
Superseded by another document. Kept for reference. Links should point to the replacement. | Removed when no longer referenced. |
ADR documents use a separate Status field in their body: Proposed, Accepted, Deprecated, or Superseded. ADRs never revert from Accepted. Note that ADR frontmatter (status: draft) refers to the document's editorial status (is the writing complete?), while the body Status refers to the decision's status (is this decision finalized?). A Proposed decision can have a draft document; an Accepted decision has a stable document.
Error Handling Philosophy
UJSX follows a tiered approach to errors:
-
Programmer errors throw. Invalid arguments (
h()called without a type), missing required parameters, ortransform()failing to find a matching rule — these are bugs that should surface immediately with a stack trace. Failing silently makes debugging harder. -
Operational conditions are no-ops with defined behavior.
selectNodereturningundefinedfor invalid paths,setNodereturning the node unchanged for unresolvable paths,Fragmentwith no children producing an empty array — these are not errors, they are well-defined boundary conditions. The caller is responsible for checking the result. -
Host errors propagate. If
HostConfig.createInstancethrows, the error bubbles up throughmountNodewith no host involvement. UJSX does not catch or suppress host errors. The current implementation has nohandleErrorhook onHostConfig— whether to add one is an open question (see host-config.md Open Question 4). -
Recovery is the caller's responsibility.
ReactiveRoot.subscribe()returns a dispose function; if the caller discards it, the effect leaks.unmount()is a stub. There is no automatic retry, circuit breaking, or error event emission for operational failures. These concerns belong to the host or consumer, not to the core library.
References
- UJSX research index:
../research/README.md - Reconciler research:
../research/reconciler/ - SDD process:
../sdd_process.md - Preact signals-core:
@preact/signals-core - TypeBox:
@alkdev/typebox - Taskgraph_ts architecture pattern:
/workspace/@alkdev/taskgraph_ts/docs/architecture/