Tracking
trackRender records the paths a consumer reads through a proxy. Covers ProxyCache, raw, hazards, ancestor wake-ups and diff helpers.
Tracking records which paths a consumer reads, so the container can wake it only when one of those paths changes. trackRender is the primitive; useStructural is built on it.
trackRender
import { StructuralContainer, trackRender } from '@dirtytalk/structural';
class Profile extends StructuralContainer<{
user: { name: string; email: string };
}> {
constructor() {
super({ user: { name: 'Ada', email: 'ada@example.com' } });
}
}
const profile = new Profile();
const { value, paths, disarm } = trackRender(profile.state, profile.interner);
value.user.name; // records "user.name"
disarm();
console.log([...paths].map((id) => profile.interner.lookup(id))); // ['user.name']
Signature:
function trackRender<S>(
state: S,
interner: PathInterner,
proxyCache?: ProxyCache,
): TrackResult<S>;
Return value
| Field | Type | Description |
|---|---|---|
value | S | A recording proxy over state. Primitives, null and undefined are returned as-is. |
paths | Set<PathId> | Grows as the proxy is read. Always a Set, never ALL_PATHS. |
disarm | () => void | Stops recording. Afterwards every read on this proxy tree returns the raw value and records nothing. Call it when the synchronous render ends. |
Recording rules
- Own, string-keyed property reads record a path. Symbol keys and inherited properties (array methods, prototype getters) do not.
- Recording is leaf-only: reading
a.b.cleaves justa.b.cin the set; each deeper read drops its immediate parent. A consumer ofuser.namedoes not wake when a siblinguser.emailchanges. A consumer that reads the wholeuserobject without going deeper keepsuserand wakes on any change under it. - Plain objects and arrays are wrapped in child proxies.
Map,Set,Dateand class instances are leaves, returned raw; a change to one is a reference change at its path. - A non-writable, non-configurable property (a field of an
Object.freezed state) is returned raw, because the Proxy invariant requires it. - Arrays:
map,filter,for..ofand spread run against the proxy, soitems.map((i) => i.title)recordsitems.length,items.0.title,items.1.title, and so on. includes,indexOfandlastIndexOfrun against the raw array and pin the array’s own path as a content dependency.Object.keys(obj),for..inand object spread pin the object’s path (the root id for the root object), so the consumer wakes on key add and remove.key in objrecordsobj.key, so the consumer wakes when the key is added or removed.- A proxy is cached per
(target, prefix)within one call, sovalue.user === value.userholds within a render. - In development (
NODE_ENV !== 'production'),set,deletePropertyanddefinePropertyon a tracked proxy throw: state is read-only outside the container.
PathInterner
PathInterner maps dotted path strings to numeric PathIds. Each container class has one, available as container.interner. Pass it to trackRender so recorded ids match the ones the container marks.
import { PathInterner } from '@dirtytalk/structural';
const interner = new PathInterner();
const id = interner.intern('user.name');
interner.lookup(id); // 'user.name'
interner.size; // 1
Array indices appear as numeric segments (items.0.title). See the API reference for all methods.
raw and untracked
function raw<T>(v: T): T;
const untracked: typeof raw;
raw(v) returns the underlying target of a recording proxy, or v itself when it is not a proxy. untracked is the same function under a name that states intent: pass a snapshot across a component boundary so the child’s reads are not attributed to the parent.
import { raw, untracked } from '@dirtytalk/structural';
import { useStructural } from '@dirtytalk/structural/react';
import { Todos } from './todos';
const todos = new Todos();
function List() {
const [state] = useStructural(todos);
const first = state.todos[0];
const same = raw(first) === todos.state.todos[0]; // true
return <Row item={untracked(first)} />;
}
A child receiving untracked(item) is not reactive to that value unless it reads from the container through its own hook.
ProxyCache
class ProxyCache {}
A caller-owned cache that lets trackRender reuse the same proxy for a (target, prefix) pair across calls, instead of allocating a new one per call. Pass it as the third argument.
import { ProxyCache, trackRender } from '@dirtytalk/structural';
import { Todos } from './todos';
const todos = new Todos();
const cache = new ProxyCache();
const first = trackRender(todos.state, todos.interner, cache);
first.value.todos.length;
first.disarm();
const second = trackRender(todos.state, todos.interner, cache);
second.value.todos.length;
second.disarm();
Rules:
- Keyed by
(target, prefix), not target alone. The same object read at two paths gets two independent proxies. A reused proxy requires the same object at the same path as last time, so reordering an item to a new index allocates a fresh proxy for it, while items that keep their index are reused. - One cache per call site. Use one per hook instance or per resolved dependency, never one global or per-container cache. An entry holds one live session at a time; sharing a cache between independently timed callers lets one caller’s render repoint an entry the other is still reading. If a consumer tracks several independent things per render, give each its own cache.
- No eviction. The cache is weakly keyed by target, so unreferenced objects are collected.
- The root proxy is never reused. Only nested proxies are. This keeps
useMemo(..., [state])patterns recording correctly. disarm()prunes stale entries for objects read at shifting prefixes. Call it when each render ends.raw()still unwraps reused proxies.
Tracking hazards
Values read off a recording proxy are themselves proxies. Two situations need raw():
- Identity comparisons. Comparing a value read from the proxy with a raw reference using
===, or with an identity search such asArray.prototype.includeson a derived array, fails because the value is wrapped. Compareraw(value) === rawRef. - Escaped proxies. A proxy, or a sub-proxy inside a derived array such as a
.filterresult, that outlives the render and is armed keeps recording into a stalepathsset and breaks identity against the state. Callraw()on anything stored or handed to code expecting plain objects. Afterdisarm(), reads pass through without recording, but identity still differs from the raw object.
import { raw } from '@dirtytalk/structural';
import { Todos } from './todos';
const todos = new Todos();
declare const trackedTodos: Todos['state']['todos']; // read off a tracked proxy
const wasFiltered = trackedTodos.filter((t) => !t.done).map((t) => raw(t));
const index = todos.state.todos.indexOf(raw(trackedTodos[0]));
Warning: Writes to a tracked proxy throw in development and are silently applied to the underlying object in production. Mutate state only through the container.
Ancestor wake-ups
When patch replaces an array, null, or a class instance wholesale, the diff cannot see inside it. A consumer that read only a descendant (todos.length, profile.bio) would have nothing to match. The container handles this with a second kind of id, an ancestor-watch id:
patchmarks a normal id for the replaced path plus an ancestor-watch id for it. Plain-object branches get no ancestor-watch id, so auser.namechange does not wake auser.emailreader.- Before marking, the container refines each ancestor-watch id: it diffs the skeleton below that path and marks the descendant paths whose own value changed. Registered consumers therefore wake only when what they read changed.
expandWithAncestors(paths, interner)adds the ancestor-watch ids of every ancestor of each leaf to a path set.useStructuraldoes not need it, because refinement runs in the container. Use it for a rawchannel.subscribeconsumer whose interest should wake on an atomic replacement of any ancestor.
import { expandWithAncestors, trackRender } from '@dirtytalk/structural';
import { Todos } from './todos';
const todos = new Todos();
const { value, paths, disarm } = trackRender(todos.state, todos.interner);
value.todos.length;
disarm();
const interest = expandWithAncestors(paths, todos.interner);
todos.subscribe(
() => interest,
() => {},
);
Diff helpers
These are the building blocks the container uses. Use them for custom containers or tests.
import {
StructuralContainer,
diffAlongSkeleton,
getAt,
} from '@dirtytalk/structural';
class Pair extends StructuralContainer<{ a: number; b: number }> {
constructor() {
super({ a: 1, b: 1 });
}
}
const pair = new Pair();
const prev = { a: 1, b: 1 };
const next = { a: 2, b: 1 };
const skeleton = new Set([
pair.interner.intern('a'),
pair.interner.intern('b'),
]);
const dirty = diffAlongSkeleton(prev, next, skeleton, pair.interner);
// dirty contains the id of 'a' only
getAt(next, 'a'); // 2
diffAlongSkeleton(prev, next, skeleton, interner, equalsAt?)returns the skeleton ids whose values differ. AnALL_PATHSskeleton returnsALL_PATHS.changedPathsFromPatch(prev, next, patch, interner, equalsAt?)returns the paths from a patch whose values changed, plus ancestor-watch ids for atomic replacements.getAt(state, 'a.b.0')reads a value at a dotted path and returnsundefinedfor missing intermediates.
See the API reference for signatures.
Debug hooks
Note:
__setTrackTraceand__setPersistTrackingProxiesare internal, test-only seams. They are exported but not part of the stable API.
__setTrackTrace(hook | null)installs a global hook that receives aTrackTraceEventfor every recording decision (root-wrap,record,drop-parent,pin,ownKeys,has,pass-through,disarm).__setPersistTrackingProxies(boolean | null)forces whethertrackRenderhonors a suppliedProxyCache.nullrestores the default, which is enabled.