Critical fixes: - Restructure pointers.md: move setNode prop-key writes section under its own heading (was incorrectly nested under selectNode) - Add Context/Density/Direction/RenderContext documentation section to host-config.md (was only a brief constraint bullet) - Advance all 5 ADRs from Status: Proposed → Accepted and frontmatter from status: draft → status: stable (decisions are driving implementation) - Add error handling philosophy section to README Warning/suggestion fixes: - Add isUElement null check (node !== null) to schema.md discriminator table - Add UjsxEnvelope convenience type documentation to events.md - Add Direction Unicode arrow naming note to transforms.md - Standardize all cross-references from absolute docs/research/ paths to relative ../research/ paths across all architecture docs - Fix schema.md ADR references to use relative paths - Reduce redundancy between transforms.md and host-config.md Direction notes - Update all architecture doc frontmatter from draft → stable Deferred: - Performance model section (reconciler not yet built) - Concepts/glossary document (low ROI at current scale) - Line counts in source references (would date quickly)
10 KiB
status, last_updated
| status | last_updated |
|---|---|
| stable | 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", "items") |
props[segment] on the current UElement — if the prop value is a non-null object (including arrays), navigation continues into that value; otherwise returns undefined |
Path ["items"] → root.props.items (if items is an object or array) |
A segment that parses as a valid non-negative integer is treated as a children index. Otherwise, it is treated as a prop key. If the prop value exists and is a non-null object (which includes arrays, since typeof [] === "object"), selectNode navigates into it. If the prop value is a primitive (string, number, boolean, null) or absent, selectNode returns undefined.
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 and array props
When a string segment resolves to a prop value that is a primitive (string, number, boolean, null), selectNode returns undefined. Only non-null object prop values — including arrays, since typeof [] === "object" — can be navigation targets. This means props.items where items is an array can be navigated into, but props.title where title is a string cannot.
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 path —
path.length === 0— returnsvaluedirectly, replacing the root node. - Path into a primitive — if a segment resolves to a
UPrimitivethat cannot be navigated further (e.g., trying to access.childrenon a string), the function returns the primitive unchanged. It does not throw. This matchesselectNode's behavior of returningundefinedfor invalid paths —setNodetreats 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.
Prop-key path writes
Non-numeric path segments set values into the props object:
setNode(root, ["title"], someValue)
// Produces: { ...rootEl, props: { ...rootEl.props, title: someValue } }
This shallow-merges a key into props. The value argument must be a valid PropValue (or UNode) for the result to remain type-safe. Unlike numeric segments (which navigate into children), non-numeric segments write to props at the current level without deeper navigation — the remaining path tail determines what happens next.
This mirrors selectNode's resolution: where selectNode reads props[segment] and navigates into non-null object values, setNode writes props[segment] = value as a shallow merge.
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:
- Use
selectNodeto read values at fiber positions during the render phase. - Use
setNode-like immutable updates (or internal equivalents) during the commit phase. - Create
ValuePointerinstances 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 —
setNodenever mutates the input tree. It returns a new tree with structural sharing. Consumers must not mutateUNodeobjects directly; always usesetNodeor reconstruct nodes. - Path is informational on ValuePointer — the
pathfield 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]andprops["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
selectNodeand makessetNodesafe 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:
../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)