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:
2026-05-18 15:00:33 +00:00
parent 497a01c544
commit 09f32f0c64
13 changed files with 2072 additions and 0 deletions

View 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`