Tracking

trackRender records the paths a consumer reads through a proxy. Covers ProxyCache, raw, hazards, ancestor wake-ups and diff helpers.

View .md Edit on GitHub

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

FieldTypeDescription
valueSA recording proxy over state. Primitives, null and undefined are returned as-is.
pathsSet<PathId>Grows as the proxy is read. Always a Set, never ALL_PATHS.
disarm() => voidStops 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.c leaves just a.b.c in the set; each deeper read drops its immediate parent. A consumer of user.name does not wake when a sibling user.email changes. A consumer that reads the whole user object without going deeper keeps user and wakes on any change under it.
  • Plain objects and arrays are wrapped in child proxies. Map, Set, Date and 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..of and spread run against the proxy, so items.map((i) => i.title) records items.length, items.0.title, items.1.title, and so on.
  • includes, indexOf and lastIndexOf run against the raw array and pin the array’s own path as a content dependency.
  • Object.keys(obj), for..in and 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 obj records obj.key, so the consumer wakes when the key is added or removed.
  • A proxy is cached per (target, prefix) within one call, so value.user === value.user holds within a render.
  • In development (NODE_ENV !== 'production'), set, deleteProperty and defineProperty on 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():

  1. Identity comparisons. Comparing a value read from the proxy with a raw reference using ===, or with an identity search such as Array.prototype.includes on a derived array, fails because the value is wrapped. Compare raw(value) === rawRef.
  2. Escaped proxies. A proxy, or a sub-proxy inside a derived array such as a .filter result, that outlives the render and is armed keeps recording into a stale paths set and breaks identity against the state. Call raw() on anything stored or handed to code expecting plain objects. After disarm(), 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:

  • patch marks a normal id for the replaced path plus an ancestor-watch id for it. Plain-object branches get no ancestor-watch id, so a user.name change does not wake a user.email reader.
  • 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. useStructural does not need it, because refinement runs in the container. Use it for a raw channel.subscribe consumer 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. An ALL_PATHS skeleton returns ALL_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 returns undefined for missing intermediates.

See the API reference for signatures.

Debug hooks

Note: __setTrackTrace and __setPersistTrackingProxies are internal, test-only seams. They are exported but not part of the stable API.

  • __setTrackTrace(hook | null) installs a global hook that receives a TrackTraceEvent for every recording decision (root-wrap, record, drop-parent, pin, ownKeys, has, pass-through, disarm).
  • __setPersistTrackingProxies(boolean | null) forces whether trackRender honors a supplied ProxyCache. null restores the default, which is enabled.