defaultScheduler
The one scheduler instance. Duplicate copies of the package reuse the canonical one through the shared runtime.
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()
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:
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 from | Runs in |
|---|---|
a read | the same frame |
a write | the 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()
import { defaultScheduler } from '@studiometa/js-toolkit';
const unsubscribe = defaultScheduler.tick(({ time, delta }) => {
console.log(time, delta);
});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-failedand skipped, never unsubscribed. deltais clamped to[1, 40]ms, and the first tick after the loop wakes reports1000/60.timestays the raw rAF timestamp.
For a subscription that comes and goes within a cycle, use toggle() rather than subscribing and unsubscribing by hand.
whenIdle()
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
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.