Skip to content

defaultScheduler

The one scheduler instance. Duplicate copies of the package reuse the canonical one through the shared runtime.

ts
class Scheduler {
  get phase(): SchedulerPhase;
  tick(callback: TickCallback): () => void;
  read<T>(fn: () => T): ScheduledTask<T>;
  write<T>(fn: () => T): ScheduledTask<T>;
  whenIdle(): Promise<void>;
}

read() and write()

ts
async function 
resize
() {
const
box
= await
defaultScheduler
.
read
(() =>
el
.
getBoundingClientRect
()).
promise
;
await
defaultScheduler
.
write
(() => {
el
.
style
.
height
= `${
box
?.
height
}px`;
}).
promise
;
}

Both return a handle:

ts
interface ScheduledTask<T = unknown> {
  promise: Promise<T | undefined>;
  cancel(): void;
}
  • The promise resolves with the return value of the task, so a read hands its measurement back.
  • cancel() is idempotent, and a cancelled task's promise never resolves with a value.
  • Queued execution only. There is no synchronous escape.

The thrash rule

Scheduled fromRuns in
a readthe same frame
a writethe next frame

Each queue array is swapped for an empty one when its phase starts, so a task scheduled into the running phase lands in the next frame's batch. The write batch is taken after the reads run.

tick()

js
import { 
defaultScheduler
} from '@studiometa/js-toolkit';
const
unsubscribe
=
defaultScheduler
.
tick
(({
time
,
delta
}) => {
console
.
log
(
time
,
delta
);
});
ts
interface TickProps {
  readonly time: DOMHighResTimeStamp;
  readonly delta: number;
}
  • Tick callbacks run at the start of the flush, before read, so what they schedule belongs to the same frame.
  • The subscription is the only handle, and it keeps the loop alive. The scheduler requests the next frame when a queue inside the frame is not empty, or when a tick subscriber stays. There is no permanent rAF loop.
  • Tick subscribers are not queued work, so whenIdle() ignores them.
  • A tick callback that throws is reported as callback.scheduler-tick-failed and skipped, never unsubscribed.
  • delta is clamped to [1, 40] ms, and the first tick after the loop wakes reports 1000/60. time stays the raw rAF timestamp.

For a subscription that comes and goes within a cycle, use toggle() rather than subscribing and unsubscribing by hand.

whenIdle()

js
await defaultScheduler.whenIdle();

Resolves at the end of a flush and at the end of a background drain, because it counts background tasks too.

It is one half of the timing recipe the /test helpers encode — the other half is the mutation observer's delivery latency, which is why whenIdle() alone is not enough to know a component has mounted.

phase

ts
type SchedulerPhase = 'idle' | 'tick' | 'read' | 'write' | 'background';

Reading it is a diagnostic, not a control. Code that branches on the phase is usually code that should schedule instead.

The background lane

It posts its own turns through scheduler.postTask({ priority: 'background' }), and falls back to a MessageChannel message. Each turn runs a 5 ms slice measured from the start of the drain, then gives the thread back and posts the next turn.

Background work alone never requests an animation frame.

A failure to post is reported as scheduler.background-post-failed.

Error isolation

One try/catch per task. A task that throws is reported as callback.scheduled-task-failed, rejects its own promise with the same value, and is dropped. The flush continues and the scheduler never deadlocks.

MIT Licensed