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.
11 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 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:
ReactiveNode— a uniform interface for any signal-backed tree node (component output or element).ReactiveRoot— the top-levelSignal<UNode>that owns the element tree, supports updates, subscriptions, and event-emitting renders.- Factory functions —
reactiveComponentandreactiveElementcreateReactiveNodes whosecomputedsignals 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
interface ReactiveNode {
readonly type: string;
readonly signal: ReadonlySignal<UNode>;
readonly dispose: () => void;
}
type— identifies the node's origin. For components, this iscomponent.displayName ?? "anonymous". For elements, this is the element type string (e.g."div","operation").signal— aReadonlySignal<UNode>. Consumers read.valueto get the current node. The signal iscomputed, so it automatically re-derives when its dependencies change.dispose— intended to clean up the underlyingcomputedsubscription. Currently a no-op — see 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
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
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
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.
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
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. 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) 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) addresses this.
Constraints
@preact/signals-coreis the only reactive primitive library. The layer does not abstract over signal implementations. Switching libraries requires changingreactive.tsand all code that imports the re-exports.ReactiveNode.disposeis a contract with no implementation. Code that callsdispose()today does nothing. When real disposal is implemented, callers must be audited to ensure they call it at the right lifecycle point.ReactiveRoot.updateoverwrites 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.reactiveElementreads 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()eventidis not guaranteed unique.Date.now()collisions are possible within a single millisecond. Do not rely onidfor deduplication.
Open Questions
- Should
ReactiveNode.disposebe implemented usingeffectcleanup or stored-disposer patterns? The currentcomputedsignals have no public dispose API in@preact/signals-core. Disposal requires either switching to an effect-based approach (where eachcomputedis tracked by aneffectthat can be disposed) or maintaining an explicit disposer list. - Should
ReactiveRoottrack all subscription disposers? Adding an internalSet<() => void>for active subscribers would allowReactiveRootto clean up on unmount. This creates a lifecycle coupling —ReactiveRootwould need adestroy()method. - How should
ReactiveRootconnect to the reconciler? Options: (a)ReactiveRootemits 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 for analysis. - 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 callsubscribe()directly rather thanrender().
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