Critical fixes: - Restructure pointers.md: move setNode prop-key writes section under its own heading (was incorrectly nested under selectNode) - Add Context/Density/Direction/RenderContext documentation section to host-config.md (was only a brief constraint bullet) - Advance all 5 ADRs from Status: Proposed → Accepted and frontmatter from status: draft → status: stable (decisions are driving implementation) - Add error handling philosophy section to README Warning/suggestion fixes: - Add isUElement null check (node !== null) to schema.md discriminator table - Add UjsxEnvelope convenience type documentation to events.md - Add Direction Unicode arrow naming note to transforms.md - Standardize all cross-references from absolute docs/research/ paths to relative ../research/ paths across all architecture docs - Fix schema.md ADR references to use relative paths - Reduce redundancy between transforms.md and host-config.md Direction notes - Update all architecture doc frontmatter from draft → stable Deferred: - Performance model section (reconciler not yet built) - Concepts/glossary document (low ROI at current scale) - Line counts in source references (would date quickly)
140 lines
10 KiB
Markdown
140 lines
10 KiB
Markdown
---
|
|
status: stable
|
|
last_updated: 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`) defining `UNode`, `UElement`, `URoot`, `UPrimitive`, `PropValue`, `UniversalProps`; TypeScript types `ComponentFn` and `UComponent`; type guards `isUElement`, `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** — `TransformRegistry` for bi-directional transforms
|
|
- **Events** — `PubSubLike` and `EventEnvelope` for decoupled event emission
|
|
- **Pointers** — `ValuePointer`, `selectNode`, `setNode` for 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](host-config.md) and [lifecycle.md](lifecycle.md).
|
|
- `render()` is mount-only — no re-render, no diffing, no `prepareUpdate`/`commitUpdate` calls. See [reconciler.md](reconciler.md).
|
|
- `dispose` functions are no-ops — signal subscriptions leak. See [lifecycle.md](lifecycle.md).
|
|
- No `key` field on `UElement` — positional matching only. See [ADR-004](decisions/004-key-as-first-class-field.md).
|
|
|
|
## Architecture Documents
|
|
|
|
| Document | Content |
|
|
|----------|---------|
|
|
| [schema.md](schema.md) | TypeBox Module, UNode/UElement/URoot/UPrimitive types, type guards |
|
|
| [element-factory.md](element-factory.md) | h(), createRoot(), createComponent(), Fragment, JSX runtime |
|
|
| [reactive-layer.md](reactive-layer.md) | ReactiveRoot, reactiveComponent, reactiveElement, signals, disposal gaps |
|
|
| [host-config.md](host-config.md) | HostConfig interface, createRoot(), mount-only rendering, reconciler gap |
|
|
| [reconciler.md](reconciler.md) | Fiber tree, reconciliation algorithm, update scheduling, TypeBox optimizations |
|
|
| [lifecycle.md](lifecycle.md) | Mount, update, unmount/dispose lifecycle, signal cleanup, partial tree teardown |
|
|
| [transforms.md](transforms.md) | TransformRegistry, TransformRule, TransformContext, bi-directional transforms |
|
|
| [events.md](events.md) | EventEnvelope, PubSubLike, UjsxEventMap |
|
|
| [pointers.md](pointers.md) | ValuePointer, selectNode, setNode, tree navigation |
|
|
| [build-distribution.md](build-distribution.md) | Package structure, exports map, dependencies, runtime targets |
|
|
|
|
### Design Decisions
|
|
|
|
| ADR | Decision |
|
|
|-----|----------|
|
|
| [001](decisions/001-html-agnostic-core.md) | HTML-agnostic core — no DOM-specific props |
|
|
| [002](decisions/002-typebox-module-as-registry.md) | TypeBox Module IS the type registry |
|
|
| [003](decisions/003-preact-signals-for-reactivity.md) | Preact signals-core for reactivity |
|
|
| [004](decisions/004-key-as-first-class-field.md) | `key` as a first-class field on UElement — not a prop |
|
|
| [005](decisions/005-signal-driven-updates-over-tree-diffing.md) | 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](decisions/004-key-as-first-class-field.md), [schema.md](schema.md) | [00-KEY-FIELD-DESIGN.md](../research/reconciler/00-KEY-FIELD-DESIGN.md) |
|
|
| 1 | Reactive → Host bridge (fiber tree, signal-driven updates) | [reconciler.md](reconciler.md), [ADR-005](decisions/005-signal-driven-updates-over-tree-diffing.md) | [01-reactive-host-bridge.md](../research/reconciler/01-reactive-host-bridge.md) |
|
|
| 2 | Key-based children reconciliation (LIS algorithm) | [reconciler.md](reconciler.md) | [02-key-based-children-reconciliation.md](../research/reconciler/02-key-based-children-reconciliation.md) |
|
|
| 3 | Unmount & dispose support | [lifecycle.md](lifecycle.md) | [03-unmount-dispose-support.md](../research/reconciler/03-unmount-dispose-support.md) |
|
|
| 4 | TypeBox value optimization layer | [reconciler.md](reconciler.md) (TypeBox Optimization Layer section) | [04-typebox-optimization-layer.md](../research/reconciler/04-typebox-optimization-layer.md) |
|
|
| 5 | Flowgraph HostConfig implementations | Downstream consumer (`@alkdev/flowgraph`), not ujsx | [05-flowgraph-host-configs.md](../research/reconciler/05-flowgraph-host-configs.md) |
|
|
|
|
## Document Lifecycle
|
|
|
|
Architecture documents use YAML frontmatter with `status` and `last_updated` fields:
|
|
|
|
```yaml
|
|
---
|
|
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, or `transform()` 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**. `selectNode` returning `undefined` for invalid paths, `setNode` returning the node unchanged for unresolvable paths, `Fragment` with 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.createInstance` throws, the error bubbles up through `mountNode` with no host involvement. UJSX does not catch or suppress host errors. The current implementation has no `handleError` hook on `HostConfig` — whether to add one is an open question (see [host-config.md](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/` |