DirtyChannel

How DirtyChannel accumulates marks, coalesces them per scheduler window, notifies interested subscribers, handles re-entrancy and reports errors.

View .md Edit on GitHub

DirtyChannel<Region> accumulates dirty regions passed to mark, asks a Scheduler for a flush, and on flush calls every subscriber whose interest intersects the accumulated region.

Constructor

import { DirtyChannel, SyncScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';

declare const space: Space<number>;

const channel = new DirtyChannel(space, new SyncScheduler(), {
  onError: (err) => console.error(err),
});
ParameterTypeDescription
spaceSpace<Region>Defines how regions are combined and compared. See Custom spaces.
schedulerSchedulerDecides when flushes run. See Schedulers.
options.onError(err: unknown) => void (optional)Receives errors thrown by subscribers during a flush. See Error handling.

Construction does not call the scheduler.

mark

mark(r: Region): void

Merges r into the accumulated dirty region and, if no flush is pending, calls scheduler.request with the channel’s flush function.

import { DirtyChannel, ManualScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';

const BitSpace: 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(BitSpace, scheduler);

channel.mark(0b01);
channel.mark(0b10);
scheduler.pump();
  • Marking is cheap and synchronous; no subscriber runs inside mark unless the scheduler flushes synchronously (SyncScheduler).
  • Marking an empty region (per space.isEmpty) still requests a flush, but the flush notifies nobody.
  • After dispose(), mark does nothing.
  • The first mark of a window is stored by reference. Do not mutate a region after passing it to mark.

subscribe

subscribe(interest: () => Region, cb: (dirty: Region) => void): () => void

Registers a subscriber and returns an unsubscribe function.

import { DirtyChannel, MicrotaskScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';

declare const space: Space<Set<string>>;
const channel = new DirtyChannel(space, new MicrotaskScheduler());

const unsubscribe = channel.subscribe(
  () => new Set(['users']),
  (dirty) => console.log('users changed', dirty),
);
  • interest is a thunk. It is called at flush time, once per flush per subscriber, never at subscribe time. A subscriber can therefore move or resize its interest between flushes.
  • If interest returns null or undefined, the subscriber has no interest in that flush and intersects is not called.
  • cb runs only when space.intersects(interest(), dirty) is true. It receives the full accumulated dirty region, not an intersection.
  • All subscribers in one flush receive the same dirty object. Treat it as read-only.
  • Subscribers are called in registration order.
  • After dispose(), subscribe returns a no-op function and never calls cb.

Unsubscribing

The function returned by subscribe removes the subscriber. It is idempotent, and safe to call at any time, including from inside a callback. A subscriber unsubscribed during a flush is skipped for the rest of that flush, even if it has not run yet.

import { DirtyChannel, SyncScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';

declare const space: Space<number>;
const channel = new DirtyChannel(space, new SyncScheduler());

const unsubscribe = channel.subscribe(
  () => 1,
  () => unsubscribe(),
);

Subscribing during a flush is also safe: the new subscriber is not called in the current flush and first sees the next one.

Coalescing

Marks made within one scheduler window are merged with space.union into one region, and the scheduler is asked for a flush once. At flush time the accumulated region is taken and reset, then each subscriber is evaluated once against it. A flush whose accumulated region is empty calls no interest thunks and no callbacks.

If the space implements the optional unionInto, the channel uses it to accumulate in place, but only on a region the channel itself allocated through union. See Custom spaces.

Re-entrancy

Calling mark from inside a subscriber callback is allowed. The mark accumulates into a fresh region and is delivered by a new flush, which the channel requests after the current flush finishes. It never runs in the same flush, so callbacks cannot loop within a single flush. A callback that marks unconditionally will, however, keep producing flushes, one per scheduler window.

import { DirtyChannel, ManualScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';

const BitSpace: 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(BitSpace, scheduler);

channel.subscribe(
  () => 0b01,
  () => channel.mark(0b10),
);
channel.subscribe(
  () => 0b10,
  (d) => console.log('second flush', d),
);

channel.mark(0b01);
scheduler.pump(); // runs the first subscriber, which marks 0b10
scheduler.pump(); // logs: second flush 2

With SyncScheduler the follow-up flush runs synchronously once the current one has finished.

Error handling

An error thrown by an interest thunk, by space.intersects, or by a callback does not stop the flush: the remaining subscribers still run and the channel keeps working afterwards.

ConfigurationBehaviour
No onErrorErrors are collected. After all subscribers have run, a single error is rethrown as-is; two or more are rethrown as an AggregateError with message DirtyChannel: subscriber errors during flush.
onError setEach error is passed to onError as it happens. The flush does not throw.

Where a rethrown error surfaces depends on the scheduler: from mark with SyncScheduler, from pump() with ManualScheduler, and as an uncaught exception in the microtask or timer callback for MicrotaskScheduler and RAFScheduler. If you use those, pass onError.

Warning: Errors thrown by onError itself are not caught. They abort the flush; subscribers not yet reached are skipped for that flush, and no follow-up flush is requested for marks made during it. The next mark requests one.

Errors from space.union, space.isEmpty and unionInto are not isolated; they propagate from mark or the flush.

dispose

dispose(): void

Ends the channel permanently.

  • Cancels a pending flush via scheduler.cancel if the scheduler provides it and a flush is pending.
  • Drops all subscribers and any accumulated region.
  • Idempotent.
  • Called from inside a callback, it stops the remaining callbacks of the current flush.
  • Afterwards mark does nothing and subscribe returns a no-op unsubscribe.

Summary

MemberSignature
constructor(space, scheduler, options?: { onError? })
mark(r: Region) => void
subscribe(interest: () => Region, cb: (dirty: Region) => void) => () => void
dispose() => void

The class has no other public members. See the API reference.