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.
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 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.
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:
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)