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)
162 lines
10 KiB
Markdown
162 lines
10 KiB
Markdown
---
|
|
status: stable
|
|
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<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
|
|
|
|
```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
|
|
|
|
```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.
|
|
|
|
### Prop-key path writes
|
|
|
|
Non-numeric path segments set values into the `props` object:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
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: `../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) |