Custom spaces
Implement the Space<Region> contract for your own domain, with laws, worked key-set and numeric-range examples, the optional unionInto, and common pitfalls.
A Space<Region> defines what “changed” and “interested in” mean for your data. The engine ships no concrete space; this guide covers the contract and two worked implementations.
The contract
interface Space<Region> {
empty(): Region;
isEmpty(r: Region): boolean;
union(a: Region, b: Region): Region;
intersects(interest: Region, dirty: Region): boolean;
unionInto?(acc: Region, b: Region): Region;
}
| Method | Used for |
|---|---|
empty() | The region meaning “nothing”. |
isEmpty(r) | Tells the channel a flush has nothing to deliver. When the accumulated region is empty, no subscriber is consulted. |
union(a, b) | Merges two dirty regions during mark. |
intersects(interest, dirty) | Delivery predicate: does this subscriber care about this change? |
unionInto(acc, b) | Optional in-place union. See below. |
Laws
The source documents these:
union(empty(), r)equalsr.intersects(empty(), _)returnsfalse.- All operations are pure: the same inputs give the same output, with no side effects and no mutation of arguments. The one exception is
unionInto, which may mutate itsaccargument.
The channel also relies on these properties, so implement them too:
isEmpty(empty())istrue.unionis associative and commutative, up to equivalence. Marks arrive in arbitrary order and grouping, and the result must not depend on it.intersects(i, empty())isfalse.intersectsis monotonic: if it is true fordirty, it stays true for anyunion(dirty, x). Otherwise coalescing could hide a change that a single mark would have delivered.unionmay return one of its inputs by reference. The channel handles this, but you must never mutate an input yourself.
Interest and dirty are the same type
The interest thunk returns a Region and the accumulated dirty value is a Region, so one type must express both “what I watch” and “what changed”.
Example: key-set space
Region is a set of string keys. Two sets intersect when they share a key.
import { DirtyChannel, MicrotaskScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';
type Keys = ReadonlySet<string>;
const KeySpace: Space<Keys> = {
empty: () => new Set(),
isEmpty: (r) => r.size === 0,
union: (a, b) => new Set([...a, ...b]),
intersects: (interest, dirty) => {
for (const key of interest) if (dirty.has(key)) return true;
return false;
},
};
const channel = new DirtyChannel(KeySpace, new MicrotaskScheduler());
channel.subscribe(
() => new Set(['cart']),
(dirty) => console.log('cart changed', [...dirty]),
);
channel.mark(new Set(['cart', 'user']));
Iterate the smaller set in intersects for large regions.
Example: numeric range space
Region is one half-open range [start, end). Union takes the bounding range, which may over-approximate (a range covering the gap between two marks), but never misses a change. Over-approximation costs extra callbacks, not correctness.
import { DirtyChannel, RAFScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';
interface Range {
readonly start: number;
readonly end: number;
}
const EMPTY: Range = { start: 0, end: 0 };
const isEmpty = (r: Range): boolean => r.end <= r.start;
const RangeSpace: Space<Range> = {
empty: () => EMPTY,
isEmpty,
union: (a, b) => {
if (isEmpty(a)) return b;
if (isEmpty(b)) return a;
return { start: Math.min(a.start, b.start), end: Math.max(a.end, b.end) };
},
intersects: (interest, dirty) =>
!isEmpty(interest) &&
!isEmpty(dirty) &&
interest.start < dirty.end &&
dirty.start < interest.end,
};
const rows = new DirtyChannel(RangeSpace, new RAFScheduler());
let viewport: Range = { start: 100, end: 140 };
rows.subscribe(
() => viewport,
(dirty) => console.log('repaint rows', dirty.start, dirty.end),
);
rows.mark({ start: 120, end: 121 });
rows.mark({ start: 300, end: 301 });
The two marks union to [120, 301), which intersects the viewport, so the callback runs once with that bounding range. A mark of { start: 500, end: 501 } alone would not. Because the interest thunk reads viewport at flush time, reassigning viewport changes what the subscriber watches with no re-subscribe.
The explicit isEmpty branches in union are what keep union(empty(), r) equal to r: without them the empty range [0, 0) would stretch the result to include 0.
unionInto (optional)
unionInto?(acc: Region, b: Region): Region
An accumulate-in-place variant of union, and the only operation allowed to mutate an argument (acc). Implement it when union allocates: merging N marks in one window with a copying union copies O(N²) elements in total, while an in-place add is O(N).
import type { Space } from '@dirtytalk/engine';
const MutableKeySpace: Space<Set<string>> = {
empty: () => new Set(),
isEmpty: (r) => r.size === 0,
union: (a, b) => new Set([...a, ...b]),
unionInto: (acc, b) => {
for (const key of b) acc.add(key);
return acc;
},
intersects: (interest, dirty) => {
for (const key of interest) if (dirty.has(key)) return true;
return false;
},
};
How the channel uses it:
- The first mark of a window is kept by reference, so the caller’s region is never mutated.
- On the second mark the channel calls
union. If the result is a new object (notaorb), the channel treats it as its own and usesunionIntofor later marks in that window. - The accumulator is replaced before subscribers see it, so no subscriber observes a mutation.
bmust never be mutated.unionIntomust return the merged region, which may beaccor a different object.- Without
unionInto, the channel usesunionthroughout; adding it is purely an optimization and must produce an equivalent result.
Pitfalls
- Mutating inputs in
unionorintersects. Regions passed tomarkcan be stored by reference and delivered to subscribers as-is. Mutating them corrupts later flushes. - Mutating the
dirtyargument in a callback. Every subscriber in the flush receives the same object. - Non-identity
empty. Ifunion(empty(), r)changesr, the first real mark is distorted. Special-case empty inunion, as in the range example. isEmptydisagreeing withintersects. IfisEmpty(r)is true butintersects(i, r)is true for somei, the flush is skipped anyway. Make empty regions intersect nothing.- Under-approximating
union. A union that drops part of its inputs loses changes silently. Over-approximate instead. - Exceptions. A throwing
intersectsis isolated per subscriber and reported (see error handling). A throwingunion,isEmptyorunionIntois not: it propagates frommarkor the flush. - Expensive
intersects. It runs once per subscriber per flush. Keep it proportional to the size of the interest, and cache derived structures in the space, not per call.