Concepts

The model shared by all dirtytalk packages: regions, spaces, mark-coalesce-flush, interest thunks, schedulers and re-entrancy.

View .md Edit on GitHub

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,
};
MethodMeaning
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.

  1. Mark. channel.mark(region) records that region changed. The first mark in a window schedules a flush through the scheduler.
  2. Coalesce. Further marks before the flush are merged with space.union. However many marks happen, the scheduler is asked for one flush.
  3. 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).

SchedulerFlush runs
SyncSchedulerImmediately, inside mark.
MicrotaskSchedulerAt the end of the current microtask checkpoint, via queueMicrotask.
RAFSchedulerOn the next animation frame (or after 16 ms without requestAnimationFrame).
ManualSchedulerWhen 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. PathSetSpace is a Space<PathSet>. A region is a Set<PathId> of interned state paths, or the ALL_PATHS symbol 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. RectSpace is a Space<DirtyRegion>, where DirtyRegion is readonly Damage[]. Each Damage has a rect, a kind ('paint', 'layout' or 'data') and an optional node. 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

TermDefinition
RegionA value describing part of a domain; used for both marks and interests.
SpaceThe Space<Region> algebra: empty, isEmpty, union, intersects.
MarkA call to channel.mark(region) recording that a region changed.
CoalesceMerging marks within one window into a single dirty region with union.
FlushThe scheduled step that notifies intersecting subscribers.
InterestA () => Region thunk evaluated per flush to say what a subscriber cares about.
SubscriberAn interest thunk plus a callback registered with subscribe.
SchedulerDecides when a flush runs: sync, microtask, animation frame or manual.
Dirty channelDirtyChannel, which accumulates marks and delivers them on flush.
PathSetStructural’s region: Set<PathId> or ALL_PATHS.
DamageSpatial’s region element: a rect, a kind and an optional node.