Concepts
The model shared by all dirtytalk packages: regions, spaces, mark-coalesce-flush, interest thunks, schedulers and re-entrancy.
Every dirtytalk package is the same pipeline: producers mark regions dirty, the channel coalesces them, and a scheduler flushes them to subscribers whose interest intersects the result. The packages differ only in what a region is.
Region
A Region is a value that describes a part of your domain. It is used for two things: what changed (a mark) and what a subscriber cares about (an interest). The engine never inspects a region. It only passes regions to the Space you provide.
Space
Space<Region> is the algebra over regions. It is the only interface a domain must implement.
import type { Space } from '@dirtytalk/engine';
const NumberBitsetSpace: Space<number> = {
empty: () => 0,
isEmpty: (r) => r === 0,
union: (a, b) => a | b,
intersects: (interest, dirty) => (interest & dirty) !== 0,
};
| Method | Meaning |
|---|---|
empty() | The region containing nothing. |
isEmpty(r) | Whether r contains nothing. |
union(a, b) | The region covering both a and b. |
intersects(interest, dirty) | Whether a subscriber with interest must be told about dirty. |
Contracts: union(empty(), r) equals r, intersects(empty(), _) is false, and all operations are pure.
An optional unionInto(acc, b) accumulates in place. It may mutate and return acc, but must not mutate b. DirtyChannel calls it only on an accumulator it owns, so it is an optimization that avoids O(N²) copying for bursts of marks. If absent, union is used.
See Custom spaces.
Mark, coalesce, flush
DirtyChannel<Region> runs the cycle.
- Mark.
channel.mark(region)records thatregionchanged. The first mark in a window schedules a flush through the scheduler. - Coalesce. Further marks before the flush are merged with
space.union. However many marks happen, the scheduler is asked for one flush. - Flush. The scheduler runs the flush. The channel takes the accumulated region and resets it. If it is empty, nothing is called. Otherwise each subscriber is considered in registration order.
import { DirtyChannel, ManualScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';
const BitsSpace: Space<number> = {
empty: () => 0,
isEmpty: (r) => r === 0,
union: (a, b) => a | b,
intersects: (i, d) => (i & d) !== 0,
};
const scheduler = new ManualScheduler();
const channel = new DirtyChannel(BitsSpace, scheduler);
channel.subscribe(
() => 0b011,
(dirty) => console.log('flushed with', dirty),
);
channel.mark(0b001);
channel.mark(0b010);
scheduler.pump();
// flushed with 3
channel.subscribe(interest, cb) returns an unsubscribe function. channel.dispose() cancels any pending flush and drops all subscribers. Subscribers that throw do not stop later subscribers: with one error it is rethrown after the flush, with several they are thrown as an AggregateError, and with the onError option (new DirtyChannel(space, scheduler, { onError })) errors go to that callback instead.
Interest thunks and intersection
A subscriber’s interest is a function, () => Region, not a fixed value. The channel calls it once per flush for each subscriber, so interest can change over time without re-subscribing. For each subscriber, the callback runs only if space.intersects(interest, dirty) is true. The callback receives the full coalesced dirty region, not only the part that intersects.
An interest thunk that returns null or undefined means no interest for that flush.
Schedulers
A Scheduler decides when a flush runs. Its interface is request(flush) and an optional cancel(flush).
| Scheduler | Flush runs |
|---|---|
SyncScheduler | Immediately, inside mark. |
MicrotaskScheduler | At the end of the current microtask checkpoint, via queueMicrotask. |
RAFScheduler | On the next animation frame (or after 16 ms without requestAnimationFrame). |
ManualScheduler | When you call pump(). |
MicrotaskScheduler, RAFScheduler and ManualScheduler batch the flushes of every channel sharing the instance into one drain. See Schedulers.
Re-entrancy
Marks made during a flush, for example from a subscriber callback, do not join the flush in progress. They accumulate for the next flush, which the channel schedules once the current one finishes. Subscribers added during a flush are also skipped until the next flush. A subscriber unsubscribed during a flush before its turn does not run.
import { DirtyChannel, ManualScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';
const BitsSpace: Space<number> = {
empty: () => 0,
isEmpty: (r) => r === 0,
union: (a, b) => a | b,
intersects: (i, d) => (i & d) !== 0,
};
const scheduler = new ManualScheduler();
const channel = new DirtyChannel(BitsSpace, scheduler);
channel.subscribe(
() => 0b001,
() => channel.mark(0b100),
);
channel.subscribe(
() => 0b100,
(dirty) => console.log('second flush', dirty),
);
channel.mark(0b001);
scheduler.pump(); // first flush: the 0b100 mark is queued
scheduler.pump(); // second flush
// second flush 4
Note: A subscriber that marks a region it is itself interested in is notified again on the next flush. Avoid unconditional self-marking to prevent a flush loop.
Structural and spatial are Spaces
The other packages add no new notification machinery. Each supplies a Space and builds an API around a DirtyChannel that uses it.
- Structural.
PathSetSpaceis aSpace<PathSet>. A region is aSet<PathId>of interned state paths, or theALL_PATHSsymbol meaning everything. Union merges sets, and intersection is a shared path. A consumer’s interest is the set of paths it read. See Structural. - Spatial.
RectSpaceis aSpace<DirtyRegion>, whereDirtyRegionisreadonly Damage[]. EachDamagehas arect, akind('paint','layout'or'data') and an optionalnode. Union concatenates, and a'paint'damage intersects an interest only when their rectangles overlap.'layout'and'data'damage intersect any non-empty interest. See Spatial.
Glossary
| Term | Definition |
|---|---|
| Region | A value describing part of a domain; used for both marks and interests. |
| Space | The Space<Region> algebra: empty, isEmpty, union, intersects. |
| Mark | A call to channel.mark(region) recording that a region changed. |
| Coalesce | Merging marks within one window into a single dirty region with union. |
| Flush | The scheduled step that notifies intersecting subscribers. |
| Interest | A () => Region thunk evaluated per flush to say what a subscriber cares about. |
| Subscriber | An interest thunk plus a callback registered with subscribe. |
| Scheduler | Decides when a flush runs: sync, microtask, animation frame or manual. |
| Dirty channel | DirtyChannel, which accumulates marks and delivers them on flush. |
| PathSet | Structural’s region: Set<PathId> or ALL_PATHS. |
| Damage | Spatial’s region element: a rect, a kind and an optional node. |