--- 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 { constructor(initial: T, path: string[] = []); get value(): T; set value(v: T); get reactive(): ReadonlySignal; get path(): string[]; } ``` ### Signal backing `ValuePointer` wraps a Preact `signal`. Reading `value` returns the current signal value; writing `value` updates the signal and notifies all active effect subscriptions. The `reactive` getter exposes a `ReadonlySignal` 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"`, `"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` mirrors this behavior for writes: a non-numeric string segment sets `props[segment] = value`, performing a shallow merge of that key into the element's props. This allows targeted prop updates via path-based navigation. ### setNode prop-key writes Non-numeric path segments in `setNode` set values into the `props` object: ```typescript setNode(root, ["title"], someNode) // Produces: { ...rootEl, props: { ...rootEl.props, title: someNode } } ``` This shallow-merges a key into `props`. The `value` must be a valid `PropValue` (or `UNode`) for the result to remain type-safe. ## 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)