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:
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)
|
||||
Reference in New Issue
Block a user