Skip to content

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 read scheduled from a write runs in the next frame. A write scheduled from a read runs 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 write batch 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:

js
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:

ts
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:

js
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 in read and the render function it returns mutates in write.
  • 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.
  • delta is clamped to [1, 40] ms, and the first tick after the loop wakes reports 1000/60. time stays 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()

js
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.

ts
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 write tasks before the snapshot. Writes scheduled inside the update callback run within the transition.
  • The helper is standalone. Base has 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:

claimeffect
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.

MIT Licensed