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.

View .md Edit on GitHub

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;
}
MethodUsed 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) equals r.
  • intersects(empty(), _) returns false.
  • 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 its acc argument.

The channel also relies on these properties, so implement them too:

  • isEmpty(empty()) is true.
  • union is associative and commutative, up to equivalence. Marks arrive in arbitrary order and grouping, and the result must not depend on it.
  • intersects(i, empty()) is false.
  • intersects is monotonic: if it is true for dirty, it stays true for any union(dirty, x). Otherwise coalescing could hide a change that a single mark would have delivered.
  • union may 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 (not a or b), the channel treats it as its own and uses unionInto for later marks in that window.
  • The accumulator is replaced before subscribers see it, so no subscriber observes a mutation.
  • b must never be mutated. unionInto must return the merged region, which may be acc or a different object.
  • Without unionInto, the channel uses union throughout; adding it is purely an optimization and must produce an equivalent result.

Pitfalls

  • Mutating inputs in union or intersects. Regions passed to mark can be stored by reference and delivered to subscribers as-is. Mutating them corrupts later flushes.
  • Mutating the dirty argument in a callback. Every subscriber in the flush receives the same object.
  • Non-identity empty. If union(empty(), r) changes r, the first real mark is distorted. Special-case empty in union, as in the range example.
  • isEmpty disagreeing with intersects. If isEmpty(r) is true but intersects(i, r) is true for some i, 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 intersects is isolated per subscriber and reported (see error handling). A throwing union, isEmpty or unionInto is not: it propagates from mark or 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.