The scheduler
One frame-aligned scheduler is the clock of the framework. It replaces domScheduler, the RafService loop, SmartQueue and the view-transition scheduler of @studiometa/ui.
The lanes
frame start (rAF)
1. tick — fan out to the subscribers of the clock
2. read — measure: layout reads only
3. write — mutate: DOM writes only
style / layout / paint
between frames, on its own turns
background — time-sliced lane: mount and update lifecycle work,
mutation-record processing, manifest loading- Frame alignment. One flush per frame, at rAF. Every read runs before every write, once, before paint.
- No thrashing. A
readscheduled from awriteruns in the next frame. Awritescheduled from areadruns in the same frame. - Bounded phases. 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
writebatch is taken after the reads run. - Error isolation. One try/catch per task. A task that throws is reported and dropped; the flush continues and the scheduler never deadlocks.
- Queued execution only. There is no synchronous escape.
Reading and writing from a component
this.$read(fn) and this.$write(fn) tie tasks to the instance, and unmount cancels the pending ones:
import { Base } from '@studiometa/js-toolkit';
class Reveal extends Base {
static config = { name: 'Reveal' };
async mounted() {
const box = await this.$read(() => this.$el.getBoundingClientRect()).promise;
await this.$write(() => {
this.$el.style.setProperty('--height', `${box?.height}px`);
}).promise;
}
}Scheduling returns a cancelable handle whose promise resolves with the return value of the task:
async function measure() {
const task = defaultScheduler.read(() => el.getBoundingClientRect());
const box = await task.promise;
task.cancel(); // idempotent
return box;
}The tick
scheduler.tick(callback) subscribes to the clock:
import { defaultScheduler } from '@studiometa/js-toolkit';
const unsubscribe = defaultScheduler.tick(({ time, delta }) => {
console.log(time, delta);
});- Tick callbacks run at the start of the flush, before
read, so what they schedule belongs to the same frame. A callback measures inreadand the render function it returns mutates inwrite. - 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 and skipped, never unsubscribed.
deltais clamped to[1, 40]ms, and the first tick after the loop wakes reports1000/60.timestays the raw rAF timestamp.
A component that needs the loop for part of a cycle uses toggle() rather than subscribing and unsubscribing by hand.
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. whenIdle() counts background tasks and resolves at the end of a background drain as well as at the end of a flush.
nextFrame()
import { nextFrame } from '@studiometa/js-toolkit';
async function afterPaint() {
await nextFrame();
}afterWrite is gone
rAF callbacks run before style, layout and paint, so no phase inside the frame can read post-layout geometry. Measure in the read phase of the next frame, or use a ResizeObserver.
View transitions
viewTransition(update) is a standalone export with a progressive-enhancement contract: where the platform has no startViewTransition, the update simply runs.
import { viewTransition } from '@studiometa/js-toolkit';
async function swapPanel(el: Element, html: string) {
await viewTransition(() => {
el.innerHTML = html;
});
}- Updates queued in the same flush batch into one
startViewTransition()call, so a backdrop and a panel animate as one transition. Each later batch is appended to one promise tail, so several flushes during one transition stay serialized. - The scheduler flushes the pending
writetasks before the snapshot. Writes scheduled inside the update callback run within the transition. - The helper is standalone.
Basehas no view-transition method and no import of one.
It composes with a negotiated domUpdate(), where the ancestor chooses the lane because it knows whether the region animates:
| claim | effect |
|---|---|
wrap(viewTransition) | the change plays as one batched native view transition |
wrap((apply) => this.$write(apply).promise) | the change lands in the write phase, batched, cancelled on unmount |
wrap(motionView) | any object with update(mutate) |
Neither runner is the default.