Files
ujsx/docs/architecture/pointers.md
glm-5.1 09f32f0c64 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.
2026-05-18 15:00:33 +00:00

9.1 KiB

status, last_updated
status last_updated
draft 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

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

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

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 pathpath.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 treessetNode 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)