# BlaC Documentation — DirtyTalk Type-safe state management for React with automatic re-render optimization. Comprehensive guide covering core concepts, React integration, plugins, testing, and framework integrations. This file contains the full text of the DirtyTalk documentation only. See /llms.txt for the complete index. --- # DirtyTalk > A family of small, framework-agnostic libraries answering one question after every mutation — what changed, who cares, and when do we tell them? Source: /dirtytalk/ DirtyTalk is a family of small, framework-agnostic libraries built around one question: **after a mutation, what changed, who cares, and when do we tell them?** Each package answers that question in a different domain, but they all share a single move — compute "what changed" once at the source, in a format every subscriber can intersect cheaply. This page is the entry point to the whole section; it explains the shared thesis and points you to the package you actually need. ## The shared thesis Two unrelated problems turn out to be the same problem. A WebGPU renderer mutates a scene and must repaint; a state container mutates state and must notify subscribers. Both face the same three sub-questions after every mutation: 1. **What changed?** — a region of the world the mutation touched. 2. **Who cares?** — the subscribers whose interest overlaps that region. 3. **When do we tell them?** — the scheduling window in which we batch and deliver. The naive answer pushes all the work down to each consumer. A renderer with a single dirty bit repaints the whole canvas because the bit lost the information about _where_ the change was. A state container re-walks the entire state tree once per subscriber — N consumers means N separate tree walks doing the same equality checks. Both approaches throw away the structure of the change and pay for it on every read. DirtyTalk's answer is to compute "what changed" **once, at the source**, and express it as a **Region** — a value in an algebra with `empty`, `union`, and `intersects`. The source accumulates a single dirty region per scheduling window. Each subscriber declares an interest region. Deciding who to wake is then just one cheap `intersects` check per subscriber against the one shared dirty region — work proportional to the number of subscribers, not to the size of the state or the scene multiplied by the number of subscribers. :::note[Why "Region" is the right abstraction] A single dirty bit is a Region too — but a degenerate one that only answers "did _anything_ change?" By keeping the Region rich (damage rectangles, or a set of changed paths), the source preserves exactly the information each subscriber needs to do less work: skip an undamaged screen area, or skip a re-render when an unread field changed. ::: ## The layering DirtyTalk separates the **abstract algebra** from its **concrete instantiations**. The engine defines the shape of the problem; the two domain packages fill in what a `Region` actually is. | Package | Role | What a `Region` is | Built for | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | -------------------------------------- | | `@dirtytalk/engine` | The abstract algebra + scheduling glue. Defines the `Space` interface (`empty` / `isEmpty` / `union` / `intersects`), the `Scheduler` interface (with `SyncScheduler`, `ManualScheduler`, `MicrotaskScheduler`, `RAFScheduler`), and `DirtyChannel` — the core that accumulates marks within a window and fans out to interested subscribers in one flush. Ships **no** concrete `Region`. | abstract — you supply one | The shared core both domains depend on | | `@dirtytalk/spatial` | The concrete instantiation for 2D rendering. `RectSpace` implements `Space` where a `Region` is a list of damage rects (`Damage` entries carrying a `Rect` and a `kind`). Adds a `SceneNode` / `SceneRoot` tree, a three-stage `data → layout → paint` pipeline, and a `PointerRouter`. | a list of damage rects | canvas / GPU renderers | | `@dirtytalk/structural` | The concrete instantiation for state containers. `PathSetSpace` implements `Space` where a `Region` is a set of interned path IDs. Adds a `PathInterner`, a `trackRender` proxy recorder, diff helpers, and an abstract `StructuralContainer`. | a set of interned path IDs | observable state / data stores | Each domain package answers the _same_ algebra in its own terms. "Union two dirty regions" means "bounding-box-concatenate two damage lists" in spatial and "set-union two path-ID sets" in structural. "Does this interest intersect the dirty region?" means "do any rects overlap?" in spatial and "do the sets share an ID?" in structural. The `DirtyChannel` machinery — coalescing, selective fan-out, lazy interest thunks, re-entrancy handling, error isolation — is written once in the engine and reused by both.
The engine ships zero `Region` implementations — on purpose `@dirtytalk/engine` never references React, the DOM, or a GPU, and it ships no concrete `Space`. `RectSpace` lives in spatial; `PathSetSpace` lives in structural. The engine only `union`s and `intersects` whatever Regions you hand it — producing a Region from a mutation is the consumer's job. There is no dependency graph, no auto-tracking, and no diffing at the engine layer.
## Relationship to BlaC If you arrived here from the BlaC documentation: **BlaC is the headline product of these docs.** It is a state-container library with per-consumer path tracking, and it is a motivating consumer of the structural model — `@dirtytalk/structural` is the lower-level lineage of BlaC's per-consumer path tracking. These DirtyTalk packages are **standalone and framework-agnostic**. You can use `@dirtytalk/engine`, `@dirtytalk/spatial`, or `@dirtytalk/structural` entirely on their own, with no React and no BlaC. This section documents that lower-level engine family directly. If you want a batteries-included state library for React, start with [the BlaC introduction](/guide/introduction); if you want the underlying machinery, read on. ## Install ```bash # The abstract core (always available; the domain packages depend on it) pnpm add @dirtytalk/engine # 2D rendering domain pnpm add @dirtytalk/spatial # State-container domain pnpm add @dirtytalk/structural ``` ```bash npm install @dirtytalk/engine npm install @dirtytalk/spatial npm install @dirtytalk/structural ``` ```bash yarn add @dirtytalk/engine yarn add @dirtytalk/spatial yarn add @dirtytalk/structural ``` Both `@dirtytalk/spatial` and `@dirtytalk/structural` depend on `@dirtytalk/engine`, so installing either one pulls the engine in transitively. Install the engine on its own only when you are wiring up a brand-new domain with your own `Space`. ## Which package do I want? | If you want to… | Use | Start at | | ---------------------------------------------------------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------- | | Track changes to a 2D scene and repaint only what moved (canvas / WebGPU) | `@dirtytalk/spatial` | [Spatial getting started](/dirtytalk/spatial/getting-started) | | Observe a state container and wake only the consumers that read the changed fields | `@dirtytalk/structural` | [Structural getting started](/dirtytalk/structural/getting-started) | | Use BlaC's state management in a React app | `@blac/core` + `@blac/react` | [BlaC introduction](/guide/introduction) | | Apply the "compute changes once, fan out cheaply" pattern to a **new** domain with your own `Region` | `@dirtytalk/engine` | [Engine getting started](/dirtytalk/engine/getting-started) | | Understand the algebra and scheduling model before picking a domain | `@dirtytalk/engine` | [Engine concepts](/dirtytalk/engine/concepts) | :::tip[Not sure?] If your changes have a position on screen, you want **spatial**. If your changes are fields in an object graph, you want **structural**. If you are building neither but recognize the "what changed / who cares / when" pattern, you want the **engine** plus a `Space` of your own. ::: ## Next steps Pick a package and dive in: - **Engine** — the abstract core. [Getting started](/dirtytalk/engine/getting-started) builds your first `DirtyChannel`; [concepts](/dirtytalk/engine/concepts) covers the `Space` algebra, schedulers, and the `DirtyChannel` flush model. - **Spatial** — the 2D rendering domain. [Getting started](/dirtytalk/spatial/getting-started) builds your first scene; [concepts](/dirtytalk/spatial/concepts) covers the damage model, the render pipeline, and pointer routing. - **Structural** — the state-container domain. [Getting started](/dirtytalk/structural/getting-started) builds your first container; [concepts](/dirtytalk/structural/concepts) covers path sets, the observed skeleton, and read tracking. ## See also - [Engine: concepts](/dirtytalk/engine/concepts) — the `Space` algebra, schedulers, and `DirtyChannel` in depth. - [Spatial: concepts](/dirtytalk/spatial/concepts) — how damage rects drive the `data → layout → paint` pipeline. - [Structural: concepts](/dirtytalk/structural/concepts) — interned path IDs, the skeleton, and per-consumer tracking. - [BlaC: introduction](/guide/introduction) — the headline state library that motivated the structural model. --- # API Reference > The exhaustive public surface of @dirtytalk/engine, organized by export — Observable, Signal, Space, the four schedulers, and DirtyChannel. Source: /dirtytalk/engine/api-reference/ The exhaustive surface of `@dirtytalk/engine`, organized by export. Every signature here is quoted from the source. For the conceptual model behind these types see [Concepts](/dirtytalk/engine/concepts); for a guided introduction see [Getting Started](/dirtytalk/engine/getting-started). ## Entry points The package has two entry points: | Import path | Exports | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | | `@dirtytalk/engine` | `Observable`, `Signal`, `Space`, `Scheduler`, `SyncScheduler`, `ManualScheduler`, `MicrotaskScheduler`, `RAFScheduler`, `DirtyChannel` | | `@dirtytalk/engine/primitives` | `Observable`, `Signal` | `Observable`, `Space`, and `Scheduler` are **type-only** exports (interfaces). The rest are runtime classes. ```ts // Everything: import { Signal, SyncScheduler, ManualScheduler, MicrotaskScheduler, RAFScheduler, DirtyChannel, } from '@dirtytalk/engine'; import type { Observable, Space, Scheduler } from '@dirtytalk/engine'; // Just the primitive: import { Signal } from '@dirtytalk/engine/primitives'; import type { Observable } from '@dirtytalk/engine/primitives'; ``` --- ## `Observable` A read-and-subscribe interface. `Signal` is the only built-in implementation; the interface exists so consumers can accept any observable value. ```ts interface Observable { peek(): T; subscribe(cb: (value: T) => void): () => void; } ``` | Member | Signature | Description | | ----------- | -------------------------------------- | ------------------------------------------------------------------------------------------ | | `peek` | `(): T` | Read the current value without subscribing. | | `subscribe` | `(cb: (value: T) => void): () => void` | Register a callback invoked with the new value on change. Returns an unsubscribe function. | --- ## `Signal` `class Signal implements Observable` — a synchronous notification primitive for a single observable value. No scheduler, no coalescing. ### Constructor ```ts constructor(initial: T, equals?: (a: T, b: T) => boolean) ``` | Parameter | Type | Required | Description | | --------- | ------------------------- | -------- | -------------------------------------------------------------------------------- | | `initial` | `T` | yes | Starting value. | | `equals` | `(a: T, b: T) => boolean` | no | Equality predicate used to short-circuit notifications. Defaults to `Object.is`. | Because the default is `Object.is`: `NaN` is considered equal to `NaN` (so writing `NaN` over `NaN` does not notify), and `+0`/`-0` are considered distinct (so writing one over the other _does_ notify). ### Members ```ts get value(): T set value(next: T) peek(): T subscribe(cb: (value: T) => void): () => void ``` | Member | Signature | Returns | Description | | ------------- | ----------------------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------- | | `value` (get) | `get value(): T` | `T` | The current value. | | `value` (set) | `set value(next: T)` | `void` | Assign with an equality guard (see below). | | `peek` | `peek(): T` | `T` | Read without subscribing. Identical result to the `value` getter; provided for `Observable` conformance and intent clarity. | | `subscribe` | `subscribe(cb: (value: T) => void): () => void` | unsubscribe `() => void` | Add `cb` to the subscriber set. Returns an idempotent unsubscribe closure. | **`set value` behavior.** If `equals(current, next)` is true, the setter returns immediately — no store, no notify. Otherwise it stores `next`, snapshots the current subscribers (`Array.from`), and invokes each with `next`. Subscribers are stored in a `Set`, so they run in registration (insertion) order. Errors thrown by callbacks are collected, not aborted on; after all subscribers run, a single error is re-thrown as-is, and multiple errors are thrown as `new AggregateError(errors, 'Signal: multiple subscriber errors')`. **`subscribe` behavior.** Adds `cb` to the internal set and returns a closure that removes it. The closure is idempotent (guarded by a local flag) — calling it more than once is safe. Because subscribers are snapshotted before a notify, unsubscribing during a notify still lets the already-snapshotted callback run on the _current_ set, but not on the next. :::tip[Re-entrant writes recurse] Setting `value` from inside a subscriber triggers a fresh, fully synchronous notify cycle — unlike `DirtyChannel`, whose re-entrant marks defer to the next flush. ::: ```ts import { Signal } from '@dirtytalk/engine/primitives'; const count = new Signal(0); const unsub = count.subscribe((v) => console.log('count:', v)); count.value = 1; // => "count: 1" count.value = 1; // no notify — Object.is(1, 1) is true count.peek(); // 1 unsub(); // custom equality: const user = new Signal({ id: 1 }, (a, b) => a.id === b.id); user.subscribe((u) => console.log(u)); user.value = { id: 1 }; // no notify — same id user.value = { id: 2 }; // notifies ``` --- ## `Space` The algebra of "what changed" and "what I care about". Both are values of type `Region`. The package ships **no** concrete implementation — consuming libraries supply one. ```ts interface Space { empty(): Region; isEmpty(r: Region): boolean; union(a: Region, b: Region): Region; intersects(interest: Region, dirty: Region): boolean; } ``` | Member | Signature | Description | | ------------ | -------------------------------------------- | --------------------------------------------------------------------------------- | | `empty` | `(): Region` | The identity/zero region — "nothing changed". | | `isEmpty` | `(r: Region): boolean` | True iff `r` is the empty region. Used for the no-op flush fast-path. | | `union` | `(a: Region, b: Region): Region` | Accumulate two dirty regions. | | `intersects` | `(interest: Region, dirty: Region): boolean` | The delivery predicate: does this subscriber's interest overlap the dirty region? | **Contracts (required):** - `union(empty(), r)` must equal `r`. - `intersects(empty(), _)` must return `false`. - All four operations must be **pure** — same inputs, same output, no side effects. `DirtyChannel` calls them many times per flush and relies on stable results. ```ts import type { Space } from '@dirtytalk/engine'; // A bitset Space: Region = number. const BitsetSpace: Space = { empty: () => 0, isEmpty: (r) => r === 0, union: (a, b) => a | b, intersects: (i, d) => (i & d) !== 0, }; ``` --- ## `Scheduler` Controls when a flush runs. A `DirtyChannel` hands the scheduler a flush callback via `request`; the scheduler is responsible for invoking it. ```ts interface Scheduler { request(flush: () => void): void; cancel?(): void; } ``` | Member | Signature | Required | Description | | --------- | --------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `request` | `(flush: () => void): void` | yes | Ask for `flush` to be invoked. Must be idempotent within a scheduling window: N requests before the first flush produce one flush. (`SyncScheduler` is the deliberate exception.) Implementations store the latest `flush` passed. | | `cancel` | `(): void` | optional | Teardown that prevents a pending flush. Implemented by `MicrotaskScheduler` and `RAFScheduler`; **not** implemented by `SyncScheduler` or `ManualScheduler`. | --- ## `SyncScheduler` `class SyncScheduler implements Scheduler` — flushes immediately and synchronously on every `request`. ```ts class SyncScheduler implements Scheduler { request(flush: () => void): void; } ``` | Member | Signature | Description | | --------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `request` | `(flush: () => void): void` | Calls `flush()` immediately and synchronously, before `request` returns. Every request flushes — there is no dedupe. | No constructor arguments. No `cancel`. Intended for tests and synchronous-emit compatibility. Because each request flushes at once, every `mark` on a channel using `SyncScheduler` flushes immediately. ```ts import { DirtyChannel, SyncScheduler } from '@dirtytalk/engine'; const ch = new DirtyChannel(BitsetSpace, new SyncScheduler()); ch.subscribe( () => 0b1, (d) => console.log('dirty =', d), ); ch.mark(0b1); // => "dirty = 1" (synchronous) ``` --- ## `ManualScheduler` `class ManualScheduler implements Scheduler` — defers every flush until you call `pump()`. ```ts class ManualScheduler implements Scheduler { request(flush: () => void): void; pump(): void; } ``` | Member | Signature | Description | | --------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `request` | `(flush: () => void): void` | Marks a flush pending and stores `flush` as the latest pending callback. Does **not** invoke it. | | `pump` | `(): void` | If nothing is pending, returns immediately (no-op). Otherwise clears the pending and stored-flush state **first**, then invokes the stored callback. | No constructor arguments. No `cancel`. Because `pump` clears state before invoking, a re-entrant `request` made during the flush is preserved for the _next_ `pump` rather than cascading synchronously. Intended for tests, replay, and SSR. ```ts import { DirtyChannel, ManualScheduler } from '@dirtytalk/engine'; const sched = new ManualScheduler(); const ch = new DirtyChannel(BitsetSpace, sched); const calls: number[] = []; ch.subscribe( () => 0b1, (d) => calls.push(d), ); ch.mark(0b1); ch.mark(0b1); // nothing has run yet sched.pump(); // flush now console.log(calls); // [1] — one coalesced flush sched.pump(); // no-op, nothing pending ``` --- ## `MicrotaskScheduler` `class MicrotaskScheduler implements Scheduler` — coalesces requests within a tick into a single flush at the end of the microtask queue. ```ts class MicrotaskScheduler implements Scheduler { request(flush: () => void): void; cancel(): void; } ``` | Member | Signature | Description | | --------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `request` | `(flush: () => void): void` | Stores `flush` as the latest. If not already pending, schedules a drain via `queueMicrotask`. All requests within a tick coalesce into one microtask; the last stored flush wins. | | `cancel` | `(): void` | Clears pending state and the stored flush, preventing the scheduled drain from running anything. After `cancel`, a fresh `request` works normally. | No constructor arguments. Drains at the end of the current microtask queue. The intended default for `blac`. ```ts import { DirtyChannel, MicrotaskScheduler } from '@dirtytalk/engine'; const ch = new DirtyChannel(BitsetSpace, new MicrotaskScheduler()); ch.subscribe( () => 0b011, (dirty) => console.log('flush dirty =', dirty), ); ch.mark(0b001); ch.mark(0b010); // both coalesce into ONE flush await Promise.resolve(); // => "flush dirty = 3" ``` --- ## `RAFScheduler` `class RAFScheduler implements Scheduler` — coalesces requests to a single flush per animation frame, with a timer fallback. ```ts class RAFScheduler implements Scheduler { constructor(); request(flush: () => void): void; cancel(): void; } ``` | Member | Signature | Description | | ------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `constructor` | `()` | Takes no arguments. Captures whether `requestAnimationFrame` is available **once**, at construction (`typeof globalThis.requestAnimationFrame === 'function'`). | | `request` | `(flush: () => void): void` | Stores `flush` as the latest. If no drain is currently scheduled, schedules one via `requestAnimationFrame` when available, otherwise `setTimeout(fn, 16)`. Coalesces to one flush per frame/tick; last flush wins. | | `cancel` | `(): void` | If a drain is pending, unschedules it (`cancelAnimationFrame` or `clearTimeout`), nulls the handle, and clears the stored flush. | Intended for `insomni` (frame-aligned repaints). :::caution[rAF detection is one-time] The rAF-vs-`setTimeout` choice is fixed at construction. Polyfilling `requestAnimationFrame` _after_ the instance is built will not change its behavior. ::: ```ts import { DirtyChannel, RAFScheduler } from '@dirtytalk/engine'; const ch = new DirtyChannel(BitsetSpace, new RAFScheduler()); ch.subscribe( () => 0b1, (dirty) => repaint(dirty), ); ch.mark(0b1); ch.mark(0b1); // coalesced; one repaint next frame ``` --- ## `DirtyChannel` `class DirtyChannel` — the core engine. Accumulates `mark`s within a scheduler window, then delivers the unioned dirty region to interested subscribers in one flush. ```ts class DirtyChannel { constructor(space: Space, scheduler: Scheduler); mark(r: Region): void; subscribe(interest: () => Region, cb: (dirty: Region) => void): () => void; } ``` ### Constructor ```ts constructor(space: Space, scheduler: Scheduler) ``` | Parameter | Type | Description | | ----------- | --------------- | ------------------------- | | `space` | `Space` | The region algebra. | | `scheduler` | `Scheduler` | Controls when flush runs. | On construction the channel initializes its accumulator to `space.empty()` and allocates a single stable bound flush function once (to avoid GC churn and let identity-keying schedulers dedupe). Construction does **not** call `scheduler.request`. ### `mark` ```ts mark(r: Region): void ``` | Parameter | Type | Description | | --------- | -------- | ----------------------------- | | `r` | `Region` | The region that just changed. | **Returns:** `void`. **Behavior.** Always folds `r` into the accumulator via `space.union(accumulated, r)` — once per mark, regardless of flushing state. It then requests a flush **only if** the channel is neither currently flushing nor already scheduled. A mark made during a flush accumulates into the next flush's payload; the tail of the current flush schedules that follow-up. This guard is what coalesces N marks into a single flush. ### `subscribe` ```ts subscribe(interest: () => Region, cb: (dirty: Region) => void): () => void ``` | Parameter | Type | Description | | ---------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `interest` | `() => Region` | A thunk returning the region this subscriber cares about. Re-evaluated lazily, once per flush per subscriber — not snapshotted at subscribe time. Skipped entirely on empty-dirty flushes. | | `cb` | `(dirty: Region) => void` | Invoked with the accumulated dirty region when `space.intersects(interest(), dirty)` is true. | **Returns:** an unsubscribe function `() => void`. **Behavior.** Assigns a monotonic id, stores a live entry in a registration-ordered map, and returns an idempotent unsubscribe closure that marks the entry dead and removes it. The unsubscribe is safe to call at any time, including from inside a callback mid-flush. **Flush semantics** (the ordered behavior of an internal flush): 1. Snapshot `dirty = accumulated`, reset the accumulator to `empty()`, clear the scheduled flag — all before any callback runs. 2. If `space.isEmpty(dirty)`, return immediately. The subscriber loop is skipped and interest thunks are **not** evaluated. 3. Enter flushing mode. 4. Snapshot the subscriber list. Subscribers added during this flush will not run this cycle. 5. Iterate the snapshot in registration order. Skip dead entries (checked on the entry's `alive` flag); evaluate the interest thunk in a try/catch (a throw is recorded and the subscriber is skipped); skip if `intersects` is false; otherwise invoke the callback in a try/catch (a throw is recorded and iteration continues). 6. Exit flushing mode. 7. If a re-entrant mark left the accumulator non-empty, schedule the next flush. 8. Surface errors: 0 → nothing; exactly 1 → re-throw as-is; more than 1 → `throw new AggregateError(errors, 'DirtyChannel: subscriber errors during flush')`. Interest-thunk errors and callback errors both count toward the aggregate. ```ts import { DirtyChannel, SyncScheduler } from '@dirtytalk/engine'; import type { Space } from '@dirtytalk/engine'; const StringSetSpace: Space> = { empty: () => new Set(), isEmpty: (r) => r.size === 0, union: (a, b) => new Set([...a, ...b]), intersects: (interest, dirty) => { for (const k of interest) if (dirty.has(k)) return true; return false; }, }; const channel = new DirtyChannel(StringSetSpace, new SyncScheduler()); const unsub = channel.subscribe( () => new Set(['users', 'session']), // interest thunk, re-run each flush (dirty) => console.log('dirty keys:', [...dirty]), ); channel.mark(new Set(['users'])); // => "dirty keys: [ 'users' ]" channel.mark(new Set(['theme'])); // => no output: doesn't intersect interest unsub(); ``` --- ## See also - [Concepts](/dirtytalk/engine/concepts) — the Space algebra, scheduler comparison, and full flush lifecycle. - [Getting Started](/dirtytalk/engine/getting-started) — install and build your first channel. - [Structural: api reference](/dirtytalk/structural/api-reference) — a higher-level package built on this engine. - [Spatial: api reference](/dirtytalk/spatial/api-reference) — the engine applied to 2D scene damage. --- # Concepts > The model behind @dirtytalk/engine — why a region carries what changed, the Space algebra, the four schedulers, and the DirtyChannel flush lifecycle. Source: /dirtytalk/engine/concepts/ This page explains the model behind `@dirtytalk/engine`: why a region carries "what changed", what the `Space` algebra guarantees, how the four schedulers differ, and exactly what happens inside a flush. It is the "why" companion to the task-oriented [Getting Started](/dirtytalk/engine/getting-started) and the lookup-oriented [API Reference](/dirtytalk/engine/api-reference). ## The single-dirty-bit problem The naive way to track change is a boolean: "something is dirty, re-run everything." It is cheap to set and useless to act on. When a single value changes, every subscriber wakes up, recomputes, and most of them discover they did not care. A renderer with one dirty bit repaints the whole canvas. A state container with one dirty bit walks the entire tree once per consumer. The fix is to make the dirty signal carry information: not _that_ something changed, but _what_ changed. If "what changed" is a value subscribers can cheaply test their own interest against, then a flush can skip every subscriber that does not overlap. That value is the **`Region`** — and the engine's only opinion is that "what changed" and "what I care about" are the same kind of value, so they can be intersected. This is not a hypothetical generalization. Two real libraries motivated it: `insomni`, a WebGPU renderer whose region is a union of damage rectangles, and `blac`, a state-container library whose region is a set of interned path IDs. Different domains, identical algebra. The engine is that algebra plus the scheduling glue, and nothing else. ## The Space algebra A `Space` is the contract a consuming library implements to describe its notion of change. It is four pure functions: ```ts interface Space { empty(): Region; isEmpty(r: Region): boolean; union(a: Region, b: Region): Region; intersects(interest: Region, dirty: Region): boolean; } ``` Read it as a small algebra over regions: - `empty()` is the **zero region** — the identity element. "Nothing changed." - `isEmpty(r)` recognizes that zero. The channel uses it for a no-op fast-path. - `union(a, b)` **accumulates** two dirty regions into one. This is how many marks become a single flush payload. - `intersects(interest, dirty)` is the **delivery predicate** — the one decision the engine makes per subscriber per flush. ### The contracts, and why they exist Two laws are non-negotiable: 1. **`union(empty(), r)` equals `r`.** `empty()` is the identity for `union`. The channel starts each window with `empty()` and folds every mark into it; if `empty()` were not the identity, the first mark of every window would be corrupted. 2. **`intersects(empty(), _)` returns `false`.** An empty interest is interest in nothing. A subscriber whose thunk returns `empty()` must never be called. (The channel separately skips the _whole_ loop when the _dirty_ region is empty, but this law covers the per-subscriber case.) On top of those, every operation must be **pure**: same inputs produce the same output, with no side effects. The channel calls `union`, `intersects`, `isEmpty`, and `empty` many times across a flush and relies on stable results. A `Space` that mutates its arguments, caches statefully, or returns different answers for the same inputs will produce undefined behavior. Do not, for example, put logging or lazy initialization inside `intersects`. ### Worked example: `StringSet` A region that is a set of changed string keys satisfies the algebra cleanly: ```ts import type { Space } from '@dirtytalk/engine'; const StringSetSpace: Space> = { empty: () => new Set(), // zero = the empty set isEmpty: (r) => r.size === 0, // recognizes the empty set union: (a, b) => new Set([...a, ...b]), // set union accumulates intersects: (interest, dirty) => { for (const k of interest) { if (dirty.has(k)) return true; // any shared key = overlap } return false; // empty interest can never match }, }; ``` Check the laws: `union(new Set(), r)` is a fresh set with exactly `r`'s elements — equal to `r`. `intersects(new Set(), _)` loops zero times and returns `false`. Both operations are pure (each `union` allocates a new set rather than mutating an input). A bitset (`Region = number`, `union = a | b`, `intersects = (i & d) !== 0`) satisfies the same laws and is even cheaper. ## The four schedulers The `Space` decides _what_ and _who_. The `Scheduler` decides _when_. A scheduler receives a `flush` callback via `request(flush)` and is responsible for invoking it. The contract: within a scheduling window, `request` is idempotent — N calls before the first flush produce **one** flush, not N. (`SyncScheduler` is the deliberate exception: each `request` is its own window and flushes immediately.) An optional `cancel()` prevents a pending flush. The engine ships four implementations: | Scheduler | When it flushes | Coalesces? | `cancel()`? | Use it for | | -------------------- | ------------------------------------------------------- | -------------------------- | ----------- | --------------------------------------------- | | `SyncScheduler` | Immediately, inside `request` | No (every request flushes) | No | Tests, synchronous-emit compatibility | | `ManualScheduler` | Only when you call `pump()` | Yes (until pumped) | No | Tests, replay, SSR — deterministic timing | | `MicrotaskScheduler` | End of the current microtask queue | Yes (one per tick) | Yes | `blac` (the default) — batch within a JS turn | | `RAFScheduler` | Next `requestAnimationFrame`, else `setTimeout(fn, 16)` | Yes (one per frame) | Yes | `insomni` — frame-aligned repaints | A few details worth internalizing: - **`SyncScheduler` does not coalesce at the scheduler level.** Every `request` flushes synchronously. With `SyncScheduler`, each `mark` therefore flushes immediately. Coalescing of marks within a window — when it happens at all — comes from the `DirtyChannel`'s own scheduled-flag guard, not from this scheduler. - **`ManualScheduler.pump()` clears its pending state _before_ invoking the flush.** A re-entrant `request` made during that flush is preserved for the _next_ `pump`, not cascaded synchronously. A `pump` with nothing pending is a harmless no-op. - **`MicrotaskScheduler` and `RAFScheduler` store the latest flush and coalesce.** Multiple requests within the same tick/frame collapse to one drain; the last stored flush wins. Both implement `cancel()` to drop a pending drain. - **`RAFScheduler` chooses rAF-vs-setTimeout once, at construction.** It captures `typeof globalThis.requestAnimationFrame === 'function'` in the constructor. If you polyfill `requestAnimationFrame` _after_ building the instance, it will keep using `setTimeout`. ## The DirtyChannel lifecycle A `DirtyChannel` ties a `Space` and a `Scheduler` together. Here is the full flow, narrated, from a mark to a delivered callback. ### mark → accumulate ```ts channel.mark(region); ``` Every `mark` folds the region into the channel's internal accumulator via `space.union(accumulated, region)` — once per mark, regardless of whether a flush is in progress. Then, _only if_ the channel is not already flushing and has not already scheduled, it sets a scheduled flag and calls `scheduler.request(flush)`. This guard is what turns N marks into one flush request: the second through Nth marks see the flag already set and just accumulate. ### scheduler window → flush The scheduler decides when to invoke the flush callback (immediately, on `pump`, on a microtask, on a frame). The channel always hands the scheduler the **same bound function instance**, allocated once at construction. This avoids GC churn and lets identity-keying schedulers dedupe correctly. ### flush: snapshot, evaluate, deliver When the flush finally runs, it executes in a fixed order: 1. **Snapshot and reset.** The accumulated region is captured into a local `dirty`, the accumulator is reset to `empty()`, and the scheduled flag is cleared — all _before_ any callback runs. This is what lets re-entrant marks land cleanly in the fresh accumulator. 2. **Empty fast-path.** If `space.isEmpty(dirty)`, the flush returns immediately. The subscriber loop is skipped entirely and **interest thunks are not even called**. A no-op flush fires nothing. 3. **Enter flushing mode** — a flag the channel uses to recognize re-entrant marks. 4. **Snapshot the subscriber list.** Subscribers added during this flush are not in the snapshot and will not run this cycle. 5. **Iterate in registration order.** For each entry: skip it if it has been unsubscribed (an `alive` flag checked on the entry, so unsubscribing a _later_ subscriber from an _earlier_ callback works); evaluate its interest thunk; if `space.intersects(interest, dirty)` is false, skip; otherwise invoke its callback with `dirty`. 6. **Exit flushing mode.** 7. **Schedule any follow-up.** If a mark arrived during dispatch, the accumulator is now non-empty, so the channel re-sets the scheduled flag and requests the next flush — done _after_ clearing the flushing flag so `mark`'s guard does not double-schedule. 8. **Surface errors** (described below). ### Coalescing Many marks in one window → one flush, carrying the single unioned `dirty` region. The subscriber sees the whole batch at once, not one callback per mark. This is the core efficiency win and it falls directly out of the `union`-on-mark plus the scheduled-flag guard. ### Re-entrant marks defer to the next flush If a subscriber callback calls `mark` during a flush, that region accumulates into the _next_ flush's payload — it never re-enters the current dispatch synchronously. The follow-up is scheduled at step 7 once the current flush is fully done. This bounds the work per tick and makes infinite synchronous loops impossible within a single flush. :::caution[Signal does the opposite] A re-entrant _write_ inside a `Signal` subscriber recurses synchronously — a fresh, fully synchronous notify cycle. `DirtyChannel` defers; `Signal` recurses. Do not assume one behaves like the other. ::: ### Error isolation and AggregateError A throwing interest thunk is treated as "no interest this flush": the error is recorded and the subscriber is skipped. A throwing callback is likewise recorded, and iteration continues to the remaining subscribers. Errors surface only **after** the whole flush completes and the channel's state is clean (including any follow-up scheduling): - 0 errors → nothing thrown. - exactly 1 error → that error is re-thrown **as-is**, unwrapped. - more than 1 → thrown as `new AggregateError(errors, 'DirtyChannel: subscriber errors during flush')`. One subscriber throwing never starves the others. If you want per-callback isolation that never throws, wrap your own callbacks. ### Subscribe / unsubscribe during a flush is safe Because the subscriber list is snapshotted at step 4, a `subscribe` during a callback adds an entry that runs on the _next_ flush, not the current one. An `unsubscribe` during a callback flips the entry's `alive` flag and removes it from the map immediately, so a not-yet-visited subscriber in the snapshot is skipped this cycle. Unsubscribe is idempotent — safe to call any number of times, including mid-flush. ### Interest is a thunk The interest passed to `subscribe` is a function, re-evaluated lazily once per flush per subscriber — never snapshotted at subscribe time, and skipped entirely on empty-dirty flushes. This lets a subscriber move, resize, or reconfigure between flushes and always be matched against its _current_ interest. The flip side: do not put load-bearing side effects in an interest thunk (it may not run on a given flush), and keep it pure-ish — a throw is swallowed-as-error and skips that subscriber for that flush. ## The Signal / Observable layer Alongside the channel, the engine exposes a much simpler primitive for single observable values that do not need region machinery. ```ts interface Observable { peek(): T; subscribe(cb: (value: T) => void): () => void; } ``` `Signal` is the only built-in implementation. It holds one value, notifies subscribers synchronously when that value changes, and short-circuits the notify when the new value is equal to the old (by `Object.is`, or a custom comparator you pass). There is no scheduler and no coalescing — set the value, subscribers run now. How it differs from `DirtyChannel`: | | `Signal` | `DirtyChannel` | | ------------------ | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | Carries | one value `T` | a region of "what changed" | | Notification | synchronous on set | deferred to a scheduler window | | Coalescing | none | many marks → one flush | | Selective delivery | none (every subscriber notified) | interest ∩ dirty per subscriber | | Re-entrancy | recurses synchronously | defers to next flush | | Error surfacing | single re-thrown / `AggregateError('Signal: multiple subscriber errors')` | single re-thrown / `AggregateError('DirtyChannel: subscriber errors during flush')` | Reach for `Signal` when you have "one value, tell everyone now". Reach for `DirtyChannel` when you have "many things, tell the right people once, when the moment is right". ## What it is not The engine is deliberately small. It is not a reactivity framework, and it leaves several things to the layers above it: - **No concrete `Space`.** The package ships zero region implementations. You (or `@dirtytalk/spatial` / `@dirtytalk/structural`) supply one. - **No auto-tracked computed values.** There is no `computed(() => a + b)` with a hidden dependency graph. Derived values are built above this layer. - **No effect system with cleanups.** `subscribe` returns an unsubscribe function; that is the entire cleanup story. - **No selector or memoization helpers.** That is a consumer concern (React's `useMemo`, blac's per-consumer tracker). - **No diffing utilities.** Turning a mutation into a `Region` is the consuming library's job. The engine only `union`s and `intersects`. - **No glitch-free dependency graph.** There is no dependency graph at this layer at all. - **Not coupled to any framework.** React, the DOM, and the GPU are never referenced. There is no React entry point — the only entries are `.` and `./primitives`. ## See also - [Getting Started](/dirtytalk/engine/getting-started) — build a channel and watch these mechanics in action. - [API Reference](/dirtytalk/engine/api-reference) — exact signatures, parameters, and behavior notes for every export. - [Structural: concepts](/dirtytalk/structural/concepts) — a real `Space` over interned path-ID sets. - [Spatial: concepts](/dirtytalk/spatial/concepts) — a real `Space` over damage rectangles, with a frame-aligned scheduler. --- # Getting Started > From an empty file to a working DirtyChannel you can mark and observe, then swapping the scheduler to change timing. Source: /dirtytalk/engine/getting-started/ This page gets you from an empty file to a working `DirtyChannel` you can mark and observe, then shows how swapping the scheduler changes timing. It is the hands-on introduction to `@dirtytalk/engine`; for the reasoning behind the design see [Concepts](/dirtytalk/engine/concepts), and for the exhaustive surface see the [API Reference](/dirtytalk/engine/api-reference). ## Install ```bash pnpm add @dirtytalk/engine ``` ```bash npm install @dirtytalk/engine ``` ```bash yarn add @dirtytalk/engine ``` The package is ESM-first (`"type": "module"`), side-effect free, and has zero runtime dependencies. It ships a main entry point and one subpath, `@dirtytalk/engine/primitives`, for the standalone `Signal` and `Observable` types. ## The one idea Every reactive system eventually answers the same three questions after a mutation: **what changed, who cares, and when do we tell them?** The engine's whole job is to answer those three questions once, cheaply, at the source. You describe "what changed" as a `Region` (a value in an algebra you supply), the channel accumulates regions from many `mark` calls into a single union, a `Scheduler` decides when to flush, and at flush time each subscriber's declared interest is intersected against the accumulated dirty region to decide whether its callback runs. That is the entire model. Everything else on this page is filling in the blanks. ## A complete walkthrough Let's build the smallest useful channel: a `Region` that is a `Set` of keys. Marking `{ "users" }` means "the users data changed"; a subscriber interested in `{ "users", "session" }` should hear about it, while one interested only in `{ "theme" }` should not. ### 1. Define a Space The engine ships **no** concrete `Region` types — you bring your own by implementing the `Space` interface. A `Space` is four pure functions over your region type. ```ts import type { Space } from '@dirtytalk/engine'; // Region = Set: a set of string keys that changed. const StringSetSpace: Space> = { empty: () => new Set(), isEmpty: (r) => r.size === 0, union: (a, b) => new Set([...a, ...b]), intersects: (interest, dirty) => { for (const k of interest) { if (dirty.has(k)) return true; } return false; }, }; ``` Two contracts make the channel work correctly: `union(empty(), r)` must equal `r`, and `intersects(empty(), _)` must be `false`. The set implementation satisfies both for free. (More on why these matter in [Concepts](/dirtytalk/engine/concepts).) ### 2. Construct a channel A `DirtyChannel` takes your `Space` and a `Scheduler`. We start with `SyncScheduler`, which flushes immediately on every request — the easiest thing to reason about while learning. ```ts import { DirtyChannel, SyncScheduler } from '@dirtytalk/engine'; const channel = new DirtyChannel(StringSetSpace, new SyncScheduler()); ``` Construction does nothing observable: it does not request a flush and does not touch the scheduler. The channel just holds an empty accumulator until you `mark`. ### 3. Subscribe with an interest thunk `subscribe` takes two functions. The first is an **interest thunk** — it returns the region this subscriber cares about, and it is re-evaluated on every flush (not snapshotted now). The second is the **callback** that fires with the accumulated dirty region when the interest intersects it. ```ts const unsub = channel.subscribe( () => new Set(['users', 'session']), // interest, re-run each flush (dirty) => { console.log('dirty keys:', [...dirty]); }, ); ``` ### 4. Mark and observe Now drive the channel. With `SyncScheduler`, each `mark` flushes synchronously before `mark` returns. ```ts channel.mark(new Set(['users'])); // => "dirty keys: [ 'users' ]" // 'users' is in our interest, so the callback fires. channel.mark(new Set(['theme'])); // => (no output) // 'theme' does not intersect { 'users', 'session' }, so the callback is skipped. unsub(); channel.mark(new Set(['users'])); // => (no output) — we unsubscribed. ``` That contrast — delivery for `users`, silence for `theme` — is the selective fan-out the engine exists to provide. The subscriber is never told about changes it did not declare interest in. :::tip[Why a thunk and not a value?] Because subscribers move. A rendered node that has scrolled, a query whose key set grew — the interest you cared about last frame may not be the one you care about now. Returning it fresh each flush keeps the subscription correct without re-subscribing. See [interest is a thunk](/dirtytalk/engine/concepts#interest-is-a-thunk). ::: ## Swapping the scheduler The `Space` answers "what changed" and "who cares". The `Scheduler` answers "when do we tell them". Swapping it is a one-line change and the rest of your code is untouched. ### Microtask: coalesce a burst into one flush `MicrotaskScheduler` defers the flush to the end of the current microtask queue, so many marks in the same tick collapse into a single flush with a single unioned region. ```ts import { DirtyChannel, MicrotaskScheduler } from '@dirtytalk/engine'; const channel = new DirtyChannel(StringSetSpace, new MicrotaskScheduler()); channel.subscribe( () => new Set(['users']), (dirty) => console.log('flush dirty:', [...dirty]), ); channel.mark(new Set(['users'])); channel.mark(new Set(['session'])); // does not intersect, but still accumulates channel.mark(new Set(['users'])); // same window await Promise.resolve(); // => "flush dirty: [ 'users', 'session' ]" // ONE flush, with the union of all three marks. ``` Compare this with `SyncScheduler`, where those three marks would have produced up to three separate flushes. The subscriber logic did not change — only the timing did. ### Manual: flush exactly when you say `ManualScheduler` never flushes on its own. You call `pump()` to drain. This is the deterministic choice for tests, replay, and server-side rendering. ```ts import { DirtyChannel, ManualScheduler } from '@dirtytalk/engine'; const scheduler = new ManualScheduler(); const channel = new DirtyChannel(StringSetSpace, scheduler); const seen: string[][] = []; channel.subscribe( () => new Set(['users']), (dirty) => seen.push([...dirty]), ); channel.mark(new Set(['users'])); channel.mark(new Set(['users'])); // Nothing has fired yet. scheduler.pump(); console.log(seen); // [ [ 'users' ] ] — one coalesced flush scheduler.pump(); console.log(seen); // unchanged — nothing pending, pump is a no-op ``` There is also a fourth built-in, `RAFScheduler`, which flushes once per animation frame (falling back to `setTimeout(fn, 16)` when `requestAnimationFrame` is unavailable). It is the right choice for frame-aligned repaints. All four are compared in the [scheduler table](/dirtytalk/engine/concepts#the-four-schedulers). ## The Signal primitive Sometimes you have a single observable value that does not need the full channel ceremony — no regions, no interest, no coalescing window. For that, the engine exposes `Signal`, a synchronous notification primitive, on the `/primitives` subpath. ```ts import { Signal } from '@dirtytalk/engine/primitives'; const count = new Signal(0); const unsub = count.subscribe((v) => console.log('count:', v)); count.value = 1; // => "count: 1" count.value = 1; // skipped — Object.is equality short-circuits the notify count.peek(); // 1 — read without subscribing unsub(); ``` `Signal` notifies synchronously the moment you set `value`, guarded by an equality check that defaults to `Object.is`. You can pass a custom comparator as the second constructor argument: ```ts import { Signal } from '@dirtytalk/engine/primitives'; const user = new Signal({ id: 1, name: 'a' }, (a, b) => a.id === b.id); user.subscribe((u) => console.log('user:', u)); user.value = { id: 1, name: 'b' }; // no notify — same id user.value = { id: 2, name: 'b' }; // => "user: { id: 2, name: 'b' }" ``` `Signal` is intentionally not a `DirtyChannel`: it has no scheduler and no coalescing, and a re-entrant write inside a subscriber recurses synchronously rather than deferring. Pick `Signal` for "one value changed, tell everyone now"; pick `DirtyChannel` for "many things changed, tell the right people once". The difference is covered in [Concepts](/dirtytalk/engine/concepts#the-signal-observable-layer). ## When to use the engine directly `@dirtytalk/engine` is the unopinionated core. You implement the `Space`, you wire the `Scheduler`, you produce regions from your mutations. That is the right level when you are building a notification layer for a new domain, or you want full control over the region algebra. If you are working in an already-modeled domain, reach for a higher-level package instead: | You want to... | Use | | ---------------------------------------------------------------------------- | ---------------------------------------------------------------- | | A scene graph with rect-level damage, a render pipeline, and pointer routing | [`@dirtytalk/spatial`](/dirtytalk/spatial/getting-started) | | Path-set tracking over structured state (interned path IDs, skeletons) | [`@dirtytalk/structural`](/dirtytalk/structural/getting-started) | | The raw region/scheduler/channel machinery, with your own `Space` | `@dirtytalk/engine` (this package) | Both `spatial` and `structural` are built on this engine — they supply the concrete `Space` (rect unions, path-ID sets) and the ergonomics. If one of them fits your domain, start there and drop down to the engine only when you need something neither provides. ## See also - [Concepts](/dirtytalk/engine/concepts) — the Space algebra, the four schedulers, and the DirtyChannel lifecycle in depth. - [API Reference](/dirtytalk/engine/api-reference) — exhaustive signatures for every export. - [DirtyTalk overview](/dirtytalk/) — the shared thesis and how the three packages relate. - [Spatial: getting started](/dirtytalk/spatial/getting-started) — the engine applied to 2D scenes. --- # API Reference > The complete public surface of @dirtytalk/spatial — Rect helpers, RectSpace, SceneNode, SceneRoot, the render pipeline, and PointerRouter. Source: /dirtytalk/spatial/api-reference/ The complete public surface of `@dirtytalk/spatial`, organised by export. Every signature here matches the source. This page is a lookup reference; for the model behind these APIs read [Concepts](/dirtytalk/spatial/concepts), and for a guided build see [Getting Started](/dirtytalk/spatial/getting-started). All exports come from the package root — there are no subpaths: ```ts import { // values rectOverlaps, rectEquals, unionRects, rectClamp, pointInRect, RectSpace, SceneNode, SceneRoot, PointerRouter, } from '@dirtytalk/spatial'; import type { // types Rect, DamageKind, Damage, DirtyRegion, SceneNodeOptions, Renderer2D, SceneRootOptions, FrameTiming, SpatialPointerEvent, PointerHandler, } from '@dirtytalk/spatial'; ``` Scheduler classes (`SyncScheduler`, `ManualScheduler`, `RAFScheduler`, `MicrotaskScheduler`) and the `Space`/`DirtyChannel` primitives come from [`@dirtytalk/engine`](/dirtytalk/engine/api-reference). ## Types ### `Rect` A 2D axis-aligned rectangle in CSS pixels, top-left origin. Note the field names are `w`/`h`, not `width`/`height`. ```ts interface Rect { x: number; y: number; w: number; h: number; } ``` ### `DamageKind` Classifies a damage entry and thereby selects which pipeline stages run. ```ts type DamageKind = 'paint' | 'layout' | 'data'; ``` | Value | Stages run | | ---------- | ---------------------- | | `'paint'` | Paint only. | | `'layout'` | Layout → paint. | | `'data'` | Data → layout → paint. | ### `Damage` A single damage entry: a rectangle, a kind, and (at runtime) the emitting node. ```ts interface Damage { rect: Rect; kind: DamageKind; node?: unknown; } ``` `node` is typed `unknown` to avoid an import cycle between `types.ts` and `scene-node.ts`. At runtime it holds the emitting `SceneNode`; `SceneRoot` casts it to `SceneNode | undefined` when running the pipeline. It is optional — damage marked directly on the channel may have no node. ### `DirtyRegion` The region type carried by the spatial `DirtyChannel`: a read-only list of damage entries. ```ts type DirtyRegion = readonly Damage[]; ``` :::caution[Treat as immutable] `RectSpace.union` may return one of its inputs _by reference_ (the empty short-circuit). Never mutate an array obtained from `union` or `empty`; the `readonly` type enforces this at compile time. ::: ## Rect helpers All five are pure, side-effect-free `const` arrow functions. Only `unionRects` and `rectClamp` allocate a new `Rect`. ### `rectOverlaps(a, b)` ```ts const rectOverlaps: (a: Rect, b: Rect) => boolean; ``` Returns `true` iff the two rects share _positive_ area. Uses half-open semantics: touching edges do not overlap. Returns `false` if either rect has `w <= 0` or `h <= 0`. | Inputs | Result | | -------------------------------------------- | ------- | | Identical non-empty rects | `true` | | One fully contained in the other | `true` | | Right edge of A == left edge of B (touching) | `false` | | Either rect has `w = 0` or `h = 0` | `false` | ```ts rectOverlaps({ x: 0, y: 0, w: 10, h: 10 }, { x: 5, y: 5, w: 10, h: 10 }); // true rectOverlaps({ x: 0, y: 0, w: 10, h: 10 }, { x: 10, y: 0, w: 10, h: 10 }); // false (touching) ``` ### `rectEquals(a, b)` ```ts const rectEquals: (a: Rect, b: Rect) => boolean; ``` Field-by-field structural equality using `===` (no epsilon/tolerance): `a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h`. Any single differing field yields `false`. ```ts rectEquals({ x: 1, y: 2, w: 3, h: 4 }, { x: 1, y: 2, w: 3, h: 4 }); // true rectEquals({ x: 1, y: 2, w: 3, h: 4 }, { x: 1, y: 2, w: 3, h: 5 }); // false ``` ### `unionRects(rects)` ```ts const unionRects: (rects: readonly Rect[]) => Rect; ``` Returns the bounding box (axis-aligned hull) of all input rects. Does not mutate inputs. - Empty array → the origin sentinel `{ x: 0, y: 0, w: 0, h: 0 }`. - Single-element array → a rect equal to that element. - Each rect's extent is treated as `[x, x + w]`; zero-area members are not special-cased and can still pull the hull toward their corner. ```ts unionRects([ { x: 0, y: 0, w: 30, h: 30 }, { x: 50, y: 50, w: 20, h: 20 }, ]); // { x: 0, y: 0, w: 70, h: 70 } unionRects([]); // { x: 0, y: 0, w: 0, h: 0 } ``` ### `rectClamp(inner, outer)` ```ts const rectClamp: (inner: Rect, outer: Rect) => Rect; ``` Returns the _geometric intersection_ of `inner` and `outer`, with width and height floored at `0` via `Math.max(0, …)`. This is the function used to clip damage to clipping ancestors. - Non-overlapping inputs → a zero-area rect. - `inner` fully inside `outer` → a rect value-equal to `inner`. - `inner` equal to `outer` → an equal rect. ```ts rectClamp({ x: 5, y: 5, w: 100, h: 100 }, { x: 0, y: 0, w: 50, h: 50 }); // { x: 5, y: 5, w: 45, h: 45 } (intersection) rectClamp({ x: 200, y: 200, w: 10, h: 10 }, { x: 0, y: 0, w: 50, h: 50 }); // { x: 200, y: 200, w: 0, h: 0 } (no overlap -> zero area) ``` ### `pointInRect(x, y, r)` ```ts const pointInRect: (x: number, y: number, r: Rect) => boolean; ``` Half-open point test over `[x, x + w) × [y, y + h)`: the top-left corner is inclusive, the bottom-right edge is exclusive (CSS pixel-grid convention). A zero-area rect (`w = 0` or `h = 0`) contains no points. ```ts pointInRect(0, 0, { x: 0, y: 0, w: 10, h: 10 }); // true (top-left inclusive) pointInRect(10, 5, { x: 0, y: 0, w: 10, h: 10 }); // false (right edge exclusive) ``` :::note[`pointInRect` is fully public] It is exported from the package root even though the README's export table omits it. ::: ## `RectSpace` ```ts const RectSpace: Space; ``` The `Space` implementation that wires the rect algebra into the engine's `DirtyChannel`. `Space` (from `@dirtytalk/engine`) requires four pure methods, which `RectSpace` provides: | Method | Behaviour | | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `empty(): DirtyRegion` | Returns `[]`. A **fresh array on every call** (`empty() !== empty()`). | | `isEmpty(r): boolean` | `r.length === 0`. | | `union(a, b): DirtyRegion` | Identity short-circuit: if `a` is empty returns `b` by reference; if `b` is empty returns `a` by reference; otherwise returns `[...a, ...b]`. Never mutates. Concatenation only — no dedup, no geometric merge. | | `intersects(interest, dirty): boolean` | `false` if either side is empty. Otherwise a nested loop returning `true` as soon as any `interest` rect overlaps any `dirty` rect (via `rectOverlaps`). O(N×M). **`DamageKind` is ignored** — only rects matter. | You normally never call these directly; `SceneRoot` passes `RectSpace` to its `DirtyChannel`. They are public for advanced uses (e.g. building your own channel over the spatial region). ```ts import { DirtyChannel } from '@dirtytalk/engine'; import { RectSpace } from '@dirtytalk/spatial'; import type { DirtyRegion } from '@dirtytalk/spatial'; const channel = new DirtyChannel(RectSpace, scheduler); ``` ## `SceneNode` ```ts abstract class SceneNode { constructor(options?: SceneNodeOptions); } ``` The abstract base class for every node in the scene tree. Subclass it and implement `paint`. Owns its bounds and contributes damage to the root channel via the parent chain. ### `SceneNodeOptions` ```ts interface SceneNodeOptions { bounds?: Rect; clipsOverflow?: boolean; } ``` | Option | Default | Meaning | | --------------- | ---------------------------- | --------------------------------------------------------------------------- | | `bounds` | `{ x: 0, y: 0, w: 0, h: 0 }` | The node's initial axis-aligned rectangle. | | `clipsOverflow` | `false` | When `true`, _descendants'_ damage rects are clamped to this node's bounds. | ### Public properties | Property | Type | Default | Meaning | | --------------- | ------------------- | --------------------------- | -------------------------------------------------------------------------- | | `bounds` | `Rect` | from options or `{0,0,0,0}` | The node's rectangle. Mutable, but prefer `setBounds` for damage tracking. | | `parent` | `SceneNode \| null` | `null` | Parent in the tree; set by `adoptChild` / `removeChild`. | | `children` | `SceneNode[]` | `[]` | Direct children in adoption order (= z-order; later = topmost). | | `clipsOverflow` | `boolean` | `false` | When `true`, descendants' damage is clamped to this node's bounds. | ### `abstract paint(layer)` ```ts abstract paint(layer: unknown): void; ``` Called during the paint stage. Subclasses **must** implement it. `layer` is the renderer-provided draw surface; the spatial package never inspects it (during the culled walk `SceneRoot` calls `child.paint(undefined)`). Define `layer` to whatever your renderer needs. ### `rebuildData?()` (optional) ```ts rebuildData?(): void; ``` Optional hook for nodes that own a data pipeline (e.g. plot mark layers). Called in **Stage 1** only for `data`-kind damage entries whose node defines it. Use it to re-bin or recompute derived geometry. ### `doLayout?()` (optional) ```ts doLayout?(): void; ``` Optional hook for nodes that own layout. Called in **Stage 2** for any non-`paint` damage entry (`layout` _or_ `data`) whose node defines it. Use it to position content within `this.bounds`. ### `protected markDamaged(kind, rect?)` ```ts protected markDamaged(kind: DamageKind, rect?: Rect): void; ``` The core damage emitter — protected, so only subclasses call it. Behaviour: 1. `rect` defaults to `this.bounds` if omitted. 2. The rect is clipped by walking up the parent chain: every ancestor with `clipsOverflow === true` clamps it (`rectClamp`) to that ancestor's bounds. Cumulative across multiple clipping ancestors. (A node's own `clipsOverflow` does not clip its own damage — only ancestors clip.) 3. Builds `{ rect: clipped, kind, node: this }`. 4. If a `batch` is active, pushes into the buffer and returns (no emit yet). 5. Otherwise resolves the root. If there is no connected root, it is a **silent no-op**. Otherwise calls `root._emitDamage(damage)`. ```ts class Badge extends SceneNode { paint(_layer: unknown): void {} flash(): void { this.markDamaged('paint'); // defaults to this.bounds } } ``` ### `protected batch(fn)` ```ts protected batch(fn: () => void): void; ``` Coalesces all `markDamaged` calls made inside `fn` before emitting. - **Nested batch**: if a batch is already in flight, just runs `fn()` — the outer batch absorbs everything; the inner one does not emit separately. - Runs `fn()` inside `try/finally`; the buffer is always cleared even if `fn` throws. - **Empty buffer → no emit** (`batch(() => {})` emits nothing). - On flush, buffered damages are grouped by kind. For each kind: one entry → emitted as-is; multiple → emitted as a single entry whose rect is `unionRects` of that kind's rects. Each grouped entry is emitted via the root. - Kind-grouping order follows first-seen-kind order. ```ts class Panel extends SceneNode { paint(_layer: unknown): void {} redrawTwoRegions(): void { this.batch(() => { this.markDamaged('paint', { x: 0, y: 0, w: 30, h: 30 }); this.markDamaged('paint', { x: 50, y: 50, w: 20, h: 20 }); }); // Same-kind rects union -> beginFrame gets ONE region: // [{ x: 0, y: 0, w: 70, h: 70 }] } } ``` :::caution[batch unions same-kind rects; a move does not] `batch` collapses same-kind rects into one region. By contrast `setBounds` deliberately emits two _disjoint_ `paint` rects (old + new footprint) that are not unioned, so a multi-rect-scissor renderer can skip the dead gap between them. ::: ### `setBounds(next)` ```ts setBounds(next: Rect): void; ``` The damage-tracked way to move or resize a node. - **No-op if** `rectEquals(this.bounds, next)` — no damage, no mutation. - Otherwise: emits `markDamaged('paint', prev)` (erase old footprint), assigns `this.bounds = next`, emits `markDamaged('paint', next)` (fill new footprint), and **if the node has a parent**, emits `markDamaged('layout')` (re-layout the parent). Net for a connected node with a parent: **2 paint + 1 layout**. - On a root-less node it still mutates `this.bounds` but emits nothing. ```ts node.setBounds({ x: 100, y: 40, w: 120, h: 40 }); // erase old rect (paint) + fill new rect (paint) + parent layout ``` ### `adoptChild(child)` ```ts adoptChild(child: SceneNode): void; ``` Attaches `child` as the topmost child of this node. - If `child` already has a parent, detaches it first via `child.parent.removeChild(child)`. - Sets `child.parent = this` and appends to `this.children` (so it becomes topmost in z-order). - **If this node is connected to a root**, emits a single full-bounds `paint` for the child (`child.markDamaged('paint', child.bounds)`) so the newly-visible region paints. If not connected, no damage is emitted. ### `removeChild(child)` ```ts removeChild(child: SceneNode): void; ``` Detaches `child` from this node. - No-op if `child` is not in `this.children`. - Splices the child out, then emits `child.markDamaged('paint', child.bounds)` to damage the vacated area, **then** sets `child.parent = null`. (Order matters: damage is emitted while the child is still parented so root resolution still works.)
Root resolution and clipping internals `SceneNode` resolves its owning root by walking the parent chain, **starting at itself** — a node that is itself a root resolves to itself (so `SceneRoot.adoptChild` can emit adopt-time paint for its direct children). Root detection is structural/duck-typed: any ancestor exposing a function-valued `_emitDamage` member counts as a root. A node not connected to such a root silently swallows all damage. Damage rects are clipped by cumulative `rectClamp` against each ancestor whose `clipsOverflow` is `true`.
## `SceneRoot` ```ts class SceneRoot extends SceneNode { constructor(renderer: Renderer2D, options?: SceneRootOptions); } ``` The concrete root node. Owns the `DirtyChannel`, the scheduler, and the `Renderer2D`, and drives the three-stage pipeline. Inherits all of `SceneNode` (`bounds`, `parent`, `children`, `clipsOverflow`, `setBounds`, `adoptChild`, `removeChild`, `markDamaged`, `batch`). ### Constructor ```ts constructor(renderer: Renderer2D, options: SceneRootOptions = {}) ``` | Parameter | Notes | | ----------------------- | --------------------------------------------------------------------------------- | | `renderer` | Required. Stored on `this.renderer`. | | `options.scheduler` | Default `new RAFScheduler()`. Pass `SyncScheduler` or `ManualScheduler` in tests. | | `options.bounds` | Passed to `super`; defaults to `{0,0,0,0}`. Used as the default interest region. | | `options.onFrameTiming` | Optional. When set, every rendered frame is timed and reported. | On construction it creates `this.channel = new DirtyChannel(RectSpace, scheduler ?? new RAFScheduler())` and subscribes once with `interest = () => [{ rect: this.bounds, kind: 'paint' }]` (a thunk, re-evaluated per flush so resizing is respected) and callback `(dirty) => this._renderFrame(dirty)`. ### `SceneRootOptions` ```ts interface SceneRootOptions { scheduler?: Scheduler; // default: new RAFScheduler() bounds?: Rect; // default interest region onFrameTiming?: (timing: FrameTiming) => void; // opt-in timing hook } ``` ### Public properties | Property | Type | Notes | | ----------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `channel` | `readonly DirtyChannel` | The owned dirty channel. You can push marks directly via `channel.mark([...])`. | | `renderer` | `readonly Renderer2D` | The renderer contract instance. | | `fullFrame` | `boolean` (default `false`) | When `true`, damage is ignored: every frame repaints `[this.bounds]` and the paint walk visits all children (no culling). The "damage tracking off" baseline for cost comparison — leave `false` in production. | ### `paint(_layer)` ```ts paint(_layer: unknown): void; ``` The root does not paint itself. It walks children in adoption order and calls each `child.paint(_layer)`. This is the un-culled walk, distinct from the culled walk used during rendering. ### `hitTest(x, y)` ```ts hitTest(x: number, y: number): SceneNode | null; ``` Returns the topmost, deepest node containing the point, or `null`. - Walks children in reverse adoption order (topmost = last adopted wins). - For the first child whose bounds contain `(x, y)` (`pointInRect`, half-open), recurses and returns the deepest descendant hit, else that child itself. - Returns `null` if no child contains the point. **The root is never a valid hit target.** ```ts const node = root.hitTest(120, 35); // SceneNode | null ``` ### `channel`-direct marking Because `channel` is public, you can mark damage that has no owning node — e.g. a manual full-region invalidation: ```ts root.channel.mark([{ rect: { x: 0, y: 0, w: 800, h: 600 }, kind: 'paint' }]); ``` ### The render pipeline (behaviour) When the channel flushes, the root runs three ordered stages over the flushed `DirtyRegion`: 1. **Stage 1 — data.** For each entry: if `kind === 'data'` and the node defines `rebuildData`, call it. 2. **Stage 2 — layout.** For each entry: if `kind !== 'paint'` (so `layout` or `data`) and the node defines `doLayout`, call it. 3. **Stage 3 — paint.** `regions = fullFrame ? [this.bounds] : dirty.map(d => d.rect)` (the individual, disjoint rects — not their union). Call `renderer.beginFrame(regions)`; paint each top-level child whose bounds overlap a region, in adoption order (this is the cull); call `renderer.endFrame()`. Strict per-frame call order for a `data` damage: `rebuildData → doLayout → beginFrame → paint → endFrame`. The cull is one level deep — only direct children of the root are culled; a painted child draws its own subtree. ### `Renderer2D` ```ts interface Renderer2D { beginFrame(regions: readonly Rect[]): void; endFrame(): void; } ``` The renderer contract the package ships (no implementation). `beginFrame(regions)` begins a frame clipped/scissored to the given damage regions; **the array is never empty when called**, and `regions` is the list of _individual_ damage rects, not their union. A v1 renderer may `unionRects(regions)` and clip to the bounding box, or ignore `regions` and clear the whole canvas; a multi-rect renderer scissors to each. `endFrame()` takes no arguments (submit/flush). ```ts const renderer: Renderer2D = { beginFrame(regions) { // v1: clip to the bounding box of all damage const box = unionRects([...regions]); ctx.save(); ctx.beginPath(); ctx.rect(box.x, box.y, box.w, box.h); ctx.clip(); ctx.clearRect(box.x, box.y, box.w, box.h); }, endFrame() { ctx.restore(); }, }; ``` ### `FrameTiming` ```ts interface FrameTiming { layoutMs: number; // ms in data + layout stages (rebuildData + doLayout) paintMs: number; // ms in paint stage (beginFrame -> paint walk -> endFrame) paintedNodes: number; // top-level nodes whose paint() actually ran this frame } ``` Reported via `onFrameTiming` exactly once per _rendered_ frame. `paintedNodes` is the headline cost signal — it scales with the damaged area (every child in full-frame mode), not the scene size. When `onFrameTiming` is omitted there is **zero overhead**: `performance.now()` is never called. ```ts const root = new SceneRoot(renderer, { bounds: { x: 0, y: 0, w: 800, h: 600 }, onFrameTiming: ({ layoutMs, paintMs, paintedNodes }) => { console.log( `layout ${layoutMs}ms paint ${paintMs}ms nodes ${paintedNodes}`, ); }, }); ``` ## `PointerRouter` ```ts class PointerRouter { constructor(root: SceneRoot); dispatch(e: SpatialPointerEvent): SceneNode | null; } ``` Routes pointer events into the scene with capture semantics. Framework-agnostic: you translate DOM `PointerEvent`s into `SpatialPointerEvent`s at the boundary. Maintains a private capture map keyed by `pointerId`. ### `SpatialPointerEvent` ```ts interface SpatialPointerEvent { type: 'down' | 'move' | 'up' | 'cancel'; x: number; y: number; buttons: number; pointerId: number; } ``` A minimal, surface-agnostic pointer event. Build it from a DOM event: `{ type, x: e.offsetX, y: e.offsetY, buttons: e.buttons, pointerId: e.pointerId }`. ### `PointerHandler` ```ts interface PointerHandler { onPointerDown?(e: SpatialPointerEvent): void; onPointerMove?(e: SpatialPointerEvent): void; onPointerUp?(e: SpatialPointerEvent): void; onPointerCancel?(e: SpatialPointerEvent): void; } ``` The optional handler interface a `SceneNode` may implement to receive pointer callbacks. The router treats nodes as `Partial` and optional-chains the matching method — a node with no handler is still a valid hit target, receives no callback, and never throws. ### `constructor(root)` ```ts constructor(root: SceneRoot) ``` Holds the root for hit-testing. ### `dispatch(e)` ```ts dispatch(e: SpatialPointerEvent): SceneNode | null; ``` Dispatches an event and returns the receiving node (or `null`). Capture-based semantics: | Event | Behaviour | | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `down` | Hit-tests via `root.hitTest`. On a hit: captures the node for `e.pointerId`, invokes `onPointerDown`, returns it. On a miss: captures nothing, returns `null`. | | `move`/`up`/`cancel` **with capture** | Delivers to the captured node (even if the pointer drifted outside its bounds). On `up`/`cancel`, releases the capture. Returns the captured node. | | `move` **uncaptured** | Hit-tests by current position; invokes `onPointerMove` on the hit (if any); returns it or `null`. | | `up`/`cancel` **uncaptured** | Dropped — returns `null`, no handler invoked. | Multiple `pointerId`s are tracked independently. Hover is just `move` dispatch — a node repaints on hover by calling `markDamaged('paint')` inside `onPointerMove`. ```ts const router = new PointerRouter(root); const hit = router.dispatch({ type: 'down', x: 120, y: 35, buttons: 1, pointerId: 1, }); // `hit` is the captured node; subsequent move/up for pointerId 1 follow it. ``` ## See also - [Getting Started](/dirtytalk/spatial/getting-started) — install and build a working scene. - [Concepts](/dirtytalk/spatial/concepts) — the damage model, pipeline, and pointer routing explained. - [Engine: API Reference](/dirtytalk/engine/api-reference) — `Space`, `DirtyChannel`, and the scheduler classes. - [Engine: Concepts](/dirtytalk/engine/concepts) — coalescing, interest, and flush semantics. --- # Concepts > The model behind @dirtytalk/spatial — why a single dirty bit loses information, the damage list that replaces it, and the data → layout → paint pipeline. Source: /dirtytalk/spatial/concepts/ This page explains the model behind `@dirtytalk/spatial`: why a single dirty bit is not enough, how the damage list replaces it, and how the scene root turns a flushed list of damage into ordered data → layout → paint work. It is the _why_ and the _mental model_. For a hands-on walkthrough, start with [Getting Started](/dirtytalk/spatial/getting-started); for the exhaustive surface, see the [API Reference](/dirtytalk/spatial/api-reference). ## The problem: a single dirty bit loses information After a mutation, every renderer faces three questions: _what_ changed, _who_ cares, and _when_ do we tell them. The naive answer is a boolean: "something is dirty, repaint." That bit is cheap to set and tells you nothing useful. It cannot tell the renderer _which pixels_ to redraw, so the renderer clears the whole canvas. It cannot tell the pipeline _what kind_ of change happened, so even a colour tweak re-runs layout. The dirty bit is lossy by construction — it throws away precisely the information you need to do less work. The fix is to make "what changed" a _value_, not a flag. In the 2D-spatial domain that value is a list of **damage entries**, each carrying a rectangle (where) and a kind (what sort of change). A subscriber can intersect its interest against the list cheaply; the renderer can rasterise exactly the listed rectangles; the pipeline can decide, per entry, which stages to run. The damage list is computed once at the source and shared by everyone downstream. This is the DirtyTalk thesis applied to pixels. The same engine primitives power [`@dirtytalk/structural`](/dirtytalk/structural/concepts) for state-shaped domains; there the region is a set of paths instead of a set of rects. ## Rect: the unit of "where" A `Rect` is an axis-aligned rectangle in CSS pixels, with a top-left origin: ```ts interface Rect { x: number; y: number; w: number; // width — note: w/h, not width/height h: number; } ``` All spatial geometry is expressed in these. The package ships a small set of pure rect helpers — no side effects, allocation-light: | Helper | Answers | | ------------------------- | -------------------------------------------------------- | | `rectOverlaps(a, b)` | Do these two rects share positive area? | | `rectEquals(a, b)` | Are these structurally identical (field-by-field `===`)? | | `unionRects(rects)` | What is the bounding box of all of these? | | `rectClamp(inner, outer)` | What is the intersection of these two? | | `pointInRect(x, y, r)` | Does this point fall inside this rect? | Two conventions matter and are consistent across the package: - **Half-open intervals.** Both `rectOverlaps` and `pointInRect` treat the bottom and right edges as _exclusive_: a point at `r.x + r.w` is _outside_, and two rects whose edges merely touch do **not** overlap. This matches the CSS pixel-grid convention and means adjacent tiles never double-count a shared edge. - **Zero-area rects are empty.** A rect with `w <= 0` or `h <= 0` overlaps nothing and contains no points. `unionRects([])` returns the origin sentinel `{ x: 0, y: 0, w: 0, h: 0 }`. :::caution[`rectClamp` is intersection, not "move inside"] Despite the name, `rectClamp(inner, outer)` returns the _geometric intersection_ of the two rects, flooring width and height at zero. If `inner` lies entirely outside `outer`, you get a zero-area rect — not `inner` nudged inward. It is used to clip damage to clipping ancestors, which is exactly an intersection. ::: ## DamageKind: the unit of "what sort" Each damage entry carries a `kind` that classifies the change and, through it, decides which pipeline stages run: ```ts type DamageKind = 'paint' | 'layout' | 'data'; ``` | Kind | Emit when | Stages triggered | | -------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------- | | `paint` | A visual-only field changed — colour, label, pressed/hover state. Geometry is unchanged. | Paint only. | | `layout` | The node's bounds changed. `setBounds` emits this for the parent automatically. | Layout (`doLayout`) → paint. | | `data` | The underlying data set changed — e.g. new plot samples that must be re-binned. | Data (`rebuildData`) → layout (`doLayout`) → paint. | The ordering is a containment hierarchy: `data` implies `layout` implies `paint`. A `data` change must rebuild derived data, which may move things, which must be redrawn. A `paint` change skips straight to drawing. Stage selection is **per entry**: in a single flush that mixes kinds, a `data` node runs all three stages while a `paint` node in the same flush only paints. A full damage entry is: ```ts interface Damage { rect: Rect; kind: DamageKind; node?: unknown; // the emitting SceneNode at runtime; optional for root-level marks } ``` `node` is typed `unknown` on purpose so the `types.ts` module has no import cycle with `scene-node.ts`. At runtime it holds the `SceneNode` that emitted the damage (the root casts it back when running the pipeline). It is optional because damage pushed directly into the channel — not through a node — may have no owning node. ## RectSpace: the algebra the channel speaks The engine's `DirtyChannel` is generic over a region type via a `Space` — four pure methods that define how regions combine and test for overlap. `RectSpace` is that algebra instantiated for `DirtyRegion`, which is simply `readonly Damage[]`: ```ts type DirtyRegion = readonly Damage[]; // RectSpace satisfies Space: // empty() -> [] (a fresh array each call) // isEmpty(r) -> r.length === 0 // union(a, b) -> [...a, ...b] (with identity short-circuits) // intersects(interest, d) -> any interest rect overlaps any dirty rect ``` Three properties of this v1 implementation are worth internalising: - **`union` is concatenation with a reference short-circuit.** If one side is empty, the other side is returned _by reference_ (no copy); otherwise the two arrays are concatenated into a fresh one. There is no dedup and no geometric merge — damage entries accumulate as a plain list. This is safe precisely because `DirtyRegion` is `readonly`: nobody may mutate an array returned by `union` or `empty`. - **`intersects` ignores `DamageKind`.** Overlap is purely geometric: it returns `true` as soon as any interest rect overlaps any dirty rect, regardless of kind. A `paint` interest matches a `layout` damage at the same rect. - **It is O(N×M).** Both `union` and `intersects` are linear/quadratic over a plain array. There is no spatial index in v1. For typical per-frame damage counts this is cheap; it is a deliberate simplicity trade-off, not an oversight. :::note[Why a list and not a merged region?] A merged region (one big union rect, or a coverage mask) would lose the disjoint structure that makes partial redraw pay off. Keeping damage as a list of individual rects lets a multi-rect-scissor renderer skip the dead space _between_ damaged areas — most importantly the gap a node leaves behind when it moves. See the move discussion under `setBounds` below. ::: ## SceneNode: who owns bounds and emits damage A `SceneNode` is one node in the retained scene tree. It is abstract — you subclass it and implement `paint`. Its responsibilities: - **Own its `bounds`.** The node's axis-aligned rectangle. Mutate it through `setBounds` (which tracks damage) rather than assigning directly. - **Hold the parent chain.** `parent` and `children` form the tree. `children` is in _adoption order_, which is also _z-order_: later-adopted children are topmost. - **Emit damage.** The protected `markDamaged(kind, rect?)` is how a node declares that a region of itself changed. The rect defaults to the node's bounds and is clipped on the way up by any ancestor with `clipsOverflow === true`. - **Coalesce mutations.** The protected `batch(fn)` buffers all `markDamaged` calls made inside `fn` and flushes them grouped by kind when `fn` returns. ### How damage reaches the root `markDamaged` does not talk to the channel directly. It resolves the owning root by walking _up_ the parent chain (starting at the node itself), and calls the root's emit method. Root identity is **duck-typed**: any ancestor exposing a function-valued `_emitDamage` member counts as a root. The corollary is the most important gotcha in the package: :::caution[Detached nodes silently swallow damage] If a node is not connected to a root, `markDamaged` is a no-op — it does not throw. `setBounds` still mutates `bounds`, and `adoptChild`/`removeChild` still wire the tree, but no damage is emitted. Adopt-time paint only fires once the whole subtree is connected to a root. Build your tree, attach it to a `SceneRoot`, _then_ mutate. ::: ### batch vs. a move: two coalescing behaviours These look similar but differ deliberately, and the difference is the whole reason damage is a list: - **`batch(fn)` unions same-kind rects into one region.** Two `paint` marks inside a batch become a single `paint` entry whose rect is their bounding box. A `paint` plus a `data` mark become two entries (one per kind). The grouping is by kind, in first-seen order. - **`setBounds` (a move) emits two _disjoint_ paint rects, not unioned.** Moving a node emits `paint` over the old footprint (erase) and `paint` over the new footprint (fill), plus a `layout` for the parent. These two paint rects are deliberately _not_ unioned at the channel level, so a renderer that scissors per-rect leaves the dead gap between the old and new positions untouched. Unioning them would force a repaint of everything in between. If `setBounds` is called with an equal rect, it is a complete no-op — no damage, no mutation. ## SceneRoot: the three-stage pipeline `SceneRoot` is the concrete node at the top of the tree. It owns the `DirtyChannel`, the scheduler, and the `Renderer2D`, and it subscribes itself exactly once at construction. Its interest is a _thunk_ returning its current bounds, so resizing the root is respected on the next flush. Any damage overlapping the root's bounds triggers a frame. When the channel flushes, the root receives the coalesced `DirtyRegion` and runs three ordered stages over it: 1. **Stage 1 — data.** For each entry whose `kind === 'data'`, if its node defines `rebuildData()`, call it. This is where a plot layer re-bins samples or recomputes mark geometry. 2. **Stage 2 — layout.** For each entry whose `kind !== 'paint'` (so `layout` _and_ `data`), if its node defines `doLayout()`, call it. Data changes flow through here too, because new data may need repositioning. 3. **Stage 3 — paint.** Compute the paint regions — in normal mode these are the _individual_ damage rects (`dirty.map(d => d.rect)`), **not** their union. Call `renderer.beginFrame(regions)`, then walk the children in adoption order painting only those whose bounds overlap a region (the _cull_), then call `renderer.endFrame()`. For a single `data` damage the strict call order is: `rebuildData → doLayout → beginFrame → paint → endFrame`.
Culling is where the win lands, and it is one level deep `_paintCulled` walks the root's direct children and paints a child only if its bounds overlap at least one damage region. Repaint cost therefore scales with the _damaged area_, not the _scene size_ — the headline benefit of damage tracking. The cull is one level deep: only the root's direct children are tested. A child that survives the cull is responsible for drawing its own subtree however it likes. If you set `root.fullFrame = true`, the cull is bypassed: every frame repaints `[this.bounds]` and every child paints. That mode exists as a baseline for measuring the cost difference, not for production.
The optional `onFrameTiming` hook reports `{ layoutMs, paintMs, paintedNodes }` per rendered frame. When the hook is omitted, `performance.now()` is never called — there is zero timing overhead in the common case. ## PointerRouter: hit-testing and z-order Input is the dual of output: the renderer asks "what is at this rect?", the pointer router asks "what is at this point?". `PointerRouter` answers by hit-testing the tree. `SceneRoot.hitTest(x, y)` walks children in _reverse_ adoption order, so the topmost (last-adopted) child wins when several overlap. For the first child whose bounds contain the point (`pointInRect`, half-open), it recurses and returns the _deepest_ descendant hit, or that child if no grandchild matches. If no child contains the point it returns `null` — the root itself is never a valid hit target. `PointerRouter.dispatch(e)` adds **capture** semantics on top of hit-testing: - A `down` hit-tests, _captures_ the hit node for that `pointerId`, invokes its `onPointerDown`, and returns it. A miss captures nothing and returns `null`. - A `move`/`up`/`cancel` with an existing capture is delivered to the _captured_ node even if the pointer has drifted outside its bounds — this is what makes dragging work. An `up` or `cancel` releases the capture. - An _uncaptured_ `move` routes by current hit (this is how hover works). An _uncaptured_ `up`/`cancel` is dropped and returns `null`. Handlers are optional. The router treats a node as a `Partial` and optional-chains the relevant method, so a node with no handler is a valid, returnable hit target that simply receives no callback and never throws. The router is framework-agnostic: `SpatialPointerEvent` is a plain object you build from a DOM `PointerEvent` at the boundary. ## The Renderer2D contract: v1 vs. the planned v2 `SceneRoot` never rasterises. It calls two abstract hooks on the renderer you supply: ```ts interface Renderer2D { beginFrame(regions: readonly Rect[]): void; // never called with an empty array endFrame(): void; } ``` The contract's subtlety is in `regions`. It is the list of the frame's _individual_ damage rects, **not** their union. A far single-frame move arrives as two disjoint rects (erase old, fill new), so a renderer that scissors to each rect leaves the dead gap untouched. - **v1 — bounding-rect redraw.** The simplest correct renderer ignores the disjoint structure: call `unionRects(regions)` and clip to that one bounding box, or ignore `regions` entirely and clear the whole canvas. Correct, easy, but repaints the gap. - **planned v2 — tile/scissor redraw.** A renderer that issues one scissored draw call per rect (or per damaged tile) redraws only the listed areas and skips everything between them. The damage list is shaped to make this possible — that is why same-kind batch rects are unioned but a move's two footprints are not. ## What it is not - **Not a GPU renderer.** It ships the `Renderer2D` contract, not an implementation. You bring the rasteriser. - **Not a spatial index.** v1 uses a plain array; `union` concatenates and `intersects` is O(N×M). No dedup, no quadtree. - **Not auto-tracked reactivity.** `paint()` runs because a node was _damaged_, not because a field was read. This is a declared-damage model, not a dependency graph like React or MobX. Contrast [BlaC tracked state](/core/tracked). - **Not a virtual-scene diff.** Nodes declare their own damage; the renderer trusts the declaration. There is no reconciliation pass. - **Not an animation primitive.** Animate by calling `markDamaged('paint')` each step. - **Not coupled to the browser.** No dependency on `window`, `document`, or `HTMLCanvasElement`. `SpatialPointerEvent` is a plain object you construct at the DOM boundary. ## See also - [Getting Started](/dirtytalk/spatial/getting-started) — build a working scene step by step. - [API Reference](/dirtytalk/spatial/api-reference) — every type, helper, method, and option. - [Engine: Concepts](/dirtytalk/engine/concepts) — the `Space` algebra, `DirtyChannel`, and scheduler model this builds on. - [Structural: Concepts](/dirtytalk/structural/concepts) — the same engine applied to path-set state. --- # Getting Started > From an empty file to a live scene graph that paints only what changed — a stub renderer, a SceneNode, a SceneRoot, and pointer routing. Source: /dirtytalk/spatial/getting-started/ This page gets you from an empty file to a live scene graph that paints only what changed. It is hands-on: you will build a stub renderer, a tiny `Button` node, wire up a `SceneRoot`, mutate state, and watch the exact damaged region flow through the pipeline. Then you will route a DOM `PointerEvent` into the scene. If you want the _why_ behind the design, read [Concepts](/dirtytalk/spatial/concepts); for the exhaustive surface, see the [API Reference](/dirtytalk/spatial/api-reference). ## What this package is for `@dirtytalk/spatial` is the 2D-spatial instantiation of the DirtyTalk thesis: compute _what changed_ once, at the source, as a list of rectangle-carrying damage entries, so every subscriber can do less work. A renderer normally has to choose between repainting the whole canvas (cheap to write, expensive to run) or hand-rolling per-widget invalidation (fast, but bespoke and bug-prone). This package gives you the second outcome with the first amount of effort: nodes declare their own damage, the root coalesces it through a scheduler, and the paint walk is culled to the damaged area. It is a _compute layer_, not a rendering engine. It tells you which rectangles to redraw and in what order to run data/layout/paint work. You supply the actual rasteriser behind the `Renderer2D` contract. ## Install `@dirtytalk/spatial` depends on `@dirtytalk/engine` — the engine provides the `Space`, `DirtyChannel`, and `Scheduler` primitives that the scene root is built on. Install both. ```bash pnpm add @dirtytalk/spatial @dirtytalk/engine ``` ```bash npm install @dirtytalk/spatial @dirtytalk/engine ``` ```bash yarn add @dirtytalk/spatial @dirtytalk/engine ``` :::note[Single entry point] Everything is exported from the package root. There are no subpath entries — import `Rect`, `SceneNode`, `SceneRoot`, `PointerRouter`, and the rect helpers all from `@dirtytalk/spatial`. Scheduler classes (`SyncScheduler`, `ManualScheduler`, `RAFScheduler`, `MicrotaskScheduler`) come from `@dirtytalk/engine`. ::: ## A complete minimal scene We will build the smallest scene that demonstrates the whole loop: a renderer that logs frames, a node that knows how to invalidate itself, and a root that drives the pipeline. ### Step 1 — a stub renderer The package ships the `Renderer2D` _contract_, not an implementation. A renderer is just an object with `beginFrame(regions)` and `endFrame()`. For learning, log the regions you are handed. ```ts import type { Renderer2D, Rect } from '@dirtytalk/spatial'; const stubRenderer: Renderer2D = { beginFrame(regions: readonly Rect[]): void { console.log('beginFrame', regions); }, endFrame(): void { console.log('endFrame'); }, }; ``` `beginFrame` receives the frame's _individual_ damage rects (never an empty array). `endFrame` takes no arguments and is where a real renderer would submit or flush. ### Step 2 — a node that damages itself Subclass `SceneNode` and implement the abstract `paint` method. When the node's visible state changes, call the protected `markDamaged('paint')` to tell the root that this node's rectangle needs repainting. ```ts import { SceneNode } from '@dirtytalk/spatial'; class Button extends SceneNode { private _label = ''; get label(): string { return this._label; } setValue(label: string): void { if (this._label === label) return; // skip redundant work this._label = label; this.markDamaged('paint'); // visual-only change -> paint stage only } paint(_layer: unknown): void { // Draw the button into the renderer-provided layer. // The spatial package never inspects `_layer` — it's yours to define. } } ``` Two things to notice. First, the early-return guard: if the label did not change, do not emit damage. Second, `markDamaged('paint')` with no rect defaults to the node's own `bounds`, which is exactly the region a label change dirties. ### Step 3 — construct the root with a scheduler `SceneRoot` owns the dirty channel, the scheduler, and the renderer. For a predictable, synchronous teaching loop, pass a `SyncScheduler` — every `markDamaged` flushes immediately, so one mutation produces one frame. ```ts import { SceneRoot } from '@dirtytalk/spatial'; import { SyncScheduler } from '@dirtytalk/engine'; const root = new SceneRoot(stubRenderer, { scheduler: new SyncScheduler(), bounds: { x: 0, y: 0, w: 800, h: 600 }, }); ``` The `bounds` you pass become the root's _interest region_: any damage overlapping that rectangle triggers a frame. ### Step 4 — set bounds and adopt the child Give the button a footprint and attach it to the tree with `adoptChild`. Because the root is connected, the adoption emits a one-time full-bounds `paint` for the newly visible child. ```ts const btn = new Button({ bounds: { x: 10, y: 10, w: 120, h: 40 } }); root.adoptChild(btn); // => beginFrame [ { x: 10, y: 10, w: 120, h: 40 } ] // => endFrame ``` ### Step 5 — mutate and observe the painted region Now change the label. The node damages its own bounds, the channel flushes synchronously, and the renderer is handed exactly that rectangle. ```ts btn.setValue('Click me'); // => beginFrame [ { x: 10, y: 10, w: 120, h: 40 } ] // => endFrame btn.setValue('Click me'); // no change -> no damage -> no frame // (nothing logged) ``` The second call logs nothing: the guard in `setValue` short-circuits, so the channel never marks, and an empty flush fires no callback. This is the payoff in miniature — work is proportional to what actually changed. :::tip[Scheduler choice changes everything about coalescing] `SyncScheduler` flushes on every `mark`, so three mutations produce three frames. Swap in `ManualScheduler` (flush on `pump()`), `MicrotaskScheduler`, or the default `RAFScheduler`, and multiple mutations before the flush coalesce into one frame. See [Concepts](/dirtytalk/spatial/concepts) and the engine's [scheduler model](/dirtytalk/engine/concepts). ::: ### The full file ```ts import { SceneNode, SceneRoot } from '@dirtytalk/spatial'; import type { Renderer2D, Rect } from '@dirtytalk/spatial'; import { SyncScheduler } from '@dirtytalk/engine'; const stubRenderer: Renderer2D = { beginFrame(regions: readonly Rect[]): void { console.log('beginFrame', regions); }, endFrame(): void { console.log('endFrame'); }, }; class Button extends SceneNode { private _label = ''; get label(): string { return this._label; } setValue(label: string): void { if (this._label === label) return; this._label = label; this.markDamaged('paint'); } paint(_layer: unknown): void { // draw with _layer } } const root = new SceneRoot(stubRenderer, { scheduler: new SyncScheduler(), bounds: { x: 0, y: 0, w: 800, h: 600 }, }); const btn = new Button({ bounds: { x: 10, y: 10, w: 120, h: 40 } }); root.adoptChild(btn); btn.setValue('Click me'); ``` ## Wiring up pointer input The scene graph does not know about the DOM. To drive it from real input, construct a `PointerRouter` over the root and translate each DOM `PointerEvent` into a plain `SpatialPointerEvent` at the boundary. ```ts import { PointerRouter } from '@dirtytalk/spatial'; import type { SpatialPointerEvent } from '@dirtytalk/spatial'; const router = new PointerRouter(root); canvas.addEventListener('pointerdown', (e) => { const spatialEvent: SpatialPointerEvent = { type: 'down', x: e.offsetX, y: e.offsetY, buttons: e.buttons, pointerId: e.pointerId, }; const hit = router.dispatch(spatialEvent); // `hit` is the topmost SceneNode at (x, y), or null. }); ``` `dispatch` returns the node that received the event (or `null` if nothing was hit). A `down` _captures_ the hit node for that `pointerId`: subsequent `move`/`up`/`cancel` events for the same pointer are delivered to the captured node even if the pointer has drifted off its bounds — exactly the drag behaviour you want. Route every pointer phase through the same `dispatch`. ```ts for (const [domType, spatialType] of [ ['pointerdown', 'down'], ['pointermove', 'move'], ['pointerup', 'up'], ['pointercancel', 'cancel'], ] as const) { canvas.addEventListener(domType, (e) => { router.dispatch({ type: spatialType, x: e.offsetX, y: e.offsetY, buttons: e.buttons, pointerId: e.pointerId, }); }); } ``` To react to a pointer, give the node a handler method. Any of `onPointerDown`, `onPointerMove`, `onPointerUp`, `onPointerCancel` is optional; the router optional-chains them, so a node without a handler is still a valid hit target and simply receives no callback. ```ts class HoverButton extends SceneNode { private _hovered = false; paint(_layer: unknown): void {} onPointerMove(_e: SpatialPointerEvent): void { if (this._hovered) return; this._hovered = true; this.markDamaged('paint'); // hover repaints, like any other state change } } ``` Hover is just a `move` dispatch; the node decides whether the change is worth a repaint by calling `markDamaged`. ## When to reach for this package Use `@dirtytalk/spatial` when you have a 2D scene where repainting everything on every change is too expensive, and you want a principled invalidation model rather than ad-hoc dirty flags. Good fits: - A canvas/WebGPU UI with many widgets where most frames touch a small area. - A chart or plot surface where data updates should re-bin and re-layout, but a hover should only repaint. - Any retained-mode 2D tree that needs ordered data → layout → paint stages keyed off _what_ changed. Reach for something else when you only ever full-frame repaint (then you do not need damage tracking), when you need a full GPU renderer out of the box (this ships only the contract), or when you want auto-tracked reactive reads — this is a _declared-damage_ model, not a dependency graph. The sibling [`@dirtytalk/structural`](/dirtytalk/structural/concepts) package applies the same engine to state-shaped (path-set) domains. ## See also - [Concepts](/dirtytalk/spatial/concepts) — the damage model, the three-stage pipeline, and pointer routing explained. - [API Reference](/dirtytalk/spatial/api-reference) — every export, signature, and option. - [Engine: Concepts](/dirtytalk/engine/concepts) — the `Space` algebra, schedulers, and `DirtyChannel` that power the root. - [DirtyTalk overview](/dirtytalk/) — the shared thesis across the three packages. --- # API Reference > The complete export-by-export reference for @dirtytalk/structural — PathInterner, the PathSet algebra, trackRender, the diff helpers, StructuralContainer, and useStructural. Source: /dirtytalk/structural/api-reference/ The complete, export-by-export reference for `@dirtytalk/structural`. The package has two entry points: the main module `@dirtytalk/structural` (core, no React) and the React adapter `@dirtytalk/structural/react`. For the concepts behind these APIs, read [Concepts](/dirtytalk/structural/concepts); for a guided tour, [Getting Started](/dirtytalk/structural/getting-started). ## Module map | Subpath | Exports | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `@dirtytalk/structural` | `PathId`, `ConsumerId` (types); `PathInterner`; `ALL_PATHS`, `emptyPathSet`, `pathSetUnion`, `pathSetEquals`, `PathSetSpace`, `PathSet`, `AllPaths`; `trackRender`, `TrackResult`; `diffAlongSkeleton`, `pathsFromPatch`, `changedPathsFromPatch`, `getAt`; `StructuralContainer`, `StructuralContainerOptions`, `DeepPartial` | | `@dirtytalk/structural/react` | `useStructural` (the `UseStructuralOptions` and `UseStructuralResult` interfaces are declared alongside the hook and describe its parameter and return shapes) | :::caution[Schedulers are not re-exported] `SyncScheduler`, `ManualScheduler`, `MicrotaskScheduler`, and `RAFScheduler` are **not** exported from `@dirtytalk/structural`. Import them from [`@dirtytalk/engine`](/dirtytalk/engine/api-reference). The container takes a scheduler via `StructuralContainerOptions.scheduler`, not as a positional argument. ::: ## Type aliases ```ts /** An interned identifier for a path through state. Stable per Container class. */ export type PathId = number; /** Opaque consumer identifier. */ export type ConsumerId = string | symbol; ``` `PathId` is the integer a `PathInterner` assigns to a dotted path string. `ConsumerId` identifies a registered consumer in a container's registry (the React hook uses the string from `useId()`). ## `PathInterner` A bidirectional string ↔ `PathId` table. IDs are assigned monotonically from `0` in insertion order. One interner is shared per container _class_, but the class is standalone and directly instantiable. ```ts export class PathInterner { intern(path: string): PathId; lookup(id: PathId): string; get size(): number; } ``` ### `intern(path)` | | | | --------- | ------------------------------ | | Signature | `intern(path: string): PathId` | | Returns | The `PathId` for `path` | Idempotent. Returns the existing ID if `path` was already interned; otherwise assigns the next sequential ID (`0, 1, 2, …`) and returns it. The same string always maps to the same ID. ### `lookup(id)` | | | | --------- | ------------------------------------------------------------ | | Signature | `lookup(id: PathId): string` | | Returns | The dotted path string for `id` | | Throws | `RangeError` if `id < 0`, is not an integer, or `id >= size` | Reverse lookup. The error message is `PathInterner.lookup: unknown PathId ${id} (size=${size})`. ### `get size()` | | | | --------- | ------------------------------ | | Signature | `get size(): number` | | Returns | Count of unique interned paths | Re-interning an already-seen string does not increase `size`. ```ts import { PathInterner } from '@dirtytalk/structural'; const interner = new PathInterner(); const a = interner.intern('user.name'); // 0 const b = interner.intern('user.email'); // 1 interner.intern('user.name'); // 0 (idempotent) interner.lookup(a); // 'user.name' interner.size; // 2 interner.lookup(99); // throws RangeError ``` Independent instances have independent namespaces — both start at ID `0` for the same string. ## PathSet algebra ### `ALL_PATHS` and types ```ts export const ALL_PATHS: unique symbol; // Symbol.for('@dirtytalk/structural/ALL_PATHS') export type AllPaths = typeof ALL_PATHS; export type PathSet = Set | AllPaths; ``` `ALL_PATHS` is a registered sentinel symbol meaning "every possible path." Because it is created with `Symbol.for`, its identity is stable across module realms sharing the registry. A `PathSet` is either a concrete `Set` or `ALL_PATHS`. ### `emptyPathSet()` | | | | --------- | ----------------------------------------------- | | Signature | `emptyPathSet(): PathSet` | | Returns | A fresh empty `Set` (never `ALL_PATHS`) | Allocates a new empty set on every call. ### `pathSetUnion(a, b)` | | | | --------- | ---------------------------------------------------- | | Signature | `pathSetUnion(a: PathSet, b: PathSet): PathSet` | | Returns | The union; `ALL_PATHS` if either side is `ALL_PATHS` | Pure. When both are Sets, allocates a new `Set` copy of `a` and adds all of `b`. Never mutates its inputs. `pathSetUnion(emptyPathSet(), r)` is a fresh set equal by value to `r` (not the same reference). ### `pathSetEquals(a, b)` | | | | --------- | ----------------------------------------------------------------------------- | | Signature | `pathSetEquals(a: PathSet, b: PathSet): boolean` | | Returns | `true` iff both are `ALL_PATHS`, or both Sets of equal size with the same IDs | Order-independent. `ALL_PATHS` is never equal to any Set, including an empty one. ### `PathSetSpace` ```ts export const PathSetSpace: Space; ``` The `Space` implementation consumed by the engine's `DirtyChannel`. Its members: | Member | Behaviour | | ----------------------------- | ---------------------------------------------------------------------- | | `empty()` | `emptyPathSet()` | | `isEmpty(r)` | `r !== ALL_PATHS && r.size === 0` (so `isEmpty(ALL_PATHS)` is `false`) | | `union(a, b)` | `pathSetUnion(a, b)` | | `intersects(interest, dirty)` | See below | `intersects` semantics (load-bearing for wake-up decisions): | `interest` | `dirty` | Result | | ----------- | ----------- | --------------------------------------------------------------------------------------------- | | `ALL_PATHS` | `ALL_PATHS` | `true` | | `ALL_PATHS` | Set | `true` iff `dirty` is non-empty | | Set | `ALL_PATHS` | `true` iff `interest` is non-empty | | Set | Set | iterates the smaller set, looks up in the larger; `true` on the first shared ID, else `false` | A consequence: `intersects(emptyPathSet(), ALL_PATHS)` is `false` — an empty interest never wakes. ```ts import { ALL_PATHS, emptyPathSet, pathSetUnion, pathSetEquals, PathSetSpace, } from '@dirtytalk/structural'; const a = new Set([0, 1]); const b = new Set([1, 2]); pathSetUnion(a, b); // Set { 0, 1, 2 } (new set; a and b untouched) pathSetEquals(a, new Set([1, 0])); // true PathSetSpace.intersects(a, b); // true (share 1) PathSetSpace.intersects(emptyPathSet(), ALL_PATHS); // false ``` ## `trackRender` ```ts export interface TrackResult { value: S; // the recording Proxy (or raw value for non-objects) paths: PathSet; // ALWAYS a Set, NEVER ALL_PATHS } export const trackRender: ( state: S, interner: PathInterner, ) => TrackResult; ``` | Param | Type | Meaning | | ---------- | -------------- | ----------------------------------------- | | `state` | `S` | The state to wrap and record reads on | | `interner` | `PathInterner` | Interner used to assign IDs to read paths | Returns `{ value, paths }`. `value` is a recording `Proxy` (or the raw value if `state` is not a wrappable object). `paths` is a **live** `Set` that is empty on return and **grows as you read properties off `value`** — it is mutated after `trackRender` returns. `paths` is never `ALL_PATHS`. Recording rules (exact): - **Non-object short-circuit.** If `state` is a primitive, `null`, or `undefined`, returns `{ value: state, paths: }` with no proxy. - **Own, non-symbol reads only.** Symbol keys and inherited/prototype properties never record. Re-reads do not re-record. Conditional reads record only the taken branch. - **Leaf-only recording.** Reading `a.b.c` records `a.b.c` and drops the immediate parent (`a.b`) as it descends, yielding sibling-leaf isolation. - **Primitives, `null`, `undefined` returned as-is** — but reading the key still records that field's path. - **Nested plain objects/arrays** return child proxies recording into the same `paths` set, cached per target in a per-call `WeakMap` (so `value.user === value.user` within one render; independent across calls). - **Iteration coarsens.** Array methods (`for..of`, `.map`, `.find`, `.reduce`, …) record the entry path only, not per-index paths or `.length`; their callbacks receive raw values. Direct index access records the leaf (`value.items[2].name` → `items.2.name`). - **Leaf collection types** (`Map`, `Set`, `Date`, class instances) are not recursed; the field's path is recorded and the raw value returned. Wrappable = arrays, or objects whose prototype is `Object.prototype` or `null`. - **Own methods on non-array objects are bound to the proxy** so internal `this.x` reads keep recording when invoked. Reading a method without invoking does not record. ```ts import { PathInterner, trackRender } from '@dirtytalk/structural'; const interner = new PathInterner(); const { value, paths } = trackRender( { user: { name: 'a' }, items: [1, 2, 3] }, interner, ); void value.user.name; // records "user.name" (drops "user") value.items.reduce((s, n) => s + n, 0); // records "items" only — not items.0/.length // `paths` is now a Set containing the IDs for "user.name" and "items". ``` ## Diff helpers ```ts export const getAt: (state: unknown, path: string) => unknown; export const diffAlongSkeleton: ( prev: S, next: S, skeleton: PathSet, interner: PathInterner, equalsAt?: (pathId: PathId, prev: unknown, next: unknown) => boolean, ) => PathSet; export const pathsFromPatch: ( patch: Partial, interner: PathInterner, basePath?: string, // default '', internal recursion seed; callers omit ) => PathSet; export const changedPathsFromPatch: ( prev: S, next: S, patch: Partial, interner: PathInterner, equalsAt?: (pathId: PathId, prev: unknown, next: unknown) => boolean, ) => PathSet; ``` ### `getAt(state, path)` Reads the value at a dot-separated `path` (`""`, `"a"`, `"user.email"`, `"items.5.name"` — numeric segments index arrays via bracket access). An empty path returns `state` itself. Returns `undefined` for any missing, `null`, or primitive intermediate — **never throws**. Only own-property bracket access; no extra prototype walking. ### `diffAlongSkeleton(prev, next, skeleton, interner, equalsAt?)` Walks each `PathId` in `skeleton`, reads the value at its path in `prev` and `next` via `getAt`, and includes the ID iff the values are not equal under `equalsAt` (default `Object.is`). - `skeleton === ALL_PATHS` short-circuits to `ALL_PATHS`. - An empty skeleton short-circuits to a fresh empty set, regardless of states. - Pure — does not mutate `prev`, `next`, or `skeleton`. - With the default `Object.is`: `NaN` compares equal; shared sub-tree references compare equal (no entry). An intermediate path (e.g. `user`) _is_ flagged if the outer object got a new reference, even when its leaves are unchanged. - `equalsAt` is invoked as `(pathId, prevValue, nextValue)` — the per-path custom-equality seam. ```ts import { PathInterner, diffAlongSkeleton } from '@dirtytalk/structural'; const interner = new PathInterner(); const skeleton = new Set([ interner.intern('user.name'), interner.intern('user.email'), ]); const prev = { user: { name: 'Ada', email: 'a@x.io' } }; const next = { user: { name: 'Grace', email: 'a@x.io' } }; const dirty = diffAlongSkeleton(prev, next, skeleton, interner) as Set; dirty.has(interner.intern('user.name')); // true dirty.has(interner.intern('user.email')); // false ``` ### `pathsFromPatch(patch, interner, basePath?)` Flattens a patch tree into a `PathSet` of interned dotted paths with **tree-pulses-up** semantics: each plain-object branch contributes its own path _and_ recurses. `{ user: { email: 'x' } }` records both `"user"` and `"user.email"`. Leaves — arrays, primitives, `null`, `undefined`, class instances, `Date`, `Map`, `Set` — record their path and stop (arrays are atomic). An empty patch yields an empty set with nothing interned. Does not mutate input. `basePath` is an internal recursion seed; callers omit it. :::note[Shape-based, not value-based] `pathsFromPatch` is exported, but the container marks with `changedPathsFromPatch` instead. Use `pathsFromPatch` only when you want shape-based marking without value comparison. ::: ### `changedPathsFromPatch(prev, next, patch, interner, equalsAt?)` Like `pathsFromPatch` but **value-filtered**: a path is included only when its value actually changed. It walks the same plain-object branches and pulses up, but compares `prev[key]` vs `next[key]` at each step (threading the parallel subtrees down — equivalent to `getAt` but without re-walking from the root). It **prunes recursion** into unchanged branches: a branch whose reference is `Object.is`-equal (and, by the immutable-update invariant, its entire subtree) is skipped. This makes patch-marking precise and skeleton-independent, so raw channel subscribers wake correctly while over-spread patches do not over-wake siblings. `equalsAt` is the same `(pathId, prev, next)` seam; default `Object.is`. Returns an empty set when nothing changed. Example outcomes: - Over-spread `{ user: { name: 'Grace', email: 'a@x.io' } }` where only `name` changed → `['user', 'user.name']`. - Swapping a nested object → `['user', 'user.address', 'user.address.city']`. - A custom equality treating equal-content arrays as equal → `[]`. ## `StructuralContainer` Abstract base class. Owns a piece of state `S`, a `DirtyChannel`, and a consumer registry. Subclass it; instances of the same subclass share one `PathInterner` (keyed by constructor). ### Options and types ```ts export interface StructuralContainerOptions { /** Scheduler for the underlying DirtyChannel. * Default: a fresh MicrotaskScheduler per instance. Tests/SSR should pass SyncScheduler. */ scheduler?: Scheduler; /** Per-path-pattern equality override. Keys are EXACT dotted path strings * (interned at construction). Glob/pattern matching is a follow-up — not supported. */ equality?: ReadonlyMap boolean>; } export type DeepPartial = T extends ReadonlyArray ? ReadonlyArray> : T extends Date | Map | Set | RegExp ? T : T extends object ? { [K in keyof T]?: DeepPartial } : T; ``` `DeepPartial` recurses plain-object branches so nested patches type-check without casts; arrays become `ReadonlyArray>` (matching runtime: arrays are leaves, not per-index expandable); `Date | Map | Set | RegExp` pass through whole; primitives and functions are unchanged. ### Constructor ```ts constructor(initial: S, options?: StructuralContainerOptions) ``` | Param | Type | Default | Meaning | | ------------------- | ---------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `initial` | `S` | — | The starting state | | `options.scheduler` | `Scheduler` | `new MicrotaskScheduler()` (fresh per instance) | The scheduler for the underlying channel. Pass `SyncScheduler` for synchronous tests/SSR. | | `options.equality` | `ReadonlyMap boolean>` | `undefined` | Exact dotted-path → equality fn map; each key is interned at construction into an internal `Map` | ### Static ```ts static getInternerFor(ctor: object): PathInterner ``` Returns the lazily-created `PathInterner` for a constructor, creating one on first call. Backed by a private static `WeakMap` so constructors can be garbage-collected once all instances are gone. Same constructor → same interner; distinct constructors → distinct interners, even with identical state shapes. Path IDs never bleed across subclasses. ### Read accessors | Accessor | Type | Returns | | --------------------- | ----------------------- | ------------------------------------------------------ | | `get state()` | `S` | Current state. Immutable — do not mutate in place. | | `get interner()` | `PathInterner` | `StructuralContainer.getInternerFor(this.constructor)` | | `get channel()` | `DirtyChannel` | The underlying engine channel | | `get consumerCount()` | `number` | Number of registered consumers (registry size) | ### Mutations ```ts emit(next: S): void patch(partial: DeepPartial): void update(fn: (state: S) => S): void ``` #### `emit(next)` Replaces state with `next`. - **Reference-equal short-circuit:** if `Object.is(state, next)`, a complete no-op (no mark, no notify). - Sets the new state first. - **Single-consumer skip:** if `consumerCount <= 1` (0 or 1 registered consumers), marks `ALL_PATHS` instead of diffing — the sole consumer (if any) wakes regardless of whether its interest overlaps the change. - Otherwise (≥2 consumers): `diffAlongSkeleton(prev, next, skeleton, interner, equalsFn)` marks only observed-skeleton paths that actually changed. - Then marks the channel. #### `patch(partial)` Shallow-or-deep merge of a `DeepPartial`. - **Empty patch is a no-op** (`Object.keys(partial).length === 0`). - Computes `next` via an internal `deepMerge` that walks plain-object branches only; arrays, class instances, `Date`, `Map`, `Set`, primitives, `null`, `undefined` replace their slot atomically. It returns the **original `prev` reference when nothing actually changed** and keeps untouched subtree references stable. - If `Object.is(prev, next)` (no-op merge), returns without marking. - Applies the new state **before** marking, so consumers reading `state` inside the dirty callback see the new value. - Marks via `changedPathsFromPatch` (value-filtered, skeleton-independent). `patch` ignores the skeleton and always value-diffs, regardless of consumer count. #### `update(fn)` Sugar for `this.emit(fn(this.state))`. Same semantics as `emit`, including the single-consumer skip and the reference-equal no-op. :::caution[No in-place mutation] There is no in-place mutation primitive. Mutating `state` directly bypasses change tracking silently. All updates must be immutable replacements via `emit`/`patch`/`update`. ::: ### Subscriptions and consumer registry ```ts subscribe(interest: () => PathSet, cb: (dirty: PathSet) => void): () => void registerConsumerPaths(id: ConsumerId, paths: PathSet): void unregisterConsumer(id: ConsumerId): void ``` #### `subscribe(interest, cb)` Pass-through to `channel.subscribe`. Returns an unsubscribe function. For devtools, plugins, and manual subscribers that bypass the tracker registry. These subscribers do **not** affect the skeleton or `consumerCount`. `interest` is a thunk evaluated lazily once per flush per subscriber; return `ALL_PATHS` for "wants everything." ```ts import { ALL_PATHS } from '@dirtytalk/structural'; const unsub = container.subscribe( () => ALL_PATHS, (dirty) => console.log('changed', dirty), ); // later: unsub(); ``` #### `registerConsumerPaths(id, paths)` Records a consumer's observed `PathSet`, used to build the skeleton that `emit`/`update` diff against. **Fast-path skip:** if the previous paths for `id` are `pathSetEquals` to the new ones, returns without recomputing. Otherwise stores them and recomputes the skeleton (the union of all consumer paths). Re-registering an existing `id` replaces (does not add) — `consumerCount` is keyed by `id`. #### `unregisterConsumer(id)` Removes the consumer and recomputes the skeleton only if the `id` was present. ### Equality and the skeleton - **Custom equality** (`options.equality`) is used by both `emit` (via `diffAlongSkeleton`) and `patch` (via `changedPathsFromPatch`). When configured, the per-path callback resolves the matched path's function, falling back to `Object.is` for unmatched paths. When no overrides exist, the helpers use plain `Object.is` (the fast path). A custom equality returning `true` suppresses the diff entry even for a real value change. - **The skeleton** is the union of all registered consumers' `PathSet`s, recomputed (`O(consumers × paths)`) on every registry change. It is consulted only by `emit`/`update`; `patch` ignores it. ### Full example ```ts import { StructuralContainer, ALL_PATHS } from '@dirtytalk/structural'; import { SyncScheduler } from '@dirtytalk/engine'; interface CounterState { count: number; label: string; } class Counter extends StructuralContainer { increment() { this.patch({ count: this.state.count + 1 }); } } const c = new Counter( { count: 0, label: 'counter' }, { scheduler: new SyncScheduler() }, ); const unsub = c.subscribe( () => ALL_PATHS, // plugin-style: wants everything (dirty) => console.log(c.state, dirty), ); c.increment(); // SyncScheduler → fires synchronously: { count: 1, ... } unsub(); ``` ## `useStructural` (`@dirtytalk/structural/react`) ```ts export interface UseStructuralOptions { select?: never; // reserved; no selector option is supported } export interface UseStructuralResult> { 0: S; 1: C; readonly length: 2; [Symbol.iterator](): IterableIterator; } export function useStructural>( container: C, _options?: UseStructuralOptions, ): readonly [S, C]; ``` | Param | Type | Meaning | | ----------- | ---------------------------------- | --------------------------------------- | | `container` | `C extends StructuralContainer` | The container instance to bind to | | `_options` | `UseStructuralOptions` | Reserved and currently inert (see note) | Returns `[state, container]` (a readonly tuple). `state` is a recording proxy for this render: reading fields off it during render records this component's observed paths, and only those paths trigger re-renders. Mechanics: - Consumer id from React's `useId()`. Re-renders are forced via `useReducer((x) => x + 1, 0)` — no virtual DOM; React reconciles. - Each render calls `trackRender(container.state, container.interner)` and stores the live `paths` set in a ref. `registerConsumerPaths` is intentionally **not** called in the render body (paths are empty until JSX reads the proxy; registering then would freeze an empty skeleton and silently drop wakeups). - A `useLayoutEffect` (every render, no deps) registers the now-populated path ref after render — keeping the container's skeleton in sync with what was actually read. Conditional reads adapt the skeleton automatically; no selector needed. - A `useEffect` keyed on `[container, consumerId]` re-registers paths (handling StrictMode cleanup re-runs where the render body did not re-run) and subscribes `() => pathRef.current` to force re-renders. Cleanup unsubscribes and unregisters the consumer. - StrictMode-safe: double-invoke leaves a clean registry; unmount returns `consumerCount` to 0; two components on one container get distinct consumer ids; raw `subscribe` plugins coexist with hook consumers. :::note[The options arg is inert] The `_options` argument is reserved by the `select?: never` typing — there is no selector API. Path recording replaces selectors, so passing anything has no effect today. ::: ```tsx import { useStructural } from '@dirtytalk/structural/react'; function CounterDisplay({ counter }: { counter: Counter }) { const [state, container] = useStructural(counter); // Reading state.count records "count"; a patch to "label" won't re-render this. return ; } ``` ## See also - [Structural: Getting Started](/dirtytalk/structural/getting-started) — install and first container. - [Structural: Concepts](/dirtytalk/structural/concepts) — path sets, the skeleton, the tracker, the diff model. - [Engine: API Reference](/dirtytalk/engine/api-reference) — `DirtyChannel`, `Space`, and the schedulers you inject. - [Engine: Concepts](/dirtytalk/engine/concepts) — the flush/coalescing model these mutations rely on. --- # Concepts > The model behind @dirtytalk/structural — the quadratic diffing problem, interned path IDs, the observed skeleton, the proxy recorder, and the React adapter. Source: /dirtytalk/structural/concepts/ This page explains the model behind `@dirtytalk/structural`: the diffing-cost problem it solves, how paths become interned IDs, what the _skeleton_ is, how the proxy recorder works, and how the React adapter ties it all together. Read it after [Getting Started](/dirtytalk/structural/getting-started) when you want to understand _why_ the package is shaped the way it is. ## The problem: per-consumer diffing is quadratic State containers and UI renderers both ask the same question after a mutation: _what changed, who cares, and when do we tell them?_ For structural state — objects and arrays whose consumers track named paths — the honest answer is a set of changed paths matched against each consumer's set of observed paths. The naive way to compute this is **per-consumer diffing**: for each of `N` consumers, walk the previous and next state comparing the paths that consumer observes. When many consumers share one container and observe overlapping shapes, this is roughly `N` consumers × `N` redundant tree walks, doing the same equality checks over and over. The cost grows quadratically with consumer count exactly when you least want it to — a busy container with many subscribers. The fix this package implements is **one walk plus N intersections**: 1. Maintain a single _skeleton_ — the union of every live consumer's observed paths. 2. On a mutation, do **one** diff walk bounded to that skeleton, producing a set of changed path IDs (the _dirty_ set). 3. Decide who to wake with `N` cheap **set intersections**: a consumer wakes iff its observed path set intersects the dirty set. The expensive part (the tree walk) happens once per mutation instead of once per consumer. The per-consumer part collapses to set-membership checks. The saving is proportional to `N` when consumers share a container. This is the lineage of BlaC's per-consumer path tracking, extracted and built on top of [`@dirtytalk/engine`](/dirtytalk/engine/concepts)'s `DirtyChannel`, `Space`, and `Scheduler`. ## Paths as interned IDs: `PathInterner` Comparing and unioning dotted path strings (`"user.name"`) over and over would be wasteful. So every path is **interned** into a small integer `PathId` once, and all set operations work on those integers. `PathInterner` is a bidirectional string ↔ ID table. `intern(path)` returns a stable ID, assigning the next sequential integer (starting from `0`) the first time it sees a string and returning the same ID forever after. `lookup(id)` is the reverse. Same string, same ID — always. ```ts import { PathInterner } from '@dirtytalk/structural'; const interner = new PathInterner(); interner.intern('user.name'); // 0 interner.intern('user.email'); // 1 interner.intern('user.name'); // 0 again — idempotent interner.lookup(0); // 'user.name' interner.size; // 2 ``` ### One interner per container class IDs are only comparable within a single interner's namespace — `0` means `"user.name"` in one interner and could mean anything in another. So `StructuralContainer` keeps **one interner per subclass**, keyed by constructor in a `WeakMap` (`StructuralContainer.getInternerFor`). Every instance of the same subclass shares it, so their path IDs line up and can be unioned and intersected freely. Distinct subclasses get distinct interners even if their state shapes are identical — IDs never bleed across classes. The `WeakMap` lets a constructor (and its interner) be garbage-collected once all instances are gone. ## Path sets and the `ALL_PATHS` sentinel A `PathSet` is the unit of "interest" and "dirtiness." It is either a concrete `Set` or the special `ALL_PATHS` sentinel. `ALL_PATHS` is a registered symbol (`Symbol.for('@dirtytalk/structural/ALL_PATHS')`), so its identity is stable even across module realms that share the symbol registry. It means "every possible path." Its key property is intersection behaviour: an `ALL_PATHS` interest intersects any non-empty dirty set, and any non-empty interest intersects an `ALL_PATHS` dirty set. (An _empty_ interest never wakes, even against `ALL_PATHS` — empty means "I care about nothing.") `ALL_PATHS` shows up in two places: - **Source-side signalling.** When the container can't or won't compute a precise dirty set, it marks `ALL_PATHS` to wake everyone (see the single-consumer skip below). - **Opt-in blanket interest.** A devtools panel or plugin can `subscribe(() => ALL_PATHS, …)` to hear about every change. The set algebra lives in a handful of pure functions: | Function | Meaning | | --------------------- | --------------------------------------------------------------------------------------------------------------- | | `emptyPathSet()` | A fresh empty `Set` (never `ALL_PATHS`). | | `pathSetUnion(a, b)` | Pure union; `ALL_PATHS` if either side is `ALL_PATHS`. Does not mutate inputs. | | `pathSetEquals(a, b)` | Value equality; both `ALL_PATHS`, or two Sets with the same members. | | `PathSetSpace` | The `Space` the engine's `DirtyChannel` consumes (provides `empty`, `isEmpty`, `union`, `intersects`). | These satisfy the engine's [`Space` contract](/dirtytalk/engine/concepts): `union(empty(), r)` equals `r` by value, `intersects(empty(), _)` is `false`, and every operation is pure. ### The single-consumer skip The skeleton-based diff only pays off when there are multiple consumers to share the walk across. With **0 or 1** registered consumers, `emit` and `update` skip the diff entirely and just mark `ALL_PATHS` — the sole consumer (if any) wakes regardless of whether the change touched a path it observes. Cheaper than building a dirty set when there is nobody to discriminate between. Fine-grained isolation begins at two or more registered consumers. Note that `patch` does **not** take this shortcut: it always value-diffs the patch shape (more on this below), independent of consumer count. ## The observed skeleton The **skeleton** is the union of all currently registered consumers' path sets. It is the bound on the diff walk: `emit`/`update` only check whether the paths _somebody_ observes have changed, ignoring fields no live consumer reads. It is maintained through the consumer registry: - `registerConsumerPaths(id, paths)` records (or replaces) one consumer's observed set, then recomputes the skeleton as the union of all registered sets. There is a fast-path skip: if the new set is `pathSetEquals` to the previously stored one, nothing is recomputed. - `unregisterConsumer(id)` drops a consumer and recomputes the skeleton if it was present. Recompute is `O(consumers × paths)` — a full re-union on every registry change (incremental update is a future optimisation). The skeleton is consulted **only** by `emit`/`update`. `patch` ignores it (it value-diffs the patch directly), and raw `subscribe` callers never touch the registry — so a path observed only by a raw subscriber is invisible to `emit`'s skeleton diff, though `patch` will still wake it via value-filtered marking. ## `trackRender`: the Proxy recorder How does a consumer declare which paths it observes without writing a selector? It just _reads_ the state, and a recording proxy notes what was read. `trackRender(state, interner)` wraps `state` in a `Proxy` and returns `{ value, paths }`. `value` is the proxy; `paths` is a **live** `Set` that grows as you read properties off `value`. It is empty until you read, and it is mutated _after_ `trackRender` returns. `paths` is always a concrete `Set` — never `ALL_PATHS`. The recording rules are deliberate, and they are what make sibling isolation work: - **Leaf-only (maximal) recording.** Reading `a.b.c` records only `a.b.c`. As each deeper read descends, the immediate parent path is dropped from the set. So a consumer reading `user.name` records exactly `user.name` — not `user`. When an immutable update replaces the whole `user` object because a _sibling_ (`user.address`) changed, the consumer of `user.name` does **not** wake: its recorded leaf still resolves to the same value. A consumer that reads the whole `user` object (no deeper key) keeps `user` as its leaf and wakes on any change to it. - **Own, non-symbol reads only.** Symbol keys (`Symbol.iterator`, `Symbol.toStringTag`) and inherited/prototype properties never record. Re-reading a path does not re-record (idempotent via the Set). Conditional reads record only the branch actually taken. - **Primitives pass through.** Reading a field whose value is a primitive, `null`, or `undefined` returns it as-is — but reading the key still records that field's path. - **Nested plain objects and arrays return child proxies** recording into the same `paths` set, cached per target in a per-call `WeakMap` so `value.user === value.user` within one render. The cache dies with the call frame, so each `trackRender` call records independently. - **Iteration coarsens.** `for..of`, `.map`, `.find`, `.reduce` and friends on an array record the **entry path** (e.g. `items`) but **not** per-index paths (`items.0`) or `items.length`. Array prototype methods are bound to the raw target so their internal index reads bypass the proxy, and callbacks receive raw (un-proxied) values. Direct index access _does_ record the leaf — `value.items[2].name` records `items.2.name`. - **Leaf collection types are not recursed.** `Map`, `Set`, `Date`, and class instances are returned raw (wrapping them would rebind receiver-checked built-ins like `Map.prototype.get` and throw). Reading the field records the field's path only; a reference change to the whole value still wakes the consumer. The wrappable predicate is: arrays, or objects whose prototype is `Object.prototype` or `null`. - **Own methods on non-array objects are bound to the proxy** so their internal `this.x` reads keep recording when invoked. Reading a method without calling it does not record (methods live on the prototype). This is why the React hook needs no selector: the JSX _is_ the dependency declaration. ## The diff helpers and when each applies Two questions, two helpers. "Did the paths somebody observes change?" is answered by walking the skeleton. "Which paths did this patch actually change?" is answered by walking the patch. `getAt` is the shared path-read primitive. | Helper | Walks | Used by | Question it answers | | --------------------------------------------------------------- | --------------- | ----------------------------------- | ------------------------------------------------------------- | | `getAt(state, path)` | one dotted path | the others, internally | What value lives at this path? (never throws) | | `diffAlongSkeleton(prev, next, skeleton, interner, equalsAt?)` | the skeleton | `emit` / `update` | Which observed paths changed value between `prev` and `next`? | | `pathsFromPatch(patch, interner)` | the patch shape | exported, not used by the container | Which paths does this patch _touch_ (regardless of value)? | | `changedPathsFromPatch(prev, next, patch, interner, equalsAt?)` | the patch shape | `patch` | Which patched paths actually changed value? | - **`diffAlongSkeleton`** reads each skeleton path in both states and includes it iff the values differ under `equalsAt` (default `Object.is`). It short-circuits: `ALL_PATHS` skeleton returns `ALL_PATHS`, empty skeleton returns empty. It is pure. Because the default is reference equality, an intermediate path like `user` _is_ flagged if the outer object got a new reference even when its leaves are unchanged — which is correct for a consumer that observes `user` as a leaf. - **`pathsFromPatch`** flattens a patch tree to interned paths with **tree-pulses-up** semantics: each plain-object branch contributes its own path _and_ recurses, so `{ user: { email: 'x' } }` yields both `"user"` and `"user.email"`. Arrays, primitives, class instances, `Date`, `Map`, `Set` are leaves (recorded then stopped). It does not compare values — it is shape-based. The container does not use it for marking, but it is exported for callers who want shape-based marking. - **`changedPathsFromPatch`** is `pathsFromPatch` with a value filter: it pulses up the same branches but compares `prev[key]` vs `next[key]` at each step and skips paths whose value did not change. Crucially it **prunes recursion** into unchanged branches — relying on the invariant that an unchanged subtree keeps its reference. This makes patch marking precise and skeleton-independent, so a raw channel subscriber wakes correctly while an over-spread patch (spreading a whole parent when one field changed) does not over-wake siblings. For example, an over-spread patch `{ user: { name: 'Grace', email: 'a@x.io' } }` where only `name` actually changed marks `['user', 'user.name']` — not `user.email`. ## `emit` vs `patch` vs `update`, and the immutable-replacement requirement All three mutators install a **new** immutable value and then mark the channel. None of them mutate state in place, and there is no primitive that does. - **`emit(next)`** replaces the whole state with `next`. It short-circuits to a no-op if `Object.is(state, next)`. Otherwise it sets the new state, then computes the dirty set: `ALL_PATHS` under the single-consumer skip, else `diffAlongSkeleton` against the observed skeleton. Then it marks the channel. - **`patch(partial)`** deep-merges a `DeepPartial` into the current state. An empty patch is a no-op. The merge walks plain-object branches only; arrays, class instances, `Date`, `Map`, `Set`, and primitives replace their slot atomically. If the merge produces no actual change it returns the original reference and the whole call is a no-op. State is applied **before** marking (so consumers reading `state` inside the dirty callback see the new value), and marking uses `changedPathsFromPatch` — value-filtered and skeleton-independent. - **`update(fn)`** is sugar for `this.emit(fn(this.state))`. Same semantics as `emit`, including the single-consumer skip and the reference-equal no-op. :::caution[In-place mutation is invisible] Because change detection relies on reference comparison (`Object.is`) and on unchanged subtrees keeping their references, **mutating `state` in place is silently invisible** to tracking. `container.state.items.push(x)` will not wake anyone. Always replace immutably: `patch({ ... })`, or `update(s => ({ ...s, items: [...s.items, x] }))`. ::: ## The React adapter `useStructural(container)` connects a component to a container with per-render path tracking. The mechanics: - It derives a stable consumer id from React's `useId()`, and forces re-renders with `useReducer((x) => x + 1, 0)` — there is no virtual DOM here; React's own reconciliation handles the update. - On each render it calls `trackRender(container.state, container.interner)` and stashes the live `paths` set in a ref. It deliberately does **not** call `registerConsumerPaths` in the render body: the proxy has not been read yet, so `paths` is empty — registering it then would freeze an empty skeleton and silently drop wakeups. - A `useLayoutEffect` with no deps runs after every render and registers the now-populated path set. Conditional reads therefore reshape the skeleton automatically, render to render — no selector to keep in sync. - A `useEffect` keyed on `[container, consumerId]` re-registers the paths (covering StrictMode cleanup cycles where the render body did not re-run) and subscribes `() => pathRef.current` to the channel, forcing a re-render when the interest intersects the dirty set. Cleanup unsubscribes and unregisters the consumer. The result is StrictMode-safe: double-invocation leaves a clean registry, unmount returns `consumerCount` to zero, and two components on one container get distinct consumer ids. Raw `subscribe` plugins coexist with hook consumers. The `options` argument is reserved (`select?: never`) and currently inert — path recording replaces selectors, so there is no selector API to configure. ## Lineage to BlaC This package is the per-consumer path tracking from BlaC, extracted and rebuilt as a standalone, engine-backed container. If you know BlaC's [tracked state](/core/tracked) and [`useBloc` dependency tracking](/react/dependency-tracking), the model maps cleanly: read the fields you need during render, re-render only when those fields change. For the BlaC mental model in full, see [BlaC: Mental Model](/guide/mental-model). `@dirtytalk/structural` is the focused, framework-light distillation of that idea on top of `@dirtytalk/engine`. ## What it is not - **Not a computed/derived-value system.** There is no dependency graph and no auto-tracked computeds. Build derived values in a layer above the container. - **Not an effect system.** The unsubscribe function returned by `subscribe` is the only cleanup primitive. - **Not a mutable store.** No in-place mutation primitive exists; all updates are immutable replacements via `emit`/`patch`/`update`. - **Not a virtual DOM.** The React adapter forces a re-render via `useReducer`; reconciliation is React's job. - **Not glob/pattern equality.** The `equality` option keys are exact dotted-path strings only — pattern matching is a follow-up. - **Not a scheduler provider.** Schedulers live in `@dirtytalk/engine`; this package forces none and injects whatever you give it. ## See also - [Structural: Getting Started](/dirtytalk/structural/getting-started) — install and build your first container. - [Structural: API Reference](/dirtytalk/structural/api-reference) — exhaustive signatures for every export. - [Engine: Concepts](/dirtytalk/engine/concepts) — the `Space` algebra, schedulers, and `DirtyChannel` this package builds on. - [React: Dependency Tracking](/react/dependency-tracking) — the BlaC ancestor of this model. --- # Getting Started > Build a small state container with no framework, watch it notify subscribers on a microtask flush, then wire it into React for path-level re-render isolation. Source: /dirtytalk/structural/getting-started/ This page teaches `@dirtytalk/structural` by doing. You will build a small state container with no framework, watch it notify subscribers on a microtask flush, and then wire the same container into React so that a component re-renders only when the exact fields it read actually change. It is written for anyone who wants fine-grained, path-level change tracking for object state. ## What this package gives you `@dirtytalk/structural` is a state container for **structural data** — plain objects and arrays whose consumers care about specific named paths (`user.name`, `items`, `count`). When you mutate the state, it works out which paths actually changed and wakes only the consumers that read those paths. A component that read `state.count` will not re-render because `state.label` changed. It is built on [`@dirtytalk/engine`](/dirtytalk/engine/getting-started), which provides the dirty-tracking channel and the schedulers that decide _when_ notifications fire. The engine is a runtime dependency — you will import schedulers from it directly. ## Install `@dirtytalk/structural` depends on `@dirtytalk/engine`. Install both. React is an optional peer dependency, only needed if you use the `/react` subpath. ```bash pnpm add @dirtytalk/structural @dirtytalk/engine ``` ```bash npm install @dirtytalk/structural @dirtytalk/engine ``` ```bash yarn add @dirtytalk/structural @dirtytalk/engine ``` :::note[Engine is required] `@dirtytalk/structural` lists `@dirtytalk/engine` as a dependency, so a package manager will pull it in automatically. You still import schedulers (`SyncScheduler`, `MicrotaskScheduler`, …) from `@dirtytalk/engine` directly — they are **not** re-exported from `@dirtytalk/structural`. ::: ## A core walkthrough (no React) Let's build a container by subclassing `StructuralContainer`, drive it with `patch`/`emit`/`update`, subscribe to it through its channel, and observe a flush. ### 1. Subclass `StructuralContainer` `StructuralContainer` is abstract. You subclass it, type its state shape, and add your own methods that call the protected-by-convention mutators (`patch`, `emit`, `update`). ```ts import { StructuralContainer } from '@dirtytalk/structural'; import { SyncScheduler } from '@dirtytalk/engine'; interface CounterState { count: number; label: string; } class Counter extends StructuralContainer { increment() { // patch merges a DeepPartial into the current state. this.patch({ count: this.state.count + 1 }); } rename(label: string) { this.patch({ label }); } } // The constructor takes (initial, options). For deterministic, synchronous // behaviour (tests, SSR, this walkthrough) pass a SyncScheduler from the engine. const counter = new Counter( { count: 0, label: 'clicks' }, { scheduler: new SyncScheduler(), }, ); ``` :::caution[Schedulers come from the engine] You import `SyncScheduler` from `@dirtytalk/engine`, and you pass it as `options.scheduler` — an object, not a bare positional argument. The default scheduler (if you pass none) is a fresh `MicrotaskScheduler`, which defers and coalesces notifications onto a microtask. ::: ### 2. Subscribe through the channel using `ALL_PATHS` Every container exposes a `subscribe(interest, cb)` method that passes straight through to the underlying [`DirtyChannel`](/dirtytalk/engine/concepts). `interest` is a thunk returning the set of paths this subscriber cares about. For a devtools-style subscriber that wants to hear about _every_ change, return the `ALL_PATHS` sentinel. ```ts import { ALL_PATHS } from '@dirtytalk/structural'; const unsubscribe = counter.subscribe( () => ALL_PATHS, // "wake me on any change" (dirty) => { console.log('state is now', counter.state, 'dirty:', dirty); }, ); ``` The callback receives `dirty`, the set of path IDs that changed (or `ALL_PATHS`). Because we used a `SyncScheduler`, the callback fires synchronously the moment a mutation marks the channel. ```ts counter.increment(); // logs: state is now { count: 1, label: 'clicks' } ... counter.rename('taps'); // logs: state is now { count: 1, label: 'taps' } ... unsubscribe(); // stop listening ``` :::note[Single-consumer shortcut] With at most one registered consumer, `emit`/`update` skip diffing and mark `ALL_PATHS` — the lone consumer wakes on any change. Raw `subscribe` callers like the one above do not count toward that registry (they never call `registerConsumerPaths`), so a `subscribe` returning `ALL_PATHS` always hears every change regardless. Fine-grained, path-level isolation kicks in once there are two or more _registered_ consumers, which is exactly what the React hook does for you. ::: ### 3. Observe a microtask flush Switch to the default scheduler to see coalescing. Drop the `scheduler` option (or pass `new MicrotaskScheduler()`), and multiple mutations in one synchronous tick collapse into a single deferred notification. ```ts import { StructuralContainer, ALL_PATHS } from '@dirtytalk/structural'; const c = new Counter({ count: 0, label: 'clicks' }); // default: MicrotaskScheduler let flushes = 0; c.subscribe( () => ALL_PATHS, () => { flushes += 1; }, ); c.increment(); c.increment(); c.increment(); console.log(flushes); // 0 — nothing has flushed yet, still in this tick await Promise.resolve(); // let the microtask run console.log(flushes); // 1 — three marks coalesced into one flush ``` This is the same flush/coalescing model the engine documents in [Engine: Concepts](/dirtytalk/engine/concepts): marks accumulate via set union, a flush is requested from the scheduler, and the subscriber sees the unioned dirty region exactly once. ## A React walkthrough The React adapter lives at the `@dirtytalk/structural/react` subpath and exposes a single hook, `useStructural`. It returns `[state, container]`, where `state` is a recording proxy: reading a field off it during render registers _that path_ as this component's interest, so only changes to paths it read trigger a re-render. ```tsx import { useStructural } from '@dirtytalk/structural/react'; function CountButton({ counter }: { counter: Counter }) { const [state, container] = useStructural(counter); // Reading state.count registers the path "count" for this component. return ( ); } function LabelTag({ counter }: { counter: Counter }) { const [state] = useStructural(counter); // This component reads only state.label, so it registers "label". return {state.label}; } ``` Now the isolation pays off. With both components mounted against the same `counter` instance: - `counter.increment()` changes `count`. Only `CountButton` re-renders. `LabelTag` read `label` and stays asleep. - `counter.rename('taps')` changes `label`. Only `LabelTag` re-renders. `CountButton` stays asleep. No selector function, no manual dependency array. The hook tracks exactly the paths your JSX touched this render, and adapts automatically when conditional reads change which fields you access. Pass the container instance in however you like — props, context, or a module singleton. :::note[How the hook tracks] `useStructural` wraps `container.state` in a recording proxy each render and stores the set of paths read into a ref. After the render commits, a layout effect registers that path set with the container, keeping the container's skeleton in sync with what the component actually read. It then subscribes to the channel and forces a re-render (via `useReducer`) when an overlapping path goes dirty. It is StrictMode-safe and uses React's `useId()` for a stable per-component consumer id. See [Concepts](/dirtytalk/structural/concepts) for the full mechanism. ::: :::caution[Immutable updates only] There is no in-place mutation primitive. Mutating `state` directly (`counter.state.count++`) bypasses change tracking entirely and nobody will wake. Always go through `emit`, `patch`, or `update`, all of which install a new immutable value. ::: ## When to use this package, and how it relates to BlaC Reach for `@dirtytalk/structural` when: - You have object/array state with **many independent consumers** that each read different slices of it, and you want each consumer to re-render only when its slice changes — without writing selectors. - You want path-level change tracking that is **framework-agnostic at the core** (the container and diff helpers have no React dependency) with an optional thin React adapter. - You are building on `@dirtytalk/engine` and want a higher-level, path-aware container rather than wiring a `DirtyChannel` by hand. It is the spiritual successor to BlaC's per-consumer path tracking. If you have used BlaC's [tracked state](/core/tracked) and [dependency tracking in `useBloc`](/react/dependency-tracking), the model here will feel familiar: read what you need, re-render when it changes. `@dirtytalk/structural` distills that idea into a standalone, engine-backed package. If you want the full BlaC programming model (cubits, cross-bloc dependencies, the wider ecosystem), see the [BlaC introduction](/guide/introduction). If you want fine-grained structural tracking as a focused building block, this package is it. ## See also - [Structural: Concepts](/dirtytalk/structural/concepts) — the why and the model: path sets, the skeleton, the tracker. - [Structural: API Reference](/dirtytalk/structural/api-reference) — every export, signature, and option. - [Engine: Getting Started](/dirtytalk/engine/getting-started) — the `DirtyChannel` and schedulers this package is built on. - [Engine: Concepts](/dirtytalk/engine/concepts) — Space algebra, scheduling, and the flush model.