Scheduler
Every animation in damped runs on a scheduler: one frame request at a time, three ordered phases per frame, and no request at all while nothing is animating. You rarely touch it directly, but it is the seam for tests (give it a hand-driven FrameSource) and for your own per-frame work that should share the frame with damped.
A frame runs read, then update, then write. Within a phase, jobs run in registration order and share one FrameInfo object. A job added during a frame joins this frame only if its phase has not started yet; otherwise it runs next frame.
Functions
Section titled “Functions”createScheduler
Section titled “createScheduler”Creates a scheduler that runs its jobs on the frames of a FrameSource.
export function createScheduler(source: FrameSource = defaultFrameSource()): Scheduler| Parameter | Type | Default | Description |
|---|---|---|---|
source |
FrameSource |
requestAnimationFrame |
Where frames come from. The default is chosen when createScheduler is called: requestAnimationFrame and cancelAnimationFrame when both exist, otherwise a 16 ms setTimeout whose callback receives performance.now() (or Date.now()). |
Returns a Scheduler.
Behavior
- One frame is requested at a time, however many jobs are added.
- When no job is left after a frame, the scheduler stops requesting frames and resets, so the next wake reports
delta: 0. Cancelling the last pending job outside a frame withdraws the pending request throughsource.cancel. schedule(phase, job)runs a job once.loop(phase, job)runs it every frame for as long as it does not returnfalse. At runtime any other return value, includingundefined, keeps the loop going.deltaistimestamp - previousTimestampand is not clamped: a source with non-monotonic timestamps produces a negative delta.- Cancel functions are idempotent and harmless after the job ran. A job may cancel itself, a later job or a sibling before it runs.
- Nothing runs on import; the default source is created when you call this function.
Edge cases
- An unknown phase throws
RangeError('unknown phase "x"')without leaving a frame requested. - A job that throws is dropped (also a loop job), the rest of the frame still runs, and the error is rethrown from a microtask so it is not swallowed. Every failing job is reported.
- Without
requestAnimationFrame(a server, a test runner without a DOM) the 16 ms timer fallback is used, so the scheduler still works. - Reduced motion does not apply: a scheduler runs whatever you give it.
Example
import { createScheduler, type FrameSource } from "@damped/core";
// A hand-driven frame source: nothing runs until flush() is called.const queue = new Map<number, (timestamp: number) => void>();let next = 1;const source: FrameSource = { request(callback) { const handle = next++; queue.set(handle, callback); return handle; }, cancel(handle) { queue.delete(handle); },};function flush(timestamp: number): void { const callbacks = [...queue.values()]; queue.clear(); for (const callback of callbacks) callback(timestamp);}
const scheduler = createScheduler(source);const order: string[] = [];scheduler.schedule("write", () => order.push("write"));scheduler.schedule("read", () => order.push("read"));flush(0);console.log(order, scheduler.active); // ["read", "write"] falseSee also: frame, Scheduler, FrameSource, Phase, Testing, Compositor vs JS
The shared scheduler that every API uses when you do not pass a scheduler option.
export const frame: SchedulerBehavior
- It forwards to a scheduler that is created on first use with the default frame source, so importing the package never touches
window. frame.activeisfalseuntil something has been scheduled on it.- It cannot be replaced. To control time, create your own scheduler with
createSchedulerand pass it through thescheduleroption of the API under test. - Behavior, errors and cancellation are those of
createScheduler.
Example
import { frame } from "@damped/core";
let remaining = 3;frame.loop("update", ({ timestamp, delta }) => { console.log(`frame at ${timestamp.toFixed(0)} ms, ${delta.toFixed(1)} ms after the last`); remaining -= 1; return remaining > 0; // returning false ends the loop});
// Read in one phase and write in a later one, so layout is read once per frame.const box = document.querySelector<HTMLElement>(".box");if (box !== null) { frame.schedule("read", () => { const width = box.getBoundingClientRect().width; frame.schedule("write", () => { box.style.width = `${width + 10}px`; // same frame: the write phase has not run yet }); });}See also: createScheduler, Scheduler, Phase, SpringValueOptions
FrameInfo
Section titled “FrameInfo”The time of the current frame, handed to every job.
export interface FrameInfo { /** Frame timestamp in milliseconds, as reported by the frame source. */ timestamp: number; /** Milliseconds since the previous frame; 0 on the first frame after waking. */ delta: number;}| Field | Type | Default | Description |
|---|---|---|---|
timestamp |
number |
none | The timestamp the frame source reported, in milliseconds. |
delta |
number |
none | Milliseconds since the previous frame. 0 on the first frame after the scheduler wakes from idle. |
Behavior
- All jobs of one frame share the same object. Do not keep and mutate it.
- Springs take seconds, the timestamp is in milliseconds. Divide by 1000 when you feed it to a spring yourself.
Example
import { frame, type FrameInfo } from "@damped/core";
const samples: number[] = [];
function sample({ delta }: FrameInfo): boolean { if (delta > 0) samples.push(1000 / delta); return samples.length < 10;}
frame.loop("update", sample);See also: FrameJob, Scheduler, FrameSource
FrameSource
Section titled “FrameSource”Where a scheduler gets its frames: a pair of functions shaped like requestAnimationFrame and cancelAnimationFrame.
export interface FrameSource { request(callback: (timestamp: number) => void): number; cancel(handle: number): void;}| Member | Type | Description |
|---|---|---|
request(callback) |
(callback: (timestamp: number) => void) => number |
Ask for one frame. Call callback with a timestamp in milliseconds. Return a handle. |
cancel(handle) |
(handle: number) => void |
Withdraw a request made with that handle. |
Behavior
- The scheduler requests at most one frame at a time and calls
cancelonly to withdraw a request when its last job is cancelled. - The timestamps your source reports are the only clock springs see. The scheduler does not validate them.
- A source may call back synchronously, with any timestamps. That is how tests step through an animation.
Example
import { createScheduler, type FrameSource } from "@damped/core";
// A 30 fps source, for something that does not need 60.const everyThirdFrame: FrameSource = { request: (callback) => window.setTimeout(() => callback(performance.now()), 33), cancel: (handle) => window.clearTimeout(handle),};
export const slow = createScheduler(everyThirdFrame);See also: createScheduler, Scheduler, Testing
One of the three ordered steps of a frame.
export type Phase = "read" | "update" | "write";| Value | When | Intended use |
|---|---|---|
"read" |
first | Measure layout. |
"update" |
second | Advance state: springs run here. |
"write" |
last | Write styles. damped writes each element’s styles here, once per frame. |
Behavior
- Jobs of an earlier phase see the DOM before any write of the same frame, which avoids reading layout after writing.
- Any other string throws
RangeError('unknown phase "…"')when scheduling.
Example
import { frame, type Phase } from "@damped/core";
const phases: readonly Phase[] = ["read", "update", "write"];const seen: Phase[] = [];
for (const phase of [...phases].reverse()) { frame.schedule(phase, () => { seen.push(phase); });}// After the next frame, `seen` is ["read", "update", "write"].See also: Scheduler, createScheduler, FrameJob
FrameJob
Section titled “FrameJob”A function the scheduler runs once in a phase.
export type FrameJob = (frame: FrameInfo) => void;| Parameter | Type | Description |
|---|---|---|
frame |
FrameInfo |
The current frame. |
Behavior
- The return value of a
schedulejob is ignored. - A job that throws is dropped and its error is rethrown from a microtask. Other jobs are not affected.
looptakes a different shape,(frame: FrameInfo) => boolean: returningfalseends it.
Example
import { frame, type FrameJob } from "@damped/core";
const logFrame: FrameJob = ({ timestamp, delta }) => { console.log(`frame at ${timestamp} ms (+${delta} ms)`);};
const cancel = frame.schedule("update", logFrame);cancel(); // changed my mind: harmless, also after it ranSee also: Scheduler, FrameInfo, Phase
Scheduler
Section titled “Scheduler”The scheduler interface: schedule one-shot jobs, run loops, and ask whether a frame is pending.
export interface Scheduler { schedule(phase: Phase, job: FrameJob): () => void; loop(phase: Phase, job: (frame: FrameInfo) => boolean): () => void; readonly active: boolean;}| Member | Type | Description |
|---|---|---|
schedule(phase, job) |
(phase: Phase, job: FrameJob) => () => void |
Run job once in phase of the next frame, or of the current frame if that phase has not run yet. Returns a cancel function. |
loop(phase, job) |
(phase: Phase, job: (frame: FrameInfo) => boolean) => () => void |
Run job every frame in phase until it returns false or is cancelled. Returns a cancel function. |
active |
boolean |
true while a frame is requested. false means the scheduler is idle. |
Behavior
activeis the idle signal: after every animation settles, it isfalseand no frame is requested.- The
scheduleroption ofcreateSpringValue,animate,layout,morph,enterandexittakes one of these.
Example
import { createScheduler, type Scheduler } from "@damped/core";
function isIdle(scheduler: Scheduler): boolean { return !scheduler.active;}
const scheduler = createScheduler();console.log(isIdle(scheduler)); // true: nothing was scheduled
const stop = scheduler.loop("update", () => true);console.log(isIdle(scheduler)); // false: a frame is requestedstop();console.log(isIdle(scheduler)); // true again: cancelling the last job withdraws the requestSee also: createScheduler, frame, Phase, FrameJob, Testing