Files
ujsx/docs/architecture/pointers.md
glm-5.1 23659233ca address architecture review findings and add review document
Fixes from architecture review (4 critical, 10 warnings):

Critical:
- Fix selectNode/setNode docs to accurately describe prop-key
  navigation behavior including array support and prop-key writes
- Document RenderContext/Density exported types in host-config
- Resolve ADR dual status ambiguity with clarifying note in README
  (frontmatter status = editorial, body Status = decision)
- Effect types already addressed in prior commit

Warnings addressed:
- Add Fragment re-export note to jsx-runtime section in
  build-distribution
- Document childCtx/transformCtx helper functions in transforms.md
- Document render() accepting non-root UNode in host-config
- Add Value.Hash re-entrancy constraint to reconciler.md
- Add true-passthrough constraint and h('root') special case
  to element-factory constraints
- Add _idCounter bundling caveat note

Review document added at docs/reviews/architecture-review-2026-05-18.md
with full findings, source verification table, and recommendations.
2026-05-18 15:36:38 +00:00

10 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", "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:

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

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)