add architecture docs synced to current source and sdd process
Phase 1 of SDD process: syncing docs/architecture/ to reflect the existing source code. Eight component documents describe WHAT and WHY (not HOW) for each module: schema, element factory, reactive layer, host config, transforms, events, pointers, and build distribution. Three ADRs capture key decisions (HTML-agnostic core, TypeBox Module as type registry, Preact signals-core for reactivity). Each doc documents known reconciler gaps and references the research in docs/research/reconciler/. Also adds docs/sdd_process.md (process reference shared across alkdev projects) matching the taskgraph_ts pattern.
This commit is contained in:
126
docs/architecture/README.md
Normal file
126
docs/architecture/README.md
Normal file
@@ -0,0 +1,126 @@
|
||||
---
|
||||
status: draft
|
||||
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** (to be addressed by the reconciler work documented in `docs/research/reconciler/`):
|
||||
|
||||
- `unmount()` is a stub — no fiber tree teardown, no instance removal, no signal disposal
|
||||
- `render()` is mount-only — no re-render, no diffing, no `prepareUpdate`/`commitUpdate` calls
|
||||
- `dispose` functions are no-ops — signal subscriptions leak
|
||||
- No `key` field on `UElement` — positional matching only
|
||||
|
||||
## 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 |
|
||||
| [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 |
|
||||
|
||||
## 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 `docs/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 research in `docs/research/reconciler/` documents a phased plan to close the current gaps:
|
||||
|
||||
| Phase | Description | Status |
|
||||
|-------|-------------|--------|
|
||||
| 0 | `key` field on `UElement` | Research complete |
|
||||
| 1 | Reactive → Host bridge (fiber tree, signal-driven updates) | Research complete |
|
||||
| 2 | Key-based children reconciliation (LIS algorithm) | Research complete |
|
||||
| 3 | Unmount & dispose support | Research complete |
|
||||
| 4 | TypeBox value optimization layer | Research complete |
|
||||
| 5 | Flowgraph HostConfig implementations | Research complete |
|
||||
|
||||
Research docs are in `docs/research/reconciler/`. Architecture docs for the reconciler will be created during the architecture phase of the SDD process, informed by this research.
|
||||
|
||||
## 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`.
|
||||
|
||||
## References
|
||||
|
||||
- UJSX research index: `docs/research/README.md`
|
||||
- Reconciler research: `docs/research/reconciler/`
|
||||
- SDD process: `docs/sdd_process.md`
|
||||
- Preact signals-core: `@preact/signals-core`
|
||||
- TypeBox: `@alkdev/typebox`
|
||||
- Taskgraph_ts architecture pattern: `/workspace/@alkdev/taskgraph_ts/docs/architecture/`
|
||||
183
docs/architecture/build-distribution.md
Normal file
183
docs/architecture/build-distribution.md
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
# Build & Distribution
|
||||
|
||||
Package structure, exports map, dependencies, and runtime targets.
|
||||
|
||||
## Package
|
||||
|
||||
- **Name**: `@alkdev/ujsx`
|
||||
- **Version**: 0.1.0 (pre-release)
|
||||
- **License**: MIT OR Apache-2.0 (dual-licensed, consumer chooses)
|
||||
- **Module type**: ESM (`"type": "module"`)
|
||||
|
||||
## Build
|
||||
|
||||
UJSX uses [tsup](https://tsup.egg.garden/) for building. tsup compiles TypeScript to dual-format output (ESM + CJS) with type declarations in a single build step, replacing the need for separate `tsc` and bundler passes.
|
||||
|
||||
### Build commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `npm run build` | tsup production build (writes to `dist/`) |
|
||||
| `npm run build:tsc` | `tsc --noEmit` (type checking only) |
|
||||
| `npm run lint` | `tsc --noEmit` (same as build:tsc) |
|
||||
| `npm run test` | `vitest run` |
|
||||
| `npm run test:watch` | `vitest` in watch mode |
|
||||
| `npm run test:coverage` | `vitest run --coverage` |
|
||||
|
||||
### Output format
|
||||
|
||||
Each entry point produces four files:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `*.js` | ESM module |
|
||||
| `*.cjs` | CJS module |
|
||||
| `*.d.ts` | ESM type declarations |
|
||||
| `*.d.cts` | CJS type declarations |
|
||||
|
||||
tsup generates both ESM and CJS from a single TypeScript source. The `exports` map in `package.json` routes consumers to the correct output based on their module resolution strategy.
|
||||
|
||||
## Sub-path Exports
|
||||
|
||||
```json
|
||||
{
|
||||
".": "src/mod.ts",
|
||||
"./schema": "src/core/schema.ts",
|
||||
"./h": "src/core/h.ts",
|
||||
"./reactive": "src/core/reactive.ts",
|
||||
"./context": "src/core/context.ts",
|
||||
"./events": "src/core/events.ts",
|
||||
"./pointer": "src/core/pointer.ts",
|
||||
"./host": "src/host/config.ts",
|
||||
"./transform": "src/transform/registry.ts",
|
||||
"./jsx-runtime": "src/core/jsx-runtime.ts"
|
||||
}
|
||||
```
|
||||
|
||||
Each sub-path export maps directly to a single source file. There is no barrel re-export within sub-paths — `./schema` resolves to `schema.ts` only, not to a directory index that re-exports multiple modules.
|
||||
|
||||
### Design rationale
|
||||
|
||||
Sub-path exports exist for tree-shaking. Without them, importing `ValuePointer` would pull in the entire `mod.ts` barrel, including the HostConfig, TransformRegistry, and event system. With sub-paths, a consumer can import only what they use:
|
||||
|
||||
```typescript
|
||||
import { ValuePointer } from "@alkdev/ujsx/pointer";
|
||||
```
|
||||
|
||||
This results in a smaller bundle because bundlers can eliminate unused exports at the sub-path boundary. The barrel export (`.`) remains available for convenience — consumers who want everything can import from the root.
|
||||
|
||||
### jsx-runtime
|
||||
|
||||
The `./jsx-runtime` export enables `jsxImportSource` configuration. When a consumer sets:
|
||||
|
||||
```json
|
||||
{ "compilerOptions": { "jsxImportSource": "@alkdev/ujsx" } }
|
||||
```
|
||||
|
||||
TypeScript and other JSX transforms resolve `jsx`, `jsxs`, and `jsxDEV` from `@alkdev/ujsx/jsx-runtime`. All three are aliases for `h()` — UJSX does not distinguish between static children, dynamic children, or dev mode at the factory level.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Package | Version | Role | Hard/Peer? |
|
||||
|---------|---------|------|------------|
|
||||
| `@alkdev/typebox` | `^0.34.49` | Schema definition, runtime validation (`Value.Check`, `Module`) | Hard |
|
||||
| `@preact/signals-core` | `^1.14.1` | Reactive primitives (`signal`, `effect`, `computed`, `batch`) | Hard |
|
||||
| `@alkdev/pubsub` | `^0.1.0` | `PubSubLike` interface for event system | Hard (see note) |
|
||||
|
||||
### @alkdev/pubsub consideration
|
||||
|
||||
`@alkdev/pubsub` provides the `PubSubLike` interface used by the event system. It is currently a hard dependency, but UJSX only uses it for type definitions — the `PubSubLike<TEventMap>` interface and `EventEnvelope` type. The actual pubsub implementation is injected by consumers.
|
||||
|
||||
This is a candidate for moving to a **peer dependency** or even an optional dependency. Making it a peer would:
|
||||
- Remove the runtime dependency for consumers that don't use the event system
|
||||
- Make it explicit that UJSX does not provide a pubsub implementation
|
||||
- Allow consumers to choose their pubsub version independently
|
||||
|
||||
The current hard dependency works because `@alkdev/pubsub` is lightweight and unlikely to cause version conflicts within the `@alkdev` scope, but it should be reconsidered before 1.0.
|
||||
|
||||
### @alkdev/typebox
|
||||
|
||||
TypeBox is a hard dependency, not optional. The `UJSX` module is a `Type.Module` — it is imported and used at runtime for `Value.Check()` in schema validation and `matchesSchema()` in transforms. Removing TypeBox would remove the ability to validate UNode structures at runtime, which is a core feature.
|
||||
|
||||
### @preact/signals-core
|
||||
|
||||
Preact signals-core is the reactive layer. `ValuePointer` wraps `signal<T>`, `Context` uses `signal<ContextValue>`, and the reactive module (`reactive.ts`) composes `effect`, `computed`, and `batch`. This is a hard dependency because reactivity is fundamental to UJSX's update model, not an optional feature.
|
||||
|
||||
## Dev Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `typescript` | Type checking and declaration generation |
|
||||
| `tsup` | Build tool (dual ESM/CJS output) |
|
||||
| `vitest` | Test runner |
|
||||
| `@vitest/coverage-v8` | Code coverage |
|
||||
| `@types/node` | Node.js type definitions |
|
||||
|
||||
No runtime dev dependencies. No test framework besides Vitest. No linter configuration (type checking via `tsc --noEmit` serves as the static analysis step).
|
||||
|
||||
## Engine Requirements
|
||||
|
||||
```json
|
||||
"engines": { "node": ">=18.0.0" }
|
||||
```
|
||||
|
||||
Node 18 is the minimum for ESM support, `structuredClone`, and stable `fetch`. UJSX does not use Node-specific APIs in its core module paths — no `fs`, no `path`, no `child_process`. Platform-specific features (file I/O, Node APIs) should go in separate packages.
|
||||
|
||||
## Platform Agnosticism
|
||||
|
||||
UJSX core is platform-agnostic. It runs in:
|
||||
- Node.js 18+
|
||||
- Deno (with npm specifiers)
|
||||
- Bun
|
||||
- Any browser that supports ESM and the `@preact/signals-core` package
|
||||
|
||||
There are no Node-specific API calls in `src/core/` or `src/transform/`. The `src/host/` directory contains host configurations, but these are also platform-agnostic — they define what "create instance" and "update instance" mean for a given target, not how to interact with Node.js APIs.
|
||||
|
||||
## Published Files
|
||||
|
||||
```json
|
||||
"files": ["dist"]
|
||||
```
|
||||
|
||||
Only the `dist/` directory is published to npm. Source maps are generated by tsup but source files (`src/`) are not included in the package. This keeps the package small and signals that `dist/` is the stable API surface.
|
||||
|
||||
```json
|
||||
"publishConfig": { "access": "public" }
|
||||
```
|
||||
|
||||
The package is publicly accessible on npm under the `@alkdev` scope.
|
||||
|
||||
## Known Gaps
|
||||
|
||||
### No source maps in production
|
||||
|
||||
tsup generates source maps, but the `files` field only includes `dist/`. Consumers can debug the compiled output but cannot step through the original TypeScript source without cloning the repo. Adding `src/` to the published files or enabling source map hosting would improve the debugging experience.
|
||||
|
||||
### No tree-shaking validation
|
||||
|
||||
The sub-path export strategy is designed for tree-shaking, but there is no CI step that validates bundle sizes or checks that unused sub-paths are eliminated. A `size-limit` or `bundlewatch` check would confirm that the exports map achieves its goal.
|
||||
|
||||
### CJS compatibility is untested
|
||||
|
||||
The exports map provides CJS entry points, but the test suite runs via Vitest with ESM imports. CJS consumers may encounter edge cases (named exports, default export handling) that are not covered by automated tests.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **ESM primary** — the module is `"type": "module"`. CJS is a distribution compatibility layer, not the primary module format. Consumers should import as ESM.
|
||||
- **No platform-specific APIs in core** — `src/core/` and `src/transform/` must not import `fs`, `path`, `child_process`, or other Node.js built-in modules. Platform-specific features belong in separate packages.
|
||||
- **Dual format via tsup** — the same TypeScript source produces ESM and CJS. No separate build pipelines. The `exports` map must always have matching `import` and `require` entries for each sub-path.
|
||||
- **@alkdev/typebox is non-negotiable** — TypeBox provides the schema system. Without it, `Value.Check`, `Module.Import`, and `matchesSchema` cannot function. Do not make it optional or replace it with a lighter alternative without re-evaluating the entire validation strategy.
|
||||
- **@preact/signals-core is the reactive primitive** — do not introduce an alternative reactive system (RxJS, Solid signals, Vue reactivity) alongside Preact signals. All reactive state flows through `signal`, `effect`, `computed`, and `batch` from `@preact/signals-core`.
|
||||
|
||||
## References
|
||||
|
||||
- Source: `package.json`
|
||||
- Build tool: tsup configuration (inline in `package.json` or `tsup.config.ts`)
|
||||
- TypeBox: `@alkdev/typebox`
|
||||
- Preact signals: `@preact/signals-core`
|
||||
- PubSub: `@alkdev/pubsub`
|
||||
31
docs/architecture/decisions/001-html-agnostic-core.md
Normal file
31
docs/architecture/decisions/001-html-agnostic-core.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
# ADR-001: HTML-agnostic core
|
||||
|
||||
**Status**: Proposed
|
||||
|
||||
## Context
|
||||
|
||||
UJSX is a universal JSX IR that treats JSX as an intermediate representation. The core types (UNode, UElement, UniversalProps) are deliberately free of any platform-specific concepts.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **HTML-compatible props (className, onClick, etc.)**: Include DOM/HTML-specific prop names in core types. Rejected because it biases toward DOM rendering and forces non-DOM hosts to filter/translate irrelevant props.
|
||||
- **Separate "web" prop types**: Create a parallel type hierarchy for web/DOM props alongside the universal core. Rejected because it creates two type hierarchies and increases complexity for hosts that don't need HTML.
|
||||
|
||||
## Decision
|
||||
|
||||
UJSX core does NOT include HTML/DOM-specific concepts. No `onClick`, no `className`, no `style`, no `aria-*`. UniversalProps accepts any key-value pairs because different hosts need different prop shapes. The host decides what props mean.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Clean separation. Each host defines its own prop semantics. Flowgraph hosts use `{ name, type, url }` not `{ className, id }`.
|
||||
- TypeBox schemas for host-specific props can validate independently. No "stripping" step needed.
|
||||
|
||||
### Negative
|
||||
- Consumers targeting HTML must map their own props (e.g., `class` → `className`). This is the host's responsibility.
|
||||
- No built-in event handling system — events are host-specific. The event system (`PubSubLike`, `EventEnvelope`) is generic.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
# ADR-002: TypeBox Module as type registry
|
||||
|
||||
**Status**: Proposed
|
||||
|
||||
## Context
|
||||
|
||||
UJSX needs a type system that serves three purposes: (1) runtime validation of UNode trees, (2) JSON Schema export for documentation and tooling, (3) TypeScript type inference for developers.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Separate registry class with `register()` methods**: Build a class-based registry that maps type names to schemas. Rejected because it separates the runtime schema from the TypeScript types and adds unnecessary indirection.
|
||||
- **Zod schemas**: Use Zod for runtime validation and type inference. Rejected because Zod doesn't have `Type.Module`-style composable schemas or `Value.Equal`/`Value.Hash`/`Value.Diff` primitives that UJSX needs for the reconciler.
|
||||
- **Derived TypeScript types (`Static<typeof UJSX>`)**: Derive all types from the TypeBox Module using `Static`. Rejected for the core union types because `ComponentFn` can't be serialized (functions aren't valid JSON Schema) and the inferred types from complex TypeBox unions were less readable than hand-written types.
|
||||
|
||||
## Decision
|
||||
|
||||
The `Type.Module` system from `@alkdev/typebox` IS the type registry. No separate registry class or map. The `UJSX` Module defines all core types (`UPrimitive`, `PropValue`, `UniversalProps`, `UElement`, `URoot`, `UNode`) in a single `Type.Module` call, and TypeScript types are defined separately for clean inference.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Single source of truth for validation. `Value.Check(UJSX.Import("UElement"), node)` validates at runtime.
|
||||
- JSON Schema export for free: `Value.Convert(UJSX.Import("UElement"), node)` for serialization.
|
||||
- The planned reconciler can use `Value.Equal`, `Value.Hash`, `Value.Diff` for optimization (see reconciler Phase 4 research).
|
||||
|
||||
### Negative
|
||||
- TypeBox types and hand-written TypeScript types must be kept in sync manually. This is tractable (7 types) but requires discipline.
|
||||
- Consumers must depend on `@alkdev/typebox` to use schema validation, even if they only want the tree types. The tree types themselves are importable without TypeBox.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
# ADR-003: Preact signals-core for reactivity
|
||||
|
||||
**Status**: Proposed
|
||||
|
||||
## Context
|
||||
|
||||
UJSX needs a reactive primitive for propagating changes through element trees. The reactive layer (ReactiveRoot, reactiveComponent, reactiveElement) needs signal, computed, effect, and batch operations.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Custom reactive implementation**: Write a signal/computed/effect system from scratch. Rejected because writing a correct implementation (cycle detection, batch scheduling, lazy evaluation) is non-trivial and Preact's implementation is battle-tested.
|
||||
- **Solid.js reactive primitives**: Use Solid's reactive system. Rejected because they're coupled to Solid's render cycle and not published as a standalone package.
|
||||
- **Vue reactivity**: Use Vue's reactive system. Rejected because it uses Proxy-based tracking which has different performance characteristics and isn't designed for tree-agnostic use.
|
||||
- **RxJS**: Use observables for reactivity. Rejected because it's an observable/stream model, not a reactive state model. Different paradigm, much larger API surface.
|
||||
|
||||
## Decision
|
||||
|
||||
Use `@preact/signals-core` as the reactive layer. Re-export its primitives (`signal`, `computed`, `effect`, `batch`) and types (`Signal`, `ReadonlySignal`) from the UJSX reactive module.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Proven implementation with ~800 lines of battle-tested code, used in Preact and adopted by other frameworks.
|
||||
- Small dependency surface — `@preact/signals-core` has zero dependencies.
|
||||
- Batch scheduling is built-in, matching the reconciliation model planned for the reconciler.
|
||||
- Future consumers (flowgraph reactive host) can use the same signal/computed/effect primitives without a different reactive library.
|
||||
|
||||
### Negative
|
||||
- UJSX re-exports Preact signals, creating a coupling. If Preact signals changes its scheduling model, UJSX is affected.
|
||||
- The `effect` return value (dispose function) is currently discarded in reactiveComponent/reactiveElement — the dispose functions are no-ops. This is a known gap that the reconciler work addresses.
|
||||
156
docs/architecture/element-factory.md
Normal file
156
docs/architecture/element-factory.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
# Element Factory
|
||||
|
||||
The element factory (`h()`, `createRoot()`, `createComponent()`, `Fragment`) and JSX runtime exports that construct the universal tree.
|
||||
|
||||
## Overview
|
||||
|
||||
UJSX trees are built by factory functions, not by class instantiation or builder patterns. The tree is a plain-data structure — objects with `type`, `props`, and `children` — and the factories ensure that structure is consistent, normalized, and free of null/false sentinel values.
|
||||
|
||||
The factory layer is thin by design. It does not validate props against schemas, does not call any host, and does not maintain any tree-level state beyond a root ID counter. Its job is to produce `UNode` values that are safe for any host to consume.
|
||||
|
||||
## h() Factory
|
||||
|
||||
`h()` is the universal element constructor. It mirrors the hyperscript signature (`type, props, ...children`) and produces either a `UElement` or a `URoot` depending on the `type` argument.
|
||||
|
||||
```typescript
|
||||
function h(type: UType, props?: UniversalProps | null, ...children: UNode[]): UElement | URoot
|
||||
```
|
||||
|
||||
### Branch on type
|
||||
|
||||
- **`type === "root"`** — returns a `URoot` with `type: "root"`. This is the only way the `"root"` literal enters the tree. No other type string produces a `URoot`.
|
||||
- **Any other string or `ComponentFn`** — returns a `UElement` with that type. Component functions are rendered by hosts; `h()` does not call them.
|
||||
|
||||
### Child normalization
|
||||
|
||||
Children are processed in two steps:
|
||||
|
||||
1. **Flatten** — `children.flat(Infinity)` recursively flattens nested arrays. This means `[Fragment({ children: [...] }), [...nested]]` all collapse into a single flat list. The cast to `1` (instead of `Infinity`) is a TypeScript type-level concession; at runtime, `Infinity` flattens fully.
|
||||
2. **Filter** — `c != null && c !== false` removes `null`, `undefined`, and `false` values from the child list. This matches JSX conventions: `{condition && <elem/>}` produces `false` when the condition fails, and conditional rendering should silently drop these values rather than rendering them.
|
||||
|
||||
`true` is **not** filtered — it passes through as a `UPrimitive`. This is consistent with how React treats `true` in JSX (it renders nothing in DOM but is not stripped at the factory level).
|
||||
|
||||
### Props handling
|
||||
|
||||
`props` is shallow-copied (`{ ...props }`) if provided, or defaulted to an empty object. This means `h()` does not mutate the caller's props object, but it also does not deep-clone — nested objects and arrays are shared references.
|
||||
|
||||
If `props` is `null` or `undefined`, `resolvedProps` is `{}`. This prevents downstream code from needing null-checks on `element.props`.
|
||||
|
||||
## createRoot()
|
||||
|
||||
```typescript
|
||||
function createRoot(id: string | undefined, ...children: UNode[]): URoot
|
||||
```
|
||||
|
||||
`createRoot()` exists because `h("root", { id })` obscures the purpose. A root is a container — the entry point for a host to mount a tree — and `createRoot()` makes that explicit.
|
||||
|
||||
### Auto-generated IDs
|
||||
|
||||
When `id` is `undefined`, `createRoot()` generates one from `_idCounter` — e.g., `"root_1"`, `"root_2"`. This ensures every root has a unique identifier for the host layer to track, without requiring the consumer to invent one.
|
||||
|
||||
The counter is a module-level variable (`let _idCounter = 0`). It is:
|
||||
|
||||
- **Not reactive** — incrementing it does not trigger signal updates.
|
||||
- **Not thread-safe** — JavaScript is single-threaded, and root creation is not a concurrent operation. If UJSX is used in a worker or multi-context environment, each context gets its own module instance with its own counter.
|
||||
|
||||
These trade-offs are acceptable because root creation happens once at mount time, not in render loops.
|
||||
|
||||
### Child normalization
|
||||
|
||||
Same as `h()`: `flat(Infinity)` then filter `null`/`undefined`/`false`.
|
||||
|
||||
## createComponent()
|
||||
|
||||
```typescript
|
||||
function createComponent<P extends UniversalProps>(
|
||||
name: string,
|
||||
render: (props: P) => UNode,
|
||||
targets?: string[],
|
||||
): UComponent<P>
|
||||
```
|
||||
|
||||
`createComponent()` wraps a render function and attaches metadata. It does not create an element — it returns a `UComponent`, which is a callable function with `displayName` and `targets` properties.
|
||||
|
||||
### displayName
|
||||
|
||||
`displayName` is for debugging and error messages. It is analogous to React's `displayName` on function components. When a host logs or inspects a component, `displayName` provides a human-readable label instead of `"anonymous"`.
|
||||
|
||||
### targets
|
||||
|
||||
`targets` is a string array hinting which hosts should render this component. For example, `["dom", "ssr"]` means the component is relevant to DOM and SSR hosts. A host that does not appear in `targets` can skip the component.
|
||||
|
||||
This is a **hint, not a gate**. A host is free to render any component regardless of `targets`. The field exists for host-level optimization and filtering, not for access control.
|
||||
|
||||
### Type coercion
|
||||
|
||||
The implementation casts `render` to `UComponent<P>` via `unknown`. This is safe because `UComponent<P>` extends the render function's signature with optional properties (`displayName`, `targets`), and those properties are assigned immediately after the cast. The cast exists because TypeScript cannot infer that adding properties to a function makes it conform to the interface.
|
||||
|
||||
## Fragment
|
||||
|
||||
```typescript
|
||||
function Fragment(props: { children?: UNode[] }): UNode[]
|
||||
```
|
||||
|
||||
`Fragment` returns a flat array of `UNode` values. It does **not** wrap children in a containing element. This is the defining difference from `h("fragment", {}, ...children)`, which would produce a `UElement` node in the tree.
|
||||
|
||||
Fragment is not a tree node — it is a grouping mechanism that dissolves during construction. Its return type is `UNode[]`, not `UNode`, reflecting that it expands into the parent's child list rather than occupying its own position.
|
||||
|
||||
Child normalization is the same as `h()`: flatten and filter.
|
||||
|
||||
## JSX Runtime
|
||||
|
||||
```typescript
|
||||
export const jsx = h;
|
||||
export const jsxs = h;
|
||||
export const jsxDEV = h;
|
||||
```
|
||||
|
||||
These three exports enable UJSX to serve as a JSX runtime. When a consumer configures their TypeScript/babel/astro JSX transform with:
|
||||
|
||||
```json
|
||||
{ "jsxImportSource": "@alkdev/ujsx" }
|
||||
```
|
||||
|
||||
The transform emits calls to `jsx`, `jsxs`, and `jsxDEV` from `@alkdev/ujsx/jsx-runtime` (or the appropriate entry point). All three are aliases for `h()` because UJSX does not distinguish between static children (`jsxs`), dynamic children (`jsx`), and dev-mode calls (`jsxDEV`) at the factory level. The distinction exists in React for dev warnings and children array vs. single-child optimizations; UJSX normalizes children uniformly, so the aliases are identical.
|
||||
|
||||
Consumers who prefer hyperscript-style code can call `h()` directly. The JSX runtime exports exist solely for toolchain compatibility.
|
||||
|
||||
## Known Gaps
|
||||
|
||||
### `key` prop not extracted
|
||||
|
||||
`h()` currently passes **all** props through to the element, including `key` if provided. The reconciler requires `key` as a first-class field on `UElement` for identity-based children matching (see Phase 2 research in `docs/research/reconciler/02-key-based-children-reconciliation.md`).
|
||||
|
||||
When `key` extraction is implemented, `h()` should:
|
||||
|
||||
1. Remove `key` from `resolvedProps` before constructing the element.
|
||||
2. Promote `key` to `element.key` as a top-level field.
|
||||
3. Ensure component functions never receive `key` in their props.
|
||||
|
||||
This is documented in the schema architecture (`docs/architecture/schema.md` — Known Gaps: `key` field on `UElement`) and the reconciler key design (`docs/research/reconciler/00-KEY-FIELD-DESIGN.md`).
|
||||
|
||||
### No prop validation
|
||||
|
||||
`h()` does not validate props against the TypeBox schema. It shallow-copies whatever is passed. Runtime validation is the consumer's or host's responsibility. This keeps the factory fast and dependency-free — `h()` never imports from `schema.ts` at runtime.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **`h()` is pure** — no side effects beyond the `_idCounter` increment in `createRoot()`. It does not call hosts, subscribe to signals, or mutate external state.
|
||||
- **Children are always flat** — `flat(Infinity)` + filter means consumers never receive nested arrays or null/false children from factory output. Hosts and transforms can assume `element.children` is a flat `UNode[]` with no null slots.
|
||||
- **Props are not deep-cloned** — `h()` spreads props shallowly. Nested objects are shared references. Consumers must not mutate element.props and expect isolation.
|
||||
- **`Fragment` produces arrays, not elements** — hosts and transforms must handle `UNode[]` return values from component renders. A Fragment does not appear in the tree.
|
||||
- **`_idCounter` is module-scoped** — each module instance has its own counter. If multiple copies of UJSX are loaded (e.g., different package versions), roots from different copies may collide on `id` values.
|
||||
- **JSX aliases are identical** — `jsx`, `jsxs`, and `jsxDEV` are the same function. UJSX does not differentiate between them. Dev-mode only features (e.g., source location) are not currently supported.
|
||||
|
||||
## References
|
||||
|
||||
- Source: `src/core/h.ts`
|
||||
- Schema types: `src/core/schema.ts`
|
||||
- Schema architecture: `docs/architecture/schema.md`
|
||||
- Key field design: `docs/research/reconciler/00-KEY-FIELD-DESIGN.md`
|
||||
- Key-based reconciliation: `docs/research/reconciler/02-key-based-children-reconciliation.md`
|
||||
154
docs/architecture/events.md
Normal file
154
docs/architecture/events.md
Normal file
@@ -0,0 +1,154 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
# Events
|
||||
|
||||
EventEnvelope, PubSubLike, and the UjsxEventMap that define the observability layer.
|
||||
|
||||
## Overview
|
||||
|
||||
UJSX uses a typed event system for observability. HostConfigs emit events at key lifecycle points — root render, instance creation, component invocation — and consumers subscribe to those events for logging, debugging, or forwarding to external systems. The system is fire-and-forget: events are published with no expectation of acknowledgment, no retry, and no built-in routing beyond type-based subscription.
|
||||
|
||||
The event layer is intentionally decoupled from the reactive layer. Signals handle state propagation (data flow). Events handle observability (what happened). Mixing them would conflate "the tree changed" with "the host was notified," which are different concerns with different timing guarantees.
|
||||
|
||||
## EventEnvelope
|
||||
|
||||
```typescript
|
||||
interface EventEnvelope<TType extends string, TPayload> {
|
||||
readonly type: TType;
|
||||
readonly id: string;
|
||||
readonly payload: TPayload;
|
||||
}
|
||||
```
|
||||
|
||||
Every event is wrapped in an envelope with three fields:
|
||||
|
||||
- **type** — a string discriminator that identifies the event kind. Typed via `TType` so consumers can narrow payloads by type.
|
||||
- **id** — a unique identifier for correlation and deduplication. Events with the same id represent the same occurrence.
|
||||
- **payload** — the typed data carried by the event. Shape is determined by `UjsxEventMap[TType]`.
|
||||
|
||||
The envelope is `readonly` — consumers cannot mutate a received event. This prevents accidental mutation of shared event objects and makes it safe to forward or store events without copying.
|
||||
|
||||
### Why both type and id?
|
||||
|
||||
`type` answers "what kind of event is this?" and enables type-based subscription. `id` answers "which specific occurrence?" and enables correlation across systems. A root render event and an instance create event for the same render cycle may share no data except timing — `id` gives consumers a join key. The id generation strategy (currently `Date.now()` in `proxyEventEmitter`) is not specified by the envelope interface; consumers should not assume monotonicity or uniqueness beyond a reasonable best-effort guarantee.
|
||||
|
||||
## PubSubLike
|
||||
|
||||
```typescript
|
||||
interface PubSubLike<TEventMap extends Record<string, unknown>> {
|
||||
publish<TType>(type: TType, id: string, payload: TEventMap[TType]): void;
|
||||
subscribe<TType>(type: TType, id: string): AsyncIterable<EventEnvelope<TType, TEventMap[TType]>>;
|
||||
}
|
||||
```
|
||||
|
||||
PubSubLike is a generic typed publish/subscribe interface. It is not a concrete implementation — it is the contract that UJSX's event system expects from a pubsub provider.
|
||||
|
||||
### Design decisions
|
||||
|
||||
- **subscribe returns AsyncIterable** — not a callback, not an EventEmitter, not a Promise. AsyncIterable is the most composable streaming primitive in modern JavaScript: it works with `for await ... of`, can be wrapped in backpressure-aware pipelines, and composes naturally with `AbortSignal`-based cancellation. This matches the consuming patterns of workflow engines and async orchestrators.
|
||||
|
||||
- **Both type and id on subscribe** — `type` filters by event kind. `id` filters by occurrence. A subscriber can listen to all `root.render` events (type-only) or to a specific render cycle (type + id). The concrete pubsub implementation decides whether `id` is a filter or a partition key.
|
||||
|
||||
- **publish returns void** — fire-and-forget. No acknowledgment, no response, no promise. The publisher does not wait for subscribers. This is consistent with the observability role: events are side effects of tree operations, not request/response messages.
|
||||
|
||||
### @alkdev/pubsub
|
||||
|
||||
The `PubSubLike` interface is defined in `@alkdev/pubsub`. UJSX re-exports it as the type constraint for its event system. The actual pubsub implementation is injected by the consumer — UJSX does not bundle a pubsub runtime.
|
||||
|
||||
## UjsxEventMap
|
||||
|
||||
```typescript
|
||||
type UjsxEventMap = {
|
||||
"root.render": { childCount: number };
|
||||
"root.unmount": Record<string, unknown>;
|
||||
"instance.create": { kind: "text" | "element"; tag?: string; value?: string; props?: Record<string, unknown> };
|
||||
"component.invoke": { type: string };
|
||||
"type.call": { objectName: string; methodName: string; args: unknown[] };
|
||||
"transform.apply": { ruleName: string; direction: string };
|
||||
};
|
||||
```
|
||||
|
||||
The event map defines the known event types and their payload shapes. Each key is an event type string; each value is the expected payload structure.
|
||||
|
||||
| Event | When emitted | Payload |
|
||||
|-------|-------------|---------|
|
||||
| `root.render` | HostConfig calls `render()` on a root | `childCount` of the rendered tree |
|
||||
| `root.unmount` | HostConfig calls `unmount()` on a root | Empty record (no data) |
|
||||
| `instance.create` | HostConfig creates a text or element instance | `kind` discriminates text vs. element; `tag`, `value`, `props` are element-specific |
|
||||
| `component.invoke` | HostConfig calls a component function | `type` is the component's type string or display name |
|
||||
| `type.call` | Proxy event for remote type system dispatch | `objectName`, `methodName`, `args` describe the invoked method |
|
||||
| `transform.apply` | TransformRegistry applies a matching rule | `ruleName` and `direction` identify which rule matched |
|
||||
|
||||
### Event granularity
|
||||
|
||||
These events are coarse-grained. They answer "what happened at this boundary?" not "what was the state of every node?". Fine-grained node-level changes are handled by the reactive layer (signals), not by events.
|
||||
|
||||
## createPubSubEmitter
|
||||
|
||||
```typescript
|
||||
function createPubSubEmitter<TEventMap>(pubsub: PubSubLike<TEventMap>): (type: string, id: string, payload: unknown) => void
|
||||
```
|
||||
|
||||
Wraps a `PubSubLike` into a simple callback function. This is the bridge between the HostConfig's `emit()` function and the typed pubsub system. HostConfig needs a `(type, id, payload) => void` emitter — `createPubSubEmitter` provides exactly that signature while preserving type safety at the call site.
|
||||
|
||||
The returned function does not validate that `type` is a known event key. If an unknown type is passed, the underlying pubsub implementation decides what happens (likely a no-op or a log warning).
|
||||
|
||||
## proxyEventEmitter
|
||||
|
||||
```typescript
|
||||
function proxyEventEmitter(pubsub: PubSubLike<UjsxEventMap>): { onTypeCall(objectName, methodName, args): void }
|
||||
```
|
||||
|
||||
Convenience wrapper that maps the `type.call` event type to a method-call interface. Instead of:
|
||||
|
||||
```typescript
|
||||
pubsub.publish("type.call", id, { objectName, methodName, args });
|
||||
```
|
||||
|
||||
Consumers write:
|
||||
|
||||
```typescript
|
||||
proxy.onTypeCall("MyObject", "myMethod", [arg1, arg2]);
|
||||
```
|
||||
|
||||
The id is auto-generated from `Date.now()`. This is adequate for debugging and local event forwarding, but not for distributed deduplication. The id is not guaranteed to be unique across concurrent calls within the same millisecond.
|
||||
|
||||
## Known Gaps
|
||||
|
||||
### No event routing or middleware
|
||||
|
||||
Events are published directly to the pubsub. There is no middleware chain for filtering, transforming, or enriching events before they reach subscribers. Consumers that need middleware should wrap their pubsub implementation.
|
||||
|
||||
### No event history or replay
|
||||
|
||||
The event system is live-only. Subscribers receive events from the moment they subscribe; there is no replay of past events. Consumers that need event sourcing should persist events in their own store.
|
||||
|
||||
### No error events
|
||||
|
||||
The event map does not include error events (e.g., `instance.error`, `render.error`). HostConfig errors currently propagate as thrown exceptions, not as events. Adding error events would require deciding between fire-and-forget error logging and error-handling orchestration.
|
||||
|
||||
### type.call is a narrow proxy
|
||||
|
||||
`proxyEventEmitter` only handles `type.call`. There is no proxy for other event types. As the event map grows, this convenience may need to expand or be replaced by a more general proxy pattern.
|
||||
|
||||
### No cleanup on unmount
|
||||
|
||||
The current `unmount()` implementation in `HostConfig` does not tear down event subscriptions. If a pubsub emitter was wired to the host's `emit()` during render, unmount does not unsubscribe. This gap is shared with the broader reconciler disposal gap — see [reactive-layer.md](reactive-layer.md) (no-op `dispose` functions) and the unmount & dispose research ([03-unmount-dispose-support.md](../../research/reconciler/03-unmount-dispose-support.md)).
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Fire-and-forget** — events are published without acknowledgment. The publisher does not know if any subscriber received or processed the event.
|
||||
- **PubSubLike is injected** — UJSX does not provide a pubsub implementation. The consumer supplies one. This keeps UJSX platform-agnostic and avoids coupling to a specific pubsub runtime.
|
||||
- **UjsxEventMap is a closed type** — the event map is a fixed union. Adding new event types requires modifying the `UjsxEventMap` type definition. This is intentional: event types define the observability contract and should be explicitly enumerated.
|
||||
- **AsyncIterable subscription** — `subscribe` returns `AsyncIterable`, not a callback. Consumers must use `for await ... of` or convert to a callback-based API. This is a deliberate choice for composability.
|
||||
- **id generation is caller-specified** — the envelope requires an `id`, but does not prescribe how it is generated. `proxyEventEmitter` uses `Date.now()`, but this is a convenience, not a contract. Other callers may use UUIDs, counters, or any unique string.
|
||||
|
||||
## References
|
||||
|
||||
- Source: `src/core/events.ts`
|
||||
- PubSubLike interface: `@alkdev/pubsub`
|
||||
- HostConfig emit: `src/host/config.ts`
|
||||
- Direction type: `src/core/context.ts`
|
||||
217
docs/architecture/host-config.md
Normal file
217
docs/architecture/host-config.md
Normal file
@@ -0,0 +1,217 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
# HostConfig & createRoot
|
||||
|
||||
The `HostConfig` interface and the `createRoot()` function that together define UJSX's platform-agnostic rendering contract.
|
||||
|
||||
## Overview
|
||||
|
||||
UJSX separates *what to render* (the `UNode` tree) from *how to render it* (the `HostConfig`). A `HostConfig` is a concrete adapter that tells UJSX how to create, organize, and update instances for a specific target — DOM, Three.js, graphology, markdown, or anything else. `createRoot()` ties a host to a container and exposes `render()` and `unmount()`.
|
||||
|
||||
The current implementation is **mount-only**: `render()` walks the tree once, creates instances, and appends children. There is no re-render path, no diffing, and no update cycle. The `unmount()` method is a stub. This gap is the core motivation for the reconciler research.
|
||||
|
||||
## HostConfig Interface
|
||||
|
||||
```typescript
|
||||
interface HostConfig<TTag extends string, Instance, RootCtx> {
|
||||
name: string;
|
||||
createRootContext(container: unknown, options?: Record<string, unknown>, context?: Context): RootCtx;
|
||||
finalizeRoot?(ctx: RootCtx): void;
|
||||
createInstance(tag: TTag, props: Record<string, unknown>, ctx: RootCtx, parent?: Instance): Instance;
|
||||
createTextInstance(text: string, ctx: RootCtx, parent?: Instance): Instance;
|
||||
appendChild(parent: Instance, child: Instance, ctx: RootCtx): void;
|
||||
insertBefore?(parent: Instance, child: Instance, before: Instance, ctx: RootCtx): void;
|
||||
removeChild?(parent: Instance, child: Instance, ctx: RootCtx): void;
|
||||
prepareUpdate?(instance: Instance, tag: TTag, prevProps: Record<string, unknown>, nextProps: Record<string, unknown>, ctx: RootCtx): unknown | null;
|
||||
commitUpdate?(instance: Instance, payload: unknown, tag: TTag, prevProps: Record<string, unknown>, nextProps: Record<string, unknown>, ctx: RootCtx): void;
|
||||
emit?(type: string, id: string, payload: unknown): void;
|
||||
}
|
||||
```
|
||||
|
||||
### Three type parameters
|
||||
|
||||
`HostConfig` is generic over three type parameters that make it platform-agnostic:
|
||||
|
||||
- **`TTag`** — A string literal union constraining allowed element types. A DOM host might define `TTag = "div" | "span" | "input"`, a graph host might define `TTag = "node" | "edge"`. The type system prevents hosts from being asked to create instances for tags they don't support.
|
||||
- **`Instance`** — The host-specific instance type. DOM: `HTMLElement`. Three.js: `Object3D`. Graphology: `Graph`. This is what `createInstance` and `createTextInstance` return, and what `appendChild`/`removeChild` operate on.
|
||||
- **`RootCtx`** — The host-specific root context. Carries whatever the host needs for the lifetime of a root — canvas references, graph instances, renderer handles. Created once by `createRootContext`, passed to every host method.
|
||||
|
||||
### Lifecycle methods
|
||||
|
||||
| Method | Phase | Required | Purpose |
|
||||
|--------|-------|----------|---------|
|
||||
| `createRootContext` | Root initialization | Yes | Produce the `RootCtx` that threads through all subsequent calls |
|
||||
| `finalizeRoot` | Root teardown | No | Host cleanup after render or unmount |
|
||||
| `createInstance` | Mount | Yes | Create a host instance for an intrinsic element |
|
||||
| `createTextInstance` | Mount | Yes | Create a host instance for a primitive text node |
|
||||
| `appendChild` | Mount | Yes | Attach a child instance to a parent instance |
|
||||
| `insertBefore` | Reorder | No | Insert a child before a specific sibling; hosts that don't need ordering can fall back to `appendChild` |
|
||||
| `removeChild` | Unmount/update | No | Detach a child from a parent |
|
||||
| `prepareUpdate` | Update | No | Compare old and new props; return a payload if the instance needs updating, or `null` to skip |
|
||||
| `commitUpdate` | Update | No | Apply the payload from `prepareUpdate` to the instance |
|
||||
| `emit` | Observability | No | Emit telemetry events; hosts that don't need observability can omit this |
|
||||
|
||||
### Why methods are optional
|
||||
|
||||
`insertBefore`, `removeChild`, `prepareUpdate`, `commitUpdate`, `finalizeRoot`, and `emit` are optional because not every host needs them. A markdown renderer has no concept of reordering or in-place updates — it renders once to a string. A graph host may never need `insertBefore` because edge order is irrelevant. Making these optional lets hosts implement only what they need without stub methods.
|
||||
|
||||
## Root Interface
|
||||
|
||||
```typescript
|
||||
interface Root<TTag extends string, Instance, RootCtx> {
|
||||
host: HostConfig<TTag, Instance, RootCtx>;
|
||||
ctx: RootCtx;
|
||||
container: unknown;
|
||||
context: Context;
|
||||
render(node: UNode): void;
|
||||
unmount(): void;
|
||||
}
|
||||
```
|
||||
|
||||
`Root` is the product of `createRoot()`. It carries:
|
||||
|
||||
- **`host`** — The `HostConfig` that governs all instance operations.
|
||||
- **`ctx`** — The `RootCtx` produced by `host.createRootContext()`.
|
||||
- **`container`** — The opaque container the host renders into (a DOM element, a canvas, a graph instance).
|
||||
- **`context`** — A `Context` (from `src/core/context.ts`) wrapping a signal-based `ContextValue` with `density`, `target`, and `metadata` fields. Hosts use this to adapt rendering based on target density or other metadata. If no `Context` is provided, `createRoot()` creates a default one.
|
||||
- **`render(node)`** — Mount the tree (see Mount Pipeline).
|
||||
- **`unmount()`** — Teardown (see Known Gaps).
|
||||
|
||||
The `Context` connection is intentional: UJSX wants hosts to make density-aware and target-aware decisions without the host needing to know about signal internals. `Context` provides a reactive `ContextValue` that hosts can read during `createInstance` or `commitUpdate`.
|
||||
|
||||
## createRoot()
|
||||
|
||||
```typescript
|
||||
function createRoot<TTag extends string, Instance, RootCtx>(
|
||||
host: HostConfig<TTag, Instance, RootCtx>,
|
||||
container: unknown,
|
||||
options?: Record<string, unknown>,
|
||||
context?: Context,
|
||||
): Root<TTag, Instance, RootCtx>
|
||||
```
|
||||
|
||||
`createRoot()` wires everything together:
|
||||
|
||||
1. Calls `host.createRootContext(container, options, context)` to produce `ctx`.
|
||||
2. Falls back to `new Context()` if no `context` is provided — every root has a `Context`, never `undefined`.
|
||||
3. Defines `mountNode` for recursive tree walking.
|
||||
4. Returns a `Root` object with `render()` and `unmount()`.
|
||||
|
||||
The `container` is intentionally `unknown` — UJSX doesn't prescribe what a container is. DOM hosts pass an `HTMLElement`, graph hosts pass a `Graph` instance, test hosts might pass a plain object.
|
||||
|
||||
## Mount Pipeline
|
||||
|
||||
`render()` calls `mountNode` recursively. The pipeline handles four node types:
|
||||
|
||||
### 1. `null` or `false`
|
||||
|
||||
Returned as `undefined`. These are "holes" in the tree — conditional rendering that chose not to render.
|
||||
|
||||
### 2. Primitives (`string | number | boolean | null`)
|
||||
|
||||
`host.createTextInstance()` converts the primitive to a string instance. If a `parentInst` is provided, `host.appendChild()` attaches it immediately.
|
||||
|
||||
### 3. `URoot` nodes
|
||||
|
||||
`URoot` is a transparent container — its children are mounted directly into the parent. The root node itself produces no host instance.
|
||||
|
||||
### 4. `UElement` nodes (intrinsic or function component)
|
||||
|
||||
- **Function component** (`typeof el.type === "function"`): The component function is called with `{ ...el.props, children: el.children }`. Its output is recursively mounted. Function components are transparent — they produce no host instance of their own.
|
||||
- **Intrinsic element** (`typeof el.type === "string"`): `host.createInstance()` creates the instance. Children are mounted recursively. After children are mounted, `host.appendChild(parentInst, inst, ctx)` attaches the instance to its parent. This post-order append means parents receive fully-constructed children.
|
||||
|
||||
### Why post-order append?
|
||||
|
||||
Children are appended to their parent *after* all descendants are created. This guarantees that when `appendChild` is called, the child is a complete subtree. Host implementations can rely on this ordering — a DOM host can set innerHTML on the child before appending it to the parent, and a graph host can finalize subgraph structure before connecting the parent edge.
|
||||
|
||||
### emit events
|
||||
|
||||
During mount, two event types are emitted:
|
||||
|
||||
| Event | When | Payload |
|
||||
|-------|------|---------|
|
||||
| `instance.create` | After `createInstance` or `createTextInstance` | `{ kind: "element" \| "text", tag?, props?, value? }` |
|
||||
| `component.invoke` | After calling a function component | `{ type: displayName \| "anonymous" }` |
|
||||
| `root.render` | After the full tree is mounted | `{ childCount }` |
|
||||
|
||||
These are debugging and observability hooks. The `id` fields use `Date.now()` suffixes, which is not collision-safe and will be replaced with proper identifiers when the reconciler introduces fiber nodes with stable identity.
|
||||
|
||||
## Known Gaps
|
||||
|
||||
### The Reconciler Gap
|
||||
|
||||
This is the critical gap in the current implementation.
|
||||
|
||||
`HostConfig` defines the **update contract** — `prepareUpdate` returns a payload describing what changed, `commitUpdate` applies that payload to the instance. But `createRoot().render()` is **mount-only**. It never calls `prepareUpdate` or `commitUpdate` because there is no re-render path:
|
||||
|
||||
- `render()` has no access to the previously rendered tree. No fiber tree is stored. No diffing occurs.
|
||||
- Calling `render()` a second time would create a completely new tree of instances, appending them alongside the first tree rather than updating the existing one.
|
||||
- The update methods exist on `HostConfig` as a **forward-compatible interface** — hosts can implement them now, and the reconciler will call them when it exists.
|
||||
|
||||
Similarly, `appendChild` is the only mutation method called during mount. `insertBefore` and `removeChild` are never called because there is no re-render or unmount logic that would reorder or remove nodes.
|
||||
|
||||
### `unmount()` is a Stub
|
||||
|
||||
```typescript
|
||||
unmount() {
|
||||
host.finalizeRoot?.(ctx);
|
||||
host.emit?.("root.unmount", `root_${Date.now()}`, {});
|
||||
}
|
||||
```
|
||||
|
||||
`unmount()` calls `finalizeRoot` and emits an event. It does **not**:
|
||||
|
||||
- Remove instances from their parents (`removeChild` is never called)
|
||||
- Dispose signal subscriptions (the `Context` and any reactive effects continue to fire)
|
||||
- Tear down a fiber tree (no fiber tree exists)
|
||||
|
||||
This means calling `unmount()` followed by creating a new root on the same container will likely result in leaked instances and stale signal effects.
|
||||
|
||||
The reconciler research (`docs/research/reconciler/01-reactive-host-bridge.md` and `03-unmount-dispose-support.md`) addresses both gaps comprehensively.
|
||||
|
||||
### Event IDs Use `Date.now()`
|
||||
|
||||
The `id` parameter in `emit` calls uses `${tag}_${Date.now()}` and `text_${Date.now()}`. These are not stable or unique identifiers. When the reconciler introduces fiber nodes, each fiber will have a stable identity that replaces these placeholder IDs.
|
||||
|
||||
### No Instance Tree Reference
|
||||
|
||||
After `render()` completes, the created instance tree is not stored anywhere. The `Root` object does not hold a reference to the root instances. This means:
|
||||
|
||||
- The tree is unreachable for updates — there's no way to find an instance to call `prepareUpdate` on.
|
||||
- `unmount()` cannot iterate instances because it doesn't have them.
|
||||
|
||||
The reconciler solves this by maintaining a fiber tree alongside the instance tree.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **`HostConfig` is the sole host integration point** — all platform-specific logic lives behind this interface. UJSX never calls DOM APIs, Three.js APIs, or any other platform API directly.
|
||||
- **`TTag` constrains element types** — hosts declare the tags they support. Attempting to render an unsupported tag is a type error at compile time. At runtime, the host receives any string as `tag` and must handle unknowns.
|
||||
- **`render()` is not idempotent** — calling `render()` twice on the same root creates two independent instance trees. It does not update the first tree.
|
||||
- **Function components are synchronous and transparent** — they receive props and children, return a `UNode`, and produce no host instance. The reconciler research discusses how to handle components that return different tree shapes across renders.
|
||||
- **`Context` is always present** — `createRoot()` guarantees a `Context` exists on the `Root`. If none is provided, a default is created with `density: "full"`, `target: "markdown"`, and empty `metadata`.
|
||||
- **`container` is opaque** — UJSX passes it to `createRootContext` and stores it on `Root`, but never inspects it. The host defines what it means.
|
||||
- **Mount is depth-first, post-order** — children are fully constructed before being appended to their parent. Hosts can rely on this ordering invariant.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Should `render()` be renamed to `mount()`?** The current name suggests it can be called multiple times (like React's `render`), but it's mount-only. Renaming to `mount()` would make the semantics clearer and reserve `render()` for a future reconciler method that does support re-render.
|
||||
|
||||
2. **Should `unmount()` call `removeChild` for all instances as a safety measure?** Even without the reconciler, `unmount()` could walk the tree it just created and call `removeChild` on each instance. This would at least clean up the host's instance tree, though it wouldn't solve the signal disposal problem. The reconciler research proposes a proper fiber-based disposal.
|
||||
|
||||
3. **Should `HostConfig` include `finalizeInstance`?** The reconciler research (`03-unmount-dispose-support.md`) proposes a `finalizeInstance?(instance, ctx)` method for per-instance cleanup (releasing GPU buffers, closing connections). This would let hosts perform targeted teardown when the reconciler removes individual instances.
|
||||
|
||||
4. **How should function component errors propagate?** If a component function throws, the error bubbles up through `mountNode` with no host involvement. Should there be a `handleError` method on `HostConfig`? Or should components be wrapped in try/catch at the `mountNode` level?
|
||||
|
||||
5. **Should `createRoot` validate that required host methods are present?** Currently optional methods are called with `?.` chaining. A host that needs `insertBefore` but doesn't implement it silently falls back to `appendChild`. Should `createRoot` detect this and warn?
|
||||
|
||||
## References
|
||||
|
||||
- Source: `src/host/config.ts`
|
||||
- Schema: `docs/architecture/schema.md` — `UNode`, `UElement`, `URoot`, `UPrimitive` types
|
||||
- Context: `src/core/context.ts` — `Context` class with signal-based values
|
||||
- Reactive → Host bridge research: `docs/research/reconciler/01-reactive-host-bridge.md`
|
||||
- Unmount & dispose research: `docs/research/reconciler/03-unmount-dispose-support.md`
|
||||
- Key field design: `docs/research/reconciler/00-KEY-FIELD-DESIGN.md`
|
||||
147
docs/architecture/pointers.md
Normal file
147
docs/architecture/pointers.md
Normal file
@@ -0,0 +1,147 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
# Pointers
|
||||
|
||||
ValuePointer, selectNode, setNode, and the reactive tree reference model.
|
||||
|
||||
## Overview
|
||||
|
||||
UJSX trees are immutable data structures — `h()` produces a new node, `setNode()` returns a new tree. But some operations need reactive references to specific positions within a tree: "watch the value at this path and notify me when it changes." `ValuePointer` provides this by pairing a Preact signal with a path that identifies the pointer's position in the tree.
|
||||
|
||||
The pointer subsystem is lower-level than the reconciler. The reconciler manages structural changes (adding, removing, reordering children). Pointers manage targeted value reads and writes at known positions — a path like `["0", "children", "2"]` identifies "the third child of the first child of root." This makes pointers useful for fine-grained reactivity without rebuilding the entire tree.
|
||||
|
||||
## ValuePointer
|
||||
|
||||
```typescript
|
||||
class ValuePointer<T> {
|
||||
constructor(initial: T, path: string[] = []);
|
||||
get value(): T;
|
||||
set value(v: T);
|
||||
get reactive(): ReadonlySignal<T>;
|
||||
get path(): string[];
|
||||
}
|
||||
```
|
||||
|
||||
### Signal backing
|
||||
|
||||
`ValuePointer` wraps a Preact `signal<T>`. Reading `value` returns the current signal value; writing `value` updates the signal and notifies all active effect subscriptions. The `reactive` getter exposes a `ReadonlySignal<T>` for consumers that need signal composition without write access.
|
||||
|
||||
The signal is private — external code cannot replace the signal itself, only read or write its value. This ensures the pointer is a stable reactive reference, not a replaceable container.
|
||||
|
||||
### Path
|
||||
|
||||
The `path` field is a string array identifying the pointer's logical position in the tree. Path semantics follow `selectNode`/`setNode` conventions (see below).
|
||||
|
||||
**The path is informational.** It does not participate in automatic updates — setting a `ValuePointer.path` does not re-bind the pointer to a different tree position. The path exists for debugging, introspection, and for consumers that need to correlate pointers with tree locations.
|
||||
|
||||
The default path is `[]` (root). Path segments are strings even when they represent numeric array indices — this avoids a mixed `string | number` type and keeps path arrays JSON-serializable.
|
||||
|
||||
### Why not use signals directly?
|
||||
|
||||
A bare signal tracks a value, but not its context within a tree. `ValuePointer` adds the `path` as a first-class concern. This enables:
|
||||
|
||||
- **Debugging** — log the path to understand which tree position a reactive value represents.
|
||||
- **Batch operations** — walk a tree of pointers, each with its known position, and apply coordinated updates.
|
||||
- **Reconciler integration** — the reconciler can create pointers keyed by path, then update their values during commit phases.
|
||||
|
||||
Without `ValuePointer`, consumers would track `(signal, path)` pairs ad-hoc, which inevitably leads to inconsistent path representations and lost path information.
|
||||
|
||||
## selectNode
|
||||
|
||||
```typescript
|
||||
function selectNode(root: UNode, path: string[]): UNode | undefined
|
||||
```
|
||||
|
||||
Navigates a `UNode` tree using path segments, returning the node at that position or `undefined` if the path cannot be resolved.
|
||||
|
||||
### Path segment resolution
|
||||
|
||||
Each segment is processed sequentially against the current node:
|
||||
|
||||
| Segment | Resolution | Example |
|
||||
|---------|-----------|---------|
|
||||
| Numeric (e.g., `"0"`, `"3"`) | `children[index]` on the current `UElement` | Path `["0", "2"]` → root.children[0].children[2] |
|
||||
| Non-numeric (e.g., `"title"`) | `props[segment]` on the current `UElement`, if the prop value is an object | Path `["props", "title"]` → root.props.title (if title is an object node) |
|
||||
|
||||
A segment that parses as a valid non-negative integer is treated as a children index. Otherwise, it is treated as a prop key. This is a simplified version of RFC 6901 JSON Pointer — no special escaping, no `~` encoding, no `/` separators. Simplicity over generality.
|
||||
|
||||
### Early termination
|
||||
|
||||
If the current node is not a `UElement` (i.e., it's a `UPrimitive` — a string, number, boolean, or null), `selectNode` returns `undefined` because primitives have no children or props. This prevents runtime errors from navigating into leaf values.
|
||||
|
||||
### Non-element props
|
||||
|
||||
When a string segment resolves to a prop value that is not an object (e.g., `props.title` is a string), `selectNode` returns `undefined`. Only object-typed prop values can be navigation targets. This is because `UPrimitive` values (strings, numbers) are terminal — they have no children to navigate into. A prop that holds a `UNode` subtree is an object and can be navigated; a prop that holds a string is a leaf.
|
||||
|
||||
## setNode
|
||||
|
||||
```typescript
|
||||
function setNode(root: UNode, path: string[], value: UNode): UNode
|
||||
```
|
||||
|
||||
Returns a **new tree** with `value` set at `path`. The original tree is not mutated.
|
||||
|
||||
### Immutable update strategy
|
||||
|
||||
`setNode` creates new `UElement` objects at each level of the path, preserving the immutable UNode contract. Only the nodes on the path from root to target are recreated; siblings and unrelated subtrees share references with the original tree.
|
||||
|
||||
```
|
||||
setNode(root, ["0", "children", "1"], newNode)
|
||||
→ new root
|
||||
→ new root.children[0]
|
||||
→ new root.children[0].children[1] = newNode
|
||||
→ root.children[0].children[0] (shared reference)
|
||||
→ root.children[1] (shared reference)
|
||||
```
|
||||
|
||||
This structural sharing means `setNode` is O(depth) in allocations, not O(size of tree).
|
||||
|
||||
### Edge cases
|
||||
|
||||
- **Empty path** — `path.length === 0` — returns `value` directly, replacing the root node.
|
||||
- **Path into a primitive** — if a segment resolves to a `UPrimitive` that cannot be navigated further (e.g., trying to access `.children` on a string), the function returns the primitive unchanged. It does not throw. This matches `selectNode`'s behavior of returning `undefined` for invalid paths — `setNode` treats unresolvable paths as no-ops on the current node.
|
||||
|
||||
### Array index handling
|
||||
|
||||
When the head segment is a valid non-negative integer, `setNode` shallow-copies the `children` array and replaces the element at that index. Out-of-range indices are ignored — the copy is made but no element is replaced. This is a defensive choice: silent no-op is preferable to throwing or growing the array.
|
||||
|
||||
## Relationship to the Reconciler
|
||||
|
||||
`ValuePointer`, `selectNode`, and `setNode` are **not** the reconciler's update mechanism. They are lower-level utilities that the reconciler can use internally. The reconciler's fiber tree manages structural changes (diffing, adding, removing children). Pointers handle targeted reads and writes at known positions — a finer granularity than the reconciler's subtree updates.
|
||||
|
||||
When the reconciler is complete, it will likely:
|
||||
|
||||
1. Use `selectNode` to read values at fiber positions during the render phase.
|
||||
2. Use `setNode`-like immutable updates (or internal equivalents) during the commit phase.
|
||||
3. Create `ValuePointer` instances for properties that need reactive subscriptions (e.g., prop values bound to signals).
|
||||
|
||||
## Known Gaps
|
||||
|
||||
### No batch mutation
|
||||
|
||||
`setNode` updates one path at a time. Batch updates (setting multiple paths in one pass) require calling `setNode` repeatedly, creating intermediate trees for each update. This is correct but not optimal — a batch-aware `setNodes` could share intermediate allocations.
|
||||
|
||||
### No path validation
|
||||
|
||||
`selectNode` and `setNode` silently return `undefined` or perform no-ops for invalid paths. There is no `validatePath()` function that checks whether a path resolves before attempting navigation. Consumers that need validation must call `selectNode` and check the result.
|
||||
|
||||
### No wildcard or glob paths
|
||||
|
||||
Paths are exact sequences of segments. There is no support for `*` (any child), `**` (recursive descent), or other glob patterns. Tree queries that need glob matching should use tree walking, not path-based selection.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **.Immutable trees** — `setNode` never mutates the input tree. It returns a new tree with structural sharing. Consumers must not mutate `UNode` objects directly; always use `setNode` or reconstruct nodes.
|
||||
- **Path is informational on ValuePointer** — the `path` field does not auto-update when the tree changes. It is set at construction time and reflects the pointer's intended position, not the current tree state.
|
||||
- **Numeric strings only** — path segments are strings. Numeric segments represent array indices; non-numeric segments represent prop keys. There is no type distinction between `children[0]` and `props["0"]` — if a prop key is a valid integer string, it is treated as a children index. This is a known ambiguity that consumers should document.
|
||||
- **setNode does not throw** — invalid paths result in no-ops (returning the node unchanged) rather than errors. This matches the "return undefined" behavior of `selectNode` and makes `setNode` safe to use in reactive update loops where a path might not yet exist.
|
||||
|
||||
## References
|
||||
|
||||
- Source: `src/core/pointer.ts`
|
||||
- UNode schema: `src/core/schema.ts`
|
||||
- Preact signals: `@preact/signals-core`
|
||||
- Reconciler research: `docs/research/reconciler/` (see 01-reactive-host-bridge.md and 02-key-based-children-reconciliation.md for how selectors and path-based targeting integrate with the fiber tree)
|
||||
155
docs/architecture/reactive-layer.md
Normal file
155
docs/architecture/reactive-layer.md
Normal file
@@ -0,0 +1,155 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
# Reactive Layer
|
||||
|
||||
Signal-backed reactive wrappers around the UJSX tree, and the gaps between them and HostConfig reconciliation.
|
||||
|
||||
## Overview
|
||||
|
||||
The reactive layer wraps `UNode` trees in Preact signals so that prop and child changes propagate automatically through `computed` nodes. It does **not** implement its own reactive primitives — it re-exports `@preact/signals-core` (`signal`, `computed`, `effect`, `batch`) and builds three abstractions on top:
|
||||
|
||||
1. **`ReactiveNode`** — a uniform interface for any signal-backed tree node (component output or element).
|
||||
2. **`ReactiveRoot`** — the top-level `Signal<UNode>` that owns the element tree, supports updates, subscriptions, and event-emitting renders.
|
||||
3. **Factory functions** — `reactiveComponent` and `reactiveElement` create `ReactiveNode`s whose `computed` signals re-derive the node when inputs change.
|
||||
|
||||
The layer's purpose is to make the UJSX tree reactive without coupling to any specific host. A signal change in a `ReactiveRoot` should eventually flow through a reconciler to `HostConfig.prepareUpdate`/`commitUpdate` — but that bridge does not exist yet.
|
||||
|
||||
## ReactiveNode
|
||||
|
||||
```typescript
|
||||
interface ReactiveNode {
|
||||
readonly type: string;
|
||||
readonly signal: ReadonlySignal<UNode>;
|
||||
readonly dispose: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
- **`type`** — identifies the node's origin. For components, this is `component.displayName ?? "anonymous"`. For elements, this is the element type string (e.g. `"div"`, `"operation"`).
|
||||
- **`signal`** — a `ReadonlySignal<UNode>`. Consumers read `.value` to get the current node. The signal is `computed`, so it automatically re-derives when its dependencies change.
|
||||
- **`dispose`** — intended to clean up the underlying `computed` subscription. **Currently a no-op** — see [Known Gaps](#known-gaps).
|
||||
|
||||
`ReactiveNode` is the return type of both `reactiveComponent` and `reactiveElement`, giving consumers a single interface regardless of whether the node came from a component or an element.
|
||||
|
||||
## reactiveComponent
|
||||
|
||||
```typescript
|
||||
function reactiveComponent<P extends UniversalProps>(
|
||||
component: UComponent<P>,
|
||||
propsSignal: Signal<P>,
|
||||
): ReactiveNode
|
||||
```
|
||||
|
||||
Creates a `computed` signal that calls `component(propsSignal.value)`. When `propsSignal` changes, the computed re-evaluates, producing a new `UNode` reflecting the updated props.
|
||||
|
||||
The component function runs inside the computed's tracking scope, so any signals it reads (not just `propsSignal`) become dependencies. This means a component that reads external signals will automatically re-render when those signals change.
|
||||
|
||||
The `type` field uses `component.displayName ?? "anonymous"` for debugging. Components without a `displayName` are indistinguishable in logs — this is acceptable for now but could be improved with a `type` field on `UComponent`.
|
||||
|
||||
## reactiveElement
|
||||
|
||||
```typescript
|
||||
function reactiveElement(
|
||||
type: string,
|
||||
propsSignal: Signal<UniversalProps>,
|
||||
childrenSignals: ReadonlySignal<UNode>[],
|
||||
): ReactiveNode
|
||||
```
|
||||
|
||||
Creates a `computed` signal that assembles a `UElement` from its inputs. When `propsSignal` or any `childrenSignal` changes, the computed re-derives, producing a new `UElement` with updated props and children.
|
||||
|
||||
Children are unwrapped via `childrenSignals.map(s => s.value)`. This means every child signal is read on every evaluation — a change to any child triggers a full element rebuild. For large child lists, this is correct but not optimally granular. The cost is acceptable because `UElement` is a plain data object; the actual side effects happen downstream in the reconciler.
|
||||
|
||||
Both `reactiveComponent` and `reactiveElement` return their `dispose` as a no-op `() => {}` — the `computed` signal is never cleaned up.
|
||||
|
||||
## ReactiveRoot
|
||||
|
||||
```typescript
|
||||
class ReactiveRoot {
|
||||
constructor(initial: UNode)
|
||||
get value(): ReadonlySignal<UNode>
|
||||
update(fn: (current: UNode) => UNode): void
|
||||
subscribe(listener: (node: UNode) => void): () => void
|
||||
render(emit: (event: { type: string; id: string; payload: unknown }) => void): () => void
|
||||
}
|
||||
```
|
||||
|
||||
`ReactiveRoot` holds the root `Signal<UNode>` — the top of the element tree. It is the primary entry point for external code that needs to read or mutate the tree.
|
||||
|
||||
### `value`
|
||||
|
||||
Returns the root signal as `ReadonlySignal<UNode>`. Consumers can read `.value` or pass it as a dependency to their own `computed`/`effect` nodes, but cannot write to it directly.
|
||||
|
||||
### `update(fn)`
|
||||
|
||||
Updates the root signal inside `batch()`. This ensures that multiple synchronous writes to the signal (or other signals read by the tree's computeds) are collapsed into a single propagation cycle. The update function receives the current value and returns the new value — a functional update pattern that avoids stale-value issues.
|
||||
|
||||
### `subscribe(listener)`
|
||||
|
||||
Creates an `effect` that calls `listener(root.value)` on every change. Returns a dispose function. **This dispose function is not tracked by `ReactiveRoot`** — the caller is responsible for calling it. If the caller discards it, the effect leaks. See [Known Gaps](#known-gaps).
|
||||
|
||||
### `render(emit)`
|
||||
|
||||
Creates an `effect` that reads `root.value` and emits a `{ type: "root.render", id, payload }` event on every change. Returns a dispose function that tears down the effect and nulls the internal `renderDisposer` reference.
|
||||
|
||||
`render()` stores its effect disposer in `this.renderDisposer`, which means calling `render()` a second time overwrites the previous disposer without disposing it. This is a known hazard — the first render's effect will leak.
|
||||
|
||||
The `id` field uses `root_${Date.now()}`, which provides no uniqueness guarantee within a millisecond window. This is acceptable for current debugging use but not for event deduplication.
|
||||
|
||||
## Re-exports
|
||||
|
||||
```typescript
|
||||
export { signal, computed, effect, batch };
|
||||
export type { Signal, ReadonlySignal };
|
||||
```
|
||||
|
||||
The reactive layer re-exports the four core primitives and two core types from `@preact/signals-core`. This makes `@preact/signals-core` an implementation detail — consumers import from `ujsx/reactive` rather than reaching into `@preact/signals-core` directly. If the reactive primitive library ever changes, only `reactive.ts` needs updating.
|
||||
|
||||
The decision to use `@preact/signals-core` (rather than building custom primitives) is documented in [ADR-003](decisions/003-preact-signals-for-reactivity.md). The rationale: signal libraries are subtle to implement correctly (glitch-free propagation, cycle detection, batching). Preact's implementation is small, well-tested, and framework-agnostic.
|
||||
|
||||
## Known Gaps
|
||||
|
||||
### All `dispose` functions are no-ops
|
||||
|
||||
`reactiveComponent` and `reactiveElement` return `dispose: () => {}`. The underlying `computed` signal is never cleaned up. This means that if a `ReactiveNode` is discarded, its computed continues to track dependencies and re-evaluate on changes. In a long-lived application with dynamic tree structures, this leaks memory and CPU.
|
||||
|
||||
### `subscribe()` return value is thrown away by callers
|
||||
|
||||
`ReactiveRoot.subscribe()` returns an `effect` dispose function, but `ReactiveRoot` does not track it. Callers who discard the return value create a permanently leaking effect. There is no `unsubscribeAll()` or cleanup mechanism.
|
||||
|
||||
### `render()` overwrites previous disposer
|
||||
|
||||
Calling `render()` a second time replaces `this.renderDisposer` without disposing the first effect. The first render effect leaks permanently.
|
||||
|
||||
### No connection to HostConfig reconciler
|
||||
|
||||
`ReactiveRoot.render()` emits events, but nothing consumes those events to call `HostConfig.prepareUpdate`/`commitUpdate`. The signal layer and the host layer are two separate islands. The reconciler research ([01-reactive-host-bridge.md](../../research/reconciler/01-reactive-host-bridge.md)) proposes a fiber-based bridge: `ReactiveRoot` signal changes trigger a reconciliation pass that diffs props and calls `HostConfig` update methods.
|
||||
|
||||
### No auto-dispose on unmount
|
||||
|
||||
`ReactiveRoot` has no `unmount()` or `destroy()` method. Effects created by `subscribe()` and `render()` are never automatically torn down. The reconciler research ([03-unmount-dispose-support.md](../../research/reconciler/03-unmount-dispose-support.md)) addresses this.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **`@preact/signals-core` is the only reactive primitive library.** The layer does not abstract over signal implementations. Switching libraries requires changing `reactive.ts` and all code that imports the re-exports.
|
||||
- **`ReactiveNode.dispose` is a contract with no implementation.** Code that calls `dispose()` today does nothing. When real disposal is implemented, callers must be audited to ensure they call it at the right lifecycle point.
|
||||
- **`ReactiveRoot.update` overwrites the root signal entirely.** There is no granular update mechanism — the update function receives the entire current tree and returns a new one. For large trees, this is the correct granularity because signals handle the fine-grained propagation internally.
|
||||
- **`reactiveElement` reads every child signal on every evaluation.** A change to any child triggers a full element rebuild. This is correct but not optimal for large child lists with many independent signals.
|
||||
- **`ReactiveRoot.render()` event `id` is not guaranteed unique.** `Date.now()` collisions are possible within a single millisecond. Do not rely on `id` for deduplication.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Should `ReactiveNode.dispose` be implemented using `effect` cleanup or stored-disposer patterns?** The current `computed` signals have no public dispose API in `@preact/signals-core`. Disposal requires either switching to an effect-based approach (where each `computed` is tracked by an `effect` that can be disposed) or maintaining an explicit disposer list.
|
||||
2. **Should `ReactiveRoot` track all subscription disposers?** Adding an internal `Set<() => void>` for active subscribers would allow `ReactiveRoot` to clean up on unmount. This creates a lifecycle coupling — `ReactiveRoot` would need a `destroy()` method.
|
||||
3. **How should `ReactiveRoot` connect to the reconciler?** Options: (a) `ReactiveRoot` emits events that a reconciler subscribes to, (b) `createReactiveRoot(host, container)` bridges both layers, (c) consumer code wires them manually. See [01-reactive-host-bridge.md](../../research/reconciler/01-reactive-host-bridge.md) for analysis.
|
||||
4. **Should `render()` support multiple concurrent subscribers?** The current overwriting design suggests single-subscriber usage. If multiple hosts need to render the same reactive tree, they should each call `subscribe()` directly rather than `render()`.
|
||||
|
||||
## References
|
||||
|
||||
- Source: `src/core/reactive.ts`
|
||||
- ADR-003: `docs/architecture/decisions/003-preact-signals-for-reactivity.md`
|
||||
- Reactive → Host bridge research: `docs/research/reconciler/01-reactive-host-bridge.md`
|
||||
- Unmount & dispose research: `docs/research/reconciler/03-unmount-dispose-support.md`
|
||||
- Preact signals-core: `@preact/signals-core`
|
||||
162
docs/architecture/schema.md
Normal file
162
docs/architecture/schema.md
Normal file
@@ -0,0 +1,162 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
TypeBox Module, TypeScript types, type guards, and the design decisions behind them.
|
||||
|
||||
## Overview
|
||||
|
||||
The UJSX schema defines the shape of the universal tree: `UNode`, `UElement`, `URoot`, and `UPrimitive`. It serves two purposes:
|
||||
|
||||
1. **Runtime validation and JSON Schema export** via a TypeBox `Module` — consumed by `Value.Check()` and exportable for consumers that need schema-based contracts.
|
||||
2. **Static TypeScript types** for clean compiler inference — defined directly, not derived from the TypeBox schema, because `ComponentFn` and function-typed `PropValue` entries are runtime-only and not serializable.
|
||||
|
||||
This dual-source approach is intentional. The TypeBox Module handles validation and serialization. The TypeScript types handle ergonomics. They stay in sync through tests, not through derivation.
|
||||
|
||||
## TypeBox Module
|
||||
|
||||
The `UJSX` constant is a `Type.Module` that defines six interrelated schemas:
|
||||
|
||||
```typescript
|
||||
export const UJSX = Type.Module({
|
||||
UPrimitive: Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]),
|
||||
PropValue: Type.Union([
|
||||
Type.String(), Type.Number(), Type.Boolean(), Type.Null(),
|
||||
Type.Array(Type.Unknown()),
|
||||
Type.Ref("UNode"),
|
||||
Type.Record(Type.String(), Type.Unknown()),
|
||||
Type.Function([...Type.Rest(Type.Array(Type.Unknown()))], Type.Unknown()),
|
||||
]),
|
||||
UniversalProps: Type.Object(
|
||||
{},
|
||||
{ additionalProperties: Type.Union([Type.Ref("PropValue"), Type.Undefined()]) },
|
||||
),
|
||||
UElement: Type.Object({
|
||||
type: Type.String(),
|
||||
props: Type.Ref("UniversalProps"),
|
||||
children: Type.Array(Type.Ref("UNode")),
|
||||
}),
|
||||
URoot: Type.Object({
|
||||
type: Type.Literal("root"),
|
||||
props: Type.Ref("UniversalProps"),
|
||||
children: Type.Array(Type.Ref("UNode")),
|
||||
}),
|
||||
UNode: Type.Union([Type.Ref("UPrimitive"), Type.Ref("UElement"), Type.Ref("URoot")]),
|
||||
});
|
||||
```
|
||||
|
||||
### Design decisions in the Module
|
||||
|
||||
- **`UPrimitive`** is `string | number | boolean | null`. No `undefined` — `undefined` is JavaScript's "absent" and should not appear as a tree node value.
|
||||
- **`PropValue`** includes `Type.Function(...)`. This means a `PropValue` can be a function (event handlers, component references). The trade-off: props are **not fully serializable**. This is by design — hosts that need serialization strip functions at their boundary.
|
||||
- **`UniversalProps`** is an open object (`additionalProperties` allowed). Different hosts need different prop shapes. A DOM host needs `className`; a workflow host needs `operationId`. Constraining props to a closed schema would force hosts to extend UJSX's schema, inverting the dependency. The open schema lets hosts define their own prop contracts.
|
||||
- **`UElement.type`** is `Type.String()`, not a union of known element types. Element types are host-defined — UJSX does not maintain a registry of valid type strings.
|
||||
- **`URoot`** uses `Type.Literal("root")` as its `type` field. This makes `URoot` a discriminated union member, distinguishable from `UElement` at runtime and in TypeBox validation.
|
||||
- **`UNode`** is the union `UPrimitive | UElement | URoot`. This is the fundamental tree node type — every value in a UJSX tree is a `UNode`.
|
||||
|
||||
### Re-export
|
||||
|
||||
```typescript
|
||||
export { UJSX as schema };
|
||||
```
|
||||
|
||||
The Module is re-exported as `schema` for consumer convenience. Call sites use `UJSX.Import("UElement")` etc. with `Value.Check` for runtime validation.
|
||||
|
||||
## TypeScript Types
|
||||
|
||||
The TypeScript types are defined directly, not via `Static<typeof UJSX>`, because `ComponentFn` is a runtime function type that doesn't serialize. Deriving from the TypeBox Module would either omit functions (breaking the type) or include non-serializable types in the schema (breaking validation).
|
||||
|
||||
```typescript
|
||||
export type UPrimitive = string | number | boolean | null;
|
||||
export type PropValue =
|
||||
| string | number | boolean | null
|
||||
| unknown[]
|
||||
| UNode
|
||||
| Record<string, unknown>
|
||||
| ((...args: unknown[]) => unknown);
|
||||
export type UniversalProps = Record<string, PropValue | undefined>;
|
||||
export type UElement = {
|
||||
type: string;
|
||||
props: UniversalProps;
|
||||
children: UNode[];
|
||||
};
|
||||
export type URoot = {
|
||||
type: "root";
|
||||
props: UniversalProps;
|
||||
children: UNode[];
|
||||
};
|
||||
export type UNode = UPrimitive | UElement | URoot;
|
||||
```
|
||||
|
||||
### Component types
|
||||
|
||||
```typescript
|
||||
export type ComponentFn = (props: UniversalProps & { children?: UNode[] }) => UNode;
|
||||
export type UType = string | ComponentFn;
|
||||
export interface UComponent<P extends UniversalProps = UniversalProps> {
|
||||
(props: P & { children?: UNode[] }): UNode;
|
||||
displayName?: string;
|
||||
targets?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
- **`ComponentFn`** is the type of a function that accepts props (with optional children) and returns a `UNode`. This is the universal component contract.
|
||||
- **`UType`** is the union of string element types and component functions — used as the first argument to `h()`.
|
||||
- **`UComponent`** adds optional `displayName` (for debugging) and `targets` (for host-specific component routing). It extends `ComponentFn` with metadata.
|
||||
|
||||
`ComponentFn` and `UType` have no TypeBox representation. They are TypeScript-only. This is why the types are hand-written rather than derived from the schema.
|
||||
|
||||
## Type Guards
|
||||
|
||||
Three type guards narrow `UNode` at runtime:
|
||||
|
||||
```typescript
|
||||
function isUElement(node: UNode): node is UElement
|
||||
function isURoot(node: UNode): node is URoot
|
||||
function isUPrimitive(node: UNode): node is UPrimitive
|
||||
```
|
||||
|
||||
### Discriminators
|
||||
|
||||
| Guard | Logic | Discriminator |
|
||||
|-------|-------|---------------|
|
||||
| `isUElement` | `typeof === "object" && "type" in node && "props" in node && "children" in node && node.type !== "root"` | Has `type`/`props`/`children` keys AND `type` is not `"root"` |
|
||||
| `isURoot` | `typeof === "object" && "type" in node && node.type === "root"` | `type === "root"` |
|
||||
| `isUPrimitive` | `typeof === "string" \|\| typeof === "number" \|\| typeof === "boolean" \|\| node === null` | Not an object |
|
||||
|
||||
The `isUElement` guard excludes `URoot` by checking `type !== "root"`. Without this exclusion, `URoot` nodes would match `isUElement` because they have the same structural fields (`type`, `props`, `children`). The `"root"` literal type discriminates them.
|
||||
|
||||
## Known Gaps
|
||||
|
||||
### `key` field on `UElement`
|
||||
|
||||
`UElement` currently has no `key` field. The reconciler needs an identity mechanism to match old children to new children across re-renders. Without `key`, reconciliation is positional-only — the Nth child of the old tree maps to the Nth child of the new tree, which breaks when children are reordered, inserted, or removed.
|
||||
|
||||
The reconciler research (`docs/research/reconciler/00-KEY-FIELD-DESIGN.md`) proposes adding `key?: string` to `UElement` as a first-class field. `h()` would extract `key` from props and promote it to the element level, so component functions never receive it. `URoot` does not get `key` — roots are unique per `createRoot()` call and never need reconciliation identity.
|
||||
|
||||
**Status**: Research complete, not yet implemented.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Props are not fully serializable** — `PropValue` includes functions. Hosts that need serialization must strip function-valued props at their boundary. This is by design: event handlers and component references are first-class prop values.
|
||||
- **UniversalProps is open** — `additionalProperties` allows any key. This prevents UJSX from being a prop gatekeeper and lets hosts define their own contracts without extending UJSX's schema.
|
||||
- **TypeScript types are authoritative for type inference** — the TypeBox Module is for runtime validation and JSON Schema export only. Do not use `Static<typeof UJSX>` as the source of truth for TypeScript types; the hand-written types include `ComponentFn` and function-typed `PropValue` entries that the schema cannot express cleanly.
|
||||
- **No `key` on `UElement`** — see Known Gaps above. Positional reconciliation only until `key` is added.
|
||||
- **No `key` on `URoot`** — roots are identified by `props.id`, not a `key` field. This is by design; roots are never children of another element.
|
||||
- **Type guards are mutually exclusive** — `isUElement`, `isURoot`, and `isUPrimitive` partition the `UNode` space. Every `UNode` matches exactly one guard.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Should `key` accept numbers?** React coerces number keys to strings. The current proposal enforces `string` only — simpler, no implicit coercion. Users can wrap in `String()`. See [00-KEY-FIELD-DESIGN.md](../../research/reconciler/00-KEY-FIELD-DESIGN.md).
|
||||
2. **Should `UPrimitive` include `undefined`?** Currently `null` represents an explicitly empty value. `undefined` means "absent" and should not appear as a tree node. This is consistent with how JSX treats `undefined` children (rendered to nothing), but some hosts might benefit from an explicit "missing" sentinel. No current use case justifies this.
|
||||
3. **Should `UniversalProps` constrain value types per-host?** The open schema allows any `PropValue` for any key. A host that wants stricter prop contracts (e.g., `onClick` must be a function, `className` must be a string) must validate at its own boundary. A future host-typed props system could be layered on top without changing the base schema.
|
||||
|
||||
## References
|
||||
|
||||
- Source: `src/core/schema.ts`
|
||||
- Key field design: `docs/research/reconciler/00-KEY-FIELD-DESIGN.md`
|
||||
- TypeBox Module as type registry: `docs/architecture/decisions/002-typebox-module-as-registry.md`
|
||||
- HTML-agnostic core: `docs/architecture/decisions/001-html-agnostic-core.md`
|
||||
134
docs/architecture/transforms.md
Normal file
134
docs/architecture/transforms.md
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
# Transforms
|
||||
|
||||
The TransformRegistry, TransformRule, and TransformContext that power bi-directional tree conversion.
|
||||
|
||||
## Overview
|
||||
|
||||
UJSX trees need to convert to and from multiple target formats — markdown (mdast), HTML (hast), JSON paths (jpath). The transform system provides a generic, direction-aware rule engine that finds the right handler for a node and invokes it. Rules match on both direction and an arbitrary predicate, not on type tags alone. This enables the "same registry, different direction" pattern: one set of rule definitions handles both UJSX→target and target→UJSX transforms.
|
||||
|
||||
The registry is intentionally generic over `TInput`, `TOutput`, and `A` (ancestor type). It knows nothing about UNode, UElement, or any UJSX-specific type. This allows reuse for any tree-to-tree conversion where the same rule structure applies.
|
||||
|
||||
## TransformRule
|
||||
|
||||
```typescript
|
||||
interface TransformRule<TInput, TOutput, A> {
|
||||
name: string;
|
||||
direction: Direction;
|
||||
schema?: TSchema;
|
||||
match: (node: TInput) => boolean;
|
||||
transform: (node: TInput, ctx: TransformContext<A>, next: TransformFn<TInput, TOutput, A>) => TOutput;
|
||||
priority?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### name
|
||||
|
||||
Human-readable identifier. Used in error messages and in `transform.apply` events. Rules without meaningful names are hard to debug when the registry throws "no matching rule" errors.
|
||||
|
||||
### direction
|
||||
|
||||
One of six predefined strings: `"ujsx→mdast"`, `"mdast→ujsx"`, `"ujsx→jpath"`, `"jpath→ujsx"`, `"ujsx→hast"`, `"hast→ujsx"`. The direction is part of the match criteria — a rule for `ujsx→mdast` will not match when the context direction is `mdast→ujsx`. This eliminates the need for separate "encode" and "decode" registries.
|
||||
|
||||
The `Direction` type is defined in `context.ts`, not in the transform module. This reflects that direction is a render/conversion concept that exists outside transforms — it also appears in `RenderContext` and event payloads.
|
||||
|
||||
### schema
|
||||
|
||||
Optional TypeBox schema. When provided, it enables `matchesSchema(rule.schema, node)` as a match predicate. A rule author can use schema-based matching, predicate-based matching, or both. The registry does not automatically check `schema` during `transform()` — it is a convenience for rule authors to compose into their `match` function.
|
||||
|
||||
### match
|
||||
|
||||
A predicate that returns true if this rule should handle the given node. Combined with `direction`, this is the full match condition. Typical implementations:
|
||||
|
||||
- `matchesSchema(schema, node)` — TypeBox `Value.Check` for structural validation
|
||||
- `(node) => node.type === "heading"` — simple equality check
|
||||
- `(node) => node.type === "heading" && node.props.level > 3` — compound logic
|
||||
|
||||
### transform
|
||||
|
||||
The conversion function. Receives the node, the transform context, and a `next` callback. `next` delegates to the next matching rule in the registry — this is a chain-of-responsibility pattern:
|
||||
|
||||
- **Short-circuit**: return a converted value without calling `next`. The rule handles the node completely.
|
||||
- **Delegate**: call `next(node, ctx)` to fall through to the next rule. Useful for middleware-like rules that wrap or augment another rule's output.
|
||||
|
||||
This pattern avoids hard-coded rule chaining — rules don't reference each other. The registry manages the chain by passing `next` at invocation time.
|
||||
|
||||
### priority
|
||||
|
||||
Higher values are checked first. Default is `0`. Rules with equal priority are checked in registration order. Priority allows "catch-all" rules (low or negative priority) to coexist with specific rules without relying on registration order.
|
||||
|
||||
## TransformContext
|
||||
|
||||
```typescript
|
||||
interface TransformContext<A = unknown> {
|
||||
ancestors: A[];
|
||||
index: number;
|
||||
direction: Direction;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
- **ancestors** — stack of ancestor nodes, root-first. `childCtx()` pushes a parent onto this stack when descending. Empty at the root level.
|
||||
- **index** — position within the parent's child list. Used by `transformAll()` to pass the array index.
|
||||
- **direction** — the conversion direction. Matched against rule direction during `transform()`.
|
||||
- **metadata** — extensible key-value bag for rules to communicate across the tree traversal. For example, a heading rule might set `metadata.headingDepth` for descendants to reference.
|
||||
|
||||
## TransformRegistry
|
||||
|
||||
### register(rule)
|
||||
|
||||
Adds a rule and re-sorts by `priority` descending. The sort happens on every registration, not just at lookup time. This is acceptable because registrations happen at setup time, not in hot loops. It guarantees that `transform()` always checks the highest-priority rules first.
|
||||
|
||||
### transform(node, ctx)
|
||||
|
||||
Finds the first rule where `rule.direction === ctx.direction && rule.match(node)` returns true. Throws if no rule matches. Passes `next` as a callback that recursively calls `transform()` — this allows the matched rule to delegate to the next handler.
|
||||
|
||||
The "first match wins" semantics mean that priority and registration order resolve ambiguity. There is no rule composition beyond the `next` callback.
|
||||
|
||||
### transformAll(nodes, ctx)
|
||||
|
||||
Maps `transform()` over an array, passing each node's index as `ctx.index`. This is a convenience for transforming child lists — it preserves the ancestor stack from `ctx` without requiring callers to manage index tracking.
|
||||
|
||||
## Direction Definitions
|
||||
|
||||
The six directions pair into three bi-directional channels:
|
||||
|
||||
| Channel | Forward | Reverse |
|
||||
|---------|---------|---------|
|
||||
| Markdown | `ujsx→mdast` | `mdast→ujsx` |
|
||||
| JSON Path | `ujsx→jpath` | `jpath→ujsx` |
|
||||
| HTML | `ujsx→hast` | `hast→ujsx` |
|
||||
|
||||
These are the directions currently defined. Additional directions (e.g., `ujsx→dom`, `dom→ujsx`) can be added by extending the `Direction` union in `context.ts`. The registry itself is generic and does not enumerate directions.
|
||||
|
||||
## Known Gaps
|
||||
|
||||
### No transform composition beyond next
|
||||
|
||||
Rules can only delegate via `next`. There is no mechanism for a rule to compose multiple sub-rules (e.g., "transform children using rule X, then apply my own logic"). Such composition must be done in application code, outside the registry.
|
||||
|
||||
### No built-in error recovery
|
||||
|
||||
If `transform()` throws, the entire traversal aborts. There is no fallback rule, no "best effort" mode, and no way to skip a node and continue. Rule authors must handle their own error cases within the `transform` function.
|
||||
|
||||
### No caching or memoization
|
||||
|
||||
`transform()` performs a linear scan of rules on every call. For large rule sets or deep trees, this could become a bottleneck. No caching of match results or memoization of previous transforms is implemented.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Direction is a string union** — not an enum or extensible type. Adding a new direction requires modifying the `Direction` type in `context.ts`. This is intentional: directions define the conversion contract and should be explicitly enumerated.
|
||||
- **Priority is numeric** — there is no guaranteed order between rules with the same priority beyond registration order. Rule authors should assign distinct priorities when order matters.
|
||||
- **The registry is generic** — it has no knowledge of `UNode`, `UElement`, or any UJSX type. The same registry class could transform between any tree formats. UJSX-specific semantics live in the rules, not in the registry.
|
||||
- **matchesSchema is a standalone function** — it is not called automatically by `transform()`. Rule authors opt into schema matching by including it in their `match` predicate.
|
||||
- **next is a recursive call** — calling `next` from within `transform` re-enters `transform()` with the same node. This is correct behavior (it finds the next matching rule), but rule authors must ensure their `match` predicate excludes the current rule to avoid infinite recursion.
|
||||
|
||||
## References
|
||||
|
||||
- Source: `src/transform/registry.ts`
|
||||
- Direction type: `src/core/context.ts`
|
||||
- TypeBox Value.Check: `@alkdev/typebox`
|
||||
539
docs/sdd_process.md
Normal file
539
docs/sdd_process.md
Normal file
@@ -0,0 +1,539 @@
|
||||
# Spec-Driven Development Process
|
||||
|
||||
## Overview
|
||||
|
||||
This document defines the SDD process for the alk.dev project. It leverages:
|
||||
- **Operation registry + call protocol** for typed, composable tool invocation
|
||||
- **Hub coordination operations** (`coord.spawn`, `coord.status`, `coord.message`, etc.) for parallel worktree/session orchestration
|
||||
- **OpenCode CLI** as the agent execution environment (via the open-coordinator plugin as stopgap, transitioning to native hub operations)
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Specification First**: Invest in architecture before implementation
|
||||
2. **Roles as Modes**: Same agent adopts different behavioral modes
|
||||
3. **Flexible Self**: Agents can implement, self-review, and fix objectively
|
||||
4. **Task-Driven**: Structured task graphs with dependency analysis
|
||||
5. **Safe Exit**: Always have a way to unblock progress when stuck
|
||||
6. **Categorical Estimates**: Use risk/scope/impact categories, not time estimates. These are structurally important — upstream failures multiply downstream damage regardless of developer type (human or LLM). See the [cost-benefit framework](/workspace/@alkimiadev/taskgraph/docs/framework.md).
|
||||
|
||||
## Workflow Phases
|
||||
|
||||
### Phase 0: Exploration (Conditional)
|
||||
|
||||
**When**: Requirements unclear, multiple approaches to evaluate, or hard problems need investigation.
|
||||
|
||||
**Process**:
|
||||
1. Capture vision and guiding principles
|
||||
2. Research Specialist investigates options (`docs/research/` or external)
|
||||
3. POC Specialist validates promising approaches (`.worktrees/research/`)
|
||||
4. Document learnings
|
||||
5. Converge on recommended approach
|
||||
|
||||
**Output**: Clear understanding of WHAT to build and WHY, with validated approaches
|
||||
|
||||
### Phase 1: Architecture
|
||||
|
||||
**Objective**: Produce comprehensive, committed architecture specification.
|
||||
|
||||
**Process**:
|
||||
1. Architect creates modular architecture docs in `docs/architecture/` (Draft status)
|
||||
2. Architecture Review validates for ambiguities, risks
|
||||
3. Iterate until zero critical issues
|
||||
4. Transition to Stable status
|
||||
|
||||
**Output**: Stable architecture documents ready for decomposition
|
||||
|
||||
### Phase 2: Decomposition
|
||||
|
||||
**Objective**: Break architecture into atomic, dependency-ordered tasks.
|
||||
|
||||
**Process**:
|
||||
1. Decomposer analyzes architecture
|
||||
2. Creates tasks (markdown files in `tasks/`)
|
||||
3. Establishes dependencies between tasks
|
||||
4. Validates structure (no cycles, logical ordering)
|
||||
5. Identifies review injection points
|
||||
|
||||
**Output**: Well-structured task graph in `tasks/` directory
|
||||
|
||||
### Phase 3: Implementation
|
||||
|
||||
**Objective**: Execute tasks in dependency order with verification.
|
||||
|
||||
**Process**:
|
||||
1. Coordinator identifies parallelizable work
|
||||
2. Coordinator spawns worktrees + sessions (via `worktree({action: "spawn", ...})` or hub `coord.spawn` when available)
|
||||
- Feature work: `.worktrees/feat/<task-id>/` → Implementation Specialist
|
||||
- Research POCs: `.worktrees/research/<task-id>/` → POC Specialist
|
||||
3. Coordinator injects task context into each session
|
||||
4. Agents execute tasks with self-verification
|
||||
5. On completion: agent notifies coordinator, updates task status, commits to worktree branch
|
||||
6. On blocker: Safe Exit protocol, agent notifies coordinator, create blocker task
|
||||
7. Merge worktrees back to main when complete
|
||||
|
||||
**Output**: Completed, verified implementation
|
||||
|
||||
### Phase 4: Review & Finalization
|
||||
|
||||
**Objective**: Validate quality and readiness.
|
||||
|
||||
**Process**:
|
||||
1. Code review at injected checkpoints
|
||||
2. Final integration testing
|
||||
3. Architecture sync check
|
||||
4. Deployment preparation
|
||||
|
||||
**Output**: Production-ready codebase
|
||||
|
||||
## Roles
|
||||
|
||||
### Primary Roles
|
||||
|
||||
#### 1. Architect
|
||||
|
||||
**Responsibility**: Create and maintain architecture specifications.
|
||||
|
||||
**Mode**: Primary (interactive with user)
|
||||
|
||||
**Tools**:
|
||||
- Read, Write, Edit, Glob, Grep
|
||||
- webSearch (research patterns, best practices)
|
||||
|
||||
**Key Behaviors**:
|
||||
- Focus on WHAT and WHY, never HOW
|
||||
- Document decisions with ADR format
|
||||
- Redirect exploration work to Research Specialist
|
||||
- Iterate based on review feedback
|
||||
|
||||
**Deliverables**:
|
||||
- Modular architecture docs in `docs/architecture/`
|
||||
- Component-specific documents
|
||||
|
||||
---
|
||||
|
||||
#### 2. Decomposer
|
||||
|
||||
**Responsibility**: Transform architecture into atomic task graph.
|
||||
|
||||
**Mode**: Primary (interactive with user for approval)
|
||||
|
||||
**Tools**:
|
||||
- Read, Glob, Grep
|
||||
|
||||
**Key Behaviors**:
|
||||
- Decompose to atomic tasks (single objective, clear acceptance criteria)
|
||||
- Establish logical dependencies
|
||||
- Validate structure (no cycles, logical ordering)
|
||||
- Inject review tasks at critical points
|
||||
|
||||
**Deliverables**:
|
||||
- Task files in `tasks/` directory
|
||||
- Dependency graph validated
|
||||
|
||||
---
|
||||
|
||||
#### 3. Coordinator
|
||||
|
||||
**Responsibility**: Orchestrate parallel task execution across worktrees and sessions.
|
||||
|
||||
**Mode**: Primary (manages worktrees and agent sessions)
|
||||
|
||||
**Uses**: The `worktree` tool from the **open-coordinator** opencode plugin. Single tool with `{action, args}` dispatch. Role is auto-detected — coordinator sessions get the full operation set, spawned implementation sessions get a limited set (current, notify, status). No mode toggle required.
|
||||
|
||||
**Tools**:
|
||||
- `worktree({action, args})` — spawn, sessions, dashboard, message, abort, cleanup
|
||||
- Bash (opencode CLI for session interaction)
|
||||
- Read (monitor task files)
|
||||
- `memory` / `memory_compact` — context management and session history (via @alkdev/open-memory, when available)
|
||||
|
||||
**Key Behaviors**:
|
||||
- Identify parallelizable task groups
|
||||
- Spawn worktrees + sessions via `worktree({action: "spawn", ...})`
|
||||
- Inject task context into sessions
|
||||
- Monitor progress via `worktree({action: "sessions"})` and dashboard
|
||||
- Handle blocked tasks (escalate or reassign)
|
||||
- Merge completed worktrees
|
||||
|
||||
**Deliverables**:
|
||||
- Coordinated parallel execution
|
||||
- Blocked task escalation
|
||||
- Merged branches
|
||||
|
||||
---
|
||||
|
||||
#### 4. Implementation Specialist
|
||||
|
||||
**Responsibility**: Execute atomic tasks with self-verification.
|
||||
|
||||
**Mode**: Primary (works on assigned task in worktree)
|
||||
|
||||
**Tools**:
|
||||
- Read, Write, Edit, Glob, Grep, Bash
|
||||
- `worktree({action: "notify", ...})` — report progress/blockers to coordinator
|
||||
- `worktree({action: "current"})` — verify worktree assignment
|
||||
- webSearch (documentation lookup)
|
||||
- `memory` / `memory_compact` — context management (via @alkdev/open-memory, when available)
|
||||
|
||||
**Key Behaviors**:
|
||||
- Load task context (architecture, dependencies)
|
||||
- Propose plan before implementing
|
||||
- Implement following architecture constraints
|
||||
- Self-verify against acceptance criteria
|
||||
- Use Safe Exit when blocked
|
||||
- Notify coordinator via worktree tool
|
||||
- Commit to worktree branch
|
||||
|
||||
**Deliverables**:
|
||||
- Completed task implementation
|
||||
- Tests passing
|
||||
- Committed changes in worktree
|
||||
|
||||
---
|
||||
|
||||
### Reviewer Roles
|
||||
|
||||
#### 5. Architecture Reviewer
|
||||
|
||||
**Responsibility**: Validate architecture for ambiguities and risks.
|
||||
|
||||
**Mode**: Subagent (invoked by Architect)
|
||||
|
||||
**Tools**:
|
||||
- Read, Grep
|
||||
|
||||
**Key Behaviors**:
|
||||
- Check for undefined terms
|
||||
- Identify missing trade-off documentation
|
||||
- Validate quality attribute coverage
|
||||
- Flag ambiguities that could cause implementation issues
|
||||
|
||||
---
|
||||
|
||||
#### 6. Code Reviewer
|
||||
|
||||
**Responsibility**: Review code quality at checkpoints.
|
||||
|
||||
**Mode**: Subagent (invoked by Coordinator or as task)
|
||||
|
||||
**Tools**:
|
||||
- Read, Grep, Bash (lint, test)
|
||||
|
||||
**Key Behaviors**:
|
||||
- Check adherence to architecture
|
||||
- Validate patterns and conventions
|
||||
- Run linters and tests
|
||||
- Identify security/performance concerns
|
||||
|
||||
---
|
||||
|
||||
#### 7. Research Specialist
|
||||
|
||||
**Responsibility**: Research documentation, libraries, best practices.
|
||||
|
||||
**Mode**: Subagent (invoked by any role)
|
||||
|
||||
**Tools**:
|
||||
- Read, Write, Glob
|
||||
- webSearch (primary research tool)
|
||||
|
||||
**Key Behaviors**:
|
||||
- Find and summarize documentation
|
||||
- Evaluate library alternatives
|
||||
- Document findings
|
||||
|
||||
---
|
||||
|
||||
#### 8. POC Specialist
|
||||
|
||||
**Responsibility**: Create proof-of-concepts to validate technical approaches before production implementation.
|
||||
|
||||
**Mode**: Primary (works in isolated research worktree)
|
||||
|
||||
**Worktree Location**: `.worktrees/research/<task-id>/`
|
||||
|
||||
**Tools**:
|
||||
- Read, Write, Edit, Glob, Grep, Bash
|
||||
- webSearch (implementation references)
|
||||
|
||||
**Key Behaviors**:
|
||||
- Create minimal POCs to validate hypotheses
|
||||
- Work in isolated research worktrees
|
||||
- Document findings and recommendations
|
||||
- Timebox strictly - abandon if taking too long
|
||||
- Be honest about limitations and blockers
|
||||
|
||||
**When Invoked**:
|
||||
- After Research Specialist completes initial research
|
||||
- When a technical approach needs validation before commitment
|
||||
- When integration complexity or performance is uncertain
|
||||
|
||||
**Deliverables**:
|
||||
- Working POC code
|
||||
- Findings document with recommendation (proceed/pivot/block)
|
||||
- Updated research task with results
|
||||
|
||||
---
|
||||
|
||||
## Task File Format
|
||||
|
||||
Tasks are markdown files stored in `tasks/`. Since they're in the repo, they're automatically available in worktrees.
|
||||
|
||||
```markdown
|
||||
---
|
||||
id: auth-setup
|
||||
name: Setup Authentication
|
||||
status: pending
|
||||
depends_on: []
|
||||
scope: moderate
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement OAuth2 authentication with provider abstraction.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] OAuth2 flow works with Google provider
|
||||
- [ ] Tokens stored securely
|
||||
- [ ] Session management implemented
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/auth.md
|
||||
|
||||
## Notes
|
||||
|
||||
> Agent fills this during implementation. Document any decisions,
|
||||
> deviations from architecture, or relevant context discovered.
|
||||
|
||||
## Summary
|
||||
|
||||
> Agent fills this on completion. Brief description of what was
|
||||
> implemented, files changed, and any follow-up needed.
|
||||
```
|
||||
|
||||
### Categorical Estimates
|
||||
|
||||
These fields are structurally important, not optional metadata. They power `taskgraph decompose`, `risk-path`, `critical`, and `bottleneck` — commands that reveal structural problems in the task graph. A task missing `scope`, `risk`, `impact`, or `level` is a red flag indicating incomplete decomposition. See the [cost-benefit framework](/workspace/@alkimiadev/taskgraph/docs/framework.md) for the reasoning.
|
||||
|
||||
| Scope | Description | Example |
|
||||
|-------|-------------|---------|
|
||||
| single | One function, one file | Add validation helper |
|
||||
| narrow | One component, few files | Implement auth middleware |
|
||||
| moderate | Feature, multiple components | Build user API endpoints |
|
||||
| broad | Multi-component feature | Implement OAuth flow |
|
||||
| system | Cross-cutting changes | Database migration |
|
||||
|
||||
| Risk | Failure Likelihood |
|
||||
|------|-------------------|
|
||||
| trivial | Nearly impossible to fail |
|
||||
| low | Standard implementation |
|
||||
| medium | Some uncertainty |
|
||||
| high | Significant unknowns |
|
||||
| critical | High chance of failure |
|
||||
|
||||
### Task Lifecycle
|
||||
|
||||
**Status values**: `pending` → `in-progress` → `completed` | `blocked` | `failed`
|
||||
|
||||
**On completion**, the agent:
|
||||
1. Updates `status: completed`
|
||||
2. Fills in `## Summary` section
|
||||
3. Commits changes to worktree branch
|
||||
|
||||
## Safe Exit Protocol
|
||||
|
||||
When a task becomes untendable:
|
||||
|
||||
### Criteria
|
||||
|
||||
**Hard Criteria** (automatic):
|
||||
- Same task fails verification 3+ times
|
||||
- Task attempts exceed 5+ total
|
||||
|
||||
**Soft Criteria** (agent judgment):
|
||||
- Ambiguous architecture
|
||||
- Missing dependencies
|
||||
- External library incompatibility
|
||||
- Scope creep detected
|
||||
|
||||
### Process
|
||||
|
||||
1. Create blocker task
|
||||
2. Update original task: `status: blocked`, add blocker to `depends_on`
|
||||
3. Document in task notes
|
||||
4. Notify coordinator
|
||||
|
||||
## Review Injection
|
||||
|
||||
Use graph analysis to determine where reviews should happen:
|
||||
|
||||
| Analysis | Injection Point |
|
||||
|----------|-----------------|
|
||||
| Parallel groups | Review before groups merge |
|
||||
| Bottleneck tasks | Review before critical path |
|
||||
| High-risk tasks | Review before proceeding |
|
||||
| Critical path | Review before critical tasks |
|
||||
|
||||
## Coordinator Implementation
|
||||
|
||||
### Current (open-coordinator plugin)
|
||||
|
||||
The Coordinator uses the `worktree` tool from the open-coordinator opencode plugin. It's a single tool with `{action, args}` dispatch — no separate enable/toggle steps. Role is auto-detected from session state.
|
||||
|
||||
```
|
||||
1. Identify parallel work
|
||||
Read task files → groups of independent tasks
|
||||
|
||||
2. Spawn worktrees + sessions
|
||||
worktree({action: "spawn", args: {
|
||||
tasks: ["auth-setup", "db-schema", "api-routes"],
|
||||
prefix: "feat/",
|
||||
agent: "implementation-specialist",
|
||||
prompt: "Your task: {{task}}. Read tasks/{{task}}.md for details."
|
||||
}})
|
||||
|
||||
3. Monitor progress
|
||||
worktree({action: "sessions"}) → status of all spawned sessions
|
||||
worktree({action: "dashboard"}) → worktree + session overview
|
||||
|
||||
4. Handle issues
|
||||
- Recovery message: worktree({action: "message", args: {sessionID: "ses_...", message: "..."}})
|
||||
- Abort if unrecoverable: worktree({action: "abort", args: {sessionID: "ses_..."}})
|
||||
|
||||
5. Handle completion
|
||||
- Agent commits to worktree branch
|
||||
- Agent notifies via worktree({action: "notify", ...})
|
||||
- Coordinator merges back to main
|
||||
|
||||
6. Cleanup
|
||||
worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/auth-setup"}})
|
||||
```
|
||||
|
||||
The plugin also provides SSE-based anomaly detection (model degradation, high error count, session stall) with automatic notifications to the coordinator.
|
||||
|
||||
### Implementation Agent Operations
|
||||
|
||||
Spawned sessions (implementation specialists, code reviewers, POC specialists) get a limited worktree interface:
|
||||
|
||||
```text
|
||||
worktree({action: "current"}) → Show worktree mapping
|
||||
worktree({action: "notify", args: {message: "...", level: "info|blocking"}}) → Report to coordinator
|
||||
worktree({action: "status"}) → Show worktree git status
|
||||
```
|
||||
|
||||
The plugin auto-injects `workdir` for bash commands when a session is mapped to a worktree.
|
||||
|
||||
### Context & Memory (with @alkdev/open-memory)
|
||||
|
||||
When the open-memory plugin is available alongside open-coordinator, the coordinator gains:
|
||||
- `memory({tool: "children", args: {sessionId: "..."}})` — view sub-agent sessions spawned from the coordinator
|
||||
- `memory({tool: "messages", args: {sessionId: "..."}})` — read a spawned session's conversation for debugging
|
||||
- `memory({tool: "context"})` — check context window usage before long monitoring sessions
|
||||
- `memory_compact()` — proactively compact at natural breakpoints
|
||||
|
||||
Implementation agents can also use `memory({tool: "context"})` and `memory_compact()` to manage their context during long tasks.
|
||||
|
||||
### Future (Hub Operations)
|
||||
|
||||
Once the hub is operational, coordination uses native operations:
|
||||
|
||||
```
|
||||
1. Identify parallel work
|
||||
hub.call("coord.spawn", { task, branch, ... })
|
||||
|
||||
2. Monitor progress
|
||||
hub.call("coord.status", { parentSessionId })
|
||||
|
||||
3. Message sessions
|
||||
hub.call("coord.message", { sessionId, message })
|
||||
|
||||
4. Handle aborts
|
||||
hub.call("coord.abort", { sessionId })
|
||||
```
|
||||
|
||||
State moves from in-process tracking to Postgres `mappings` table. The open-coordinator plugin becomes unnecessary — the hub provides the same capabilities as server-side operations accessible from any environment.
|
||||
|
||||
## Document Structure
|
||||
|
||||
```
|
||||
.opencode/
|
||||
├── agents/
|
||||
│ ├── architect.md
|
||||
│ ├── decomposer.md
|
||||
│ ├── coordinator.md
|
||||
│ ├── implementation-specialist.md
|
||||
│ ├── poc-specialist.md
|
||||
│ ├── code-reviewer.md
|
||||
│ ├── architecture-reviewer.md
|
||||
│ └── research-specialist.md
|
||||
|
||||
docs/
|
||||
├── architecture/
|
||||
│ ├── hub-architecture.md
|
||||
│ ├── call-graph.md
|
||||
│ ├── spoke-runner.md
|
||||
│ ├── operations.md
|
||||
│ ├── mcp-server.md
|
||||
│ ├── coordination.md
|
||||
│ ├── storage/ # Decomposed: README.md, table-reference.md, per-domain schema files, tasks.md
|
||||
│ │ └── (ADRs in decisions/)
|
||||
│ ├── agent-sessions.md
|
||||
│ ├── pubsub-redis.md
|
||||
│ └── infrastructure.md
|
||||
├── sdd_process.md # This document
|
||||
└── decisions/ # ADRs
|
||||
|
||||
tasks/
|
||||
├── architecture/
|
||||
│ └── auth-design.md
|
||||
├── implementation/
|
||||
│ ├── storage/
|
||||
│ │ ├── tasks-table.md
|
||||
│ │ └── migrations.md
|
||||
│ └── auth/
|
||||
│ └── oauth-flow.md
|
||||
└── (taskgraph validates & analyzes dependency graph)
|
||||
|
||||
.worktrees/ # Created by coordinator
|
||||
├── feat/
|
||||
│ ├── api-auth/
|
||||
│ └── api-users/
|
||||
└── research/
|
||||
└── storage-abstraction/
|
||||
```
|
||||
|
||||
## Agent Role Specs
|
||||
|
||||
Agent definitions are in `.opencode/agents/`:
|
||||
|
||||
- **architect.md** - Creates architecture specifications
|
||||
- **decomposer.md** - Transforms architecture to task graph
|
||||
- **coordinator.md** - Orchestrates parallel execution
|
||||
- **implementation-specialist.md** - Executes tasks with self-verification
|
||||
- **poc-specialist.md** - Creates proof-of-concepts for validation
|
||||
- **code-reviewer.md** - Reviews code quality at checkpoints
|
||||
- **architecture-reviewer.md** - Validates architecture specs
|
||||
- **research-specialist.md** - Researches and documents findings
|
||||
|
||||
Use with opencode CLI:
|
||||
|
||||
```bash
|
||||
# Spawn coordinator in interactive mode
|
||||
opencode --agent coordinator
|
||||
|
||||
# Send task to implementation specialist
|
||||
opencode run -s <session-id> --agent implementation-specialist "Your task: auth-setup"
|
||||
```
|
||||
|
||||
## Evolution
|
||||
|
||||
This document should evolve with the project:
|
||||
|
||||
1. Refine roles based on actual usage
|
||||
2. Adjust task templates based on what works
|
||||
3. Document coordinator patterns as they emerge
|
||||
4. Capture learnings in after-action reviews
|
||||
Reference in New Issue
Block a user