@enclosurejs/scheduler
v1.1.0
Published
Coordinated work scheduling (micro/frame/idle queues) for Enclosure apps
Readme
@enclosurejs/scheduler — Coordinated work scheduling for Enclosure apps
[!IMPORTANT] Three queues mapped to browser timing primitives: micro (
queueMicrotask), frame (requestAnimationFrame), idle (requestIdleCallback). Pause/resume buffers jobs without losing them. Everyschedule()call returns a cancellableDisposablehandle.
The Problem
Desktop apps juggle rendering, background computation, and housekeeping. Dumping everything into setTimeout gives no priority control. Using requestAnimationFrame directly couples scheduling to individual components. When the app needs to pause all work (e.g. during a modal, resize, or plugin reload), there's no central switch.
@enclosurejs/scheduler provides a single Scheduler that coordinates WHO runs WHEN across three priority lanes, with pause/resume and per-job cancellation. It works for DOM, Canvas, and WebGPU — it coordinates timing, not rendering.
Architecture
createScheduler()
│
┌────────────┼────────────┐
▼ ▼ ▼
micro frame idle
queueMicrotask rAF / 16ms rIC / 1ms
│ │ │
└──── flush(pending) ─────┘
│
stats tracking
(scheduled / executed / cancelled)Fallbacks (Node / environments without rAF/rIC):
frame→setTimeout(fn, 16)(approximate 60fps)idle→setTimeout(fn, 1)(low-priority background)
Quick Start
import { createScheduler } from '@enclosurejs/scheduler';
const scheduler = createScheduler();
// Schedule on different priority lanes
scheduler.schedule('micro', () => updateState());
scheduler.schedule('frame', () => drawScene());
scheduler.schedule('idle', () => sendAnalytics());
// Cancel before execution
const handle = scheduler.schedule('frame', () => expensiveWork());
handle.dispose(); // cancelled — job will not run
// Pause all scheduling
scheduler.pause();
scheduler.schedule('frame', () => buffered()); // buffered, not dispatched
scheduler.resume(); // buffered jobs now dispatched
// Inspect stats
console.log(scheduler.stats); // { scheduled: 4, executed: 2, cancelled: 1 }
// Cleanup
scheduler.dispose(); // cancels all pending, clears platform timersAs a Module
import { createApp } from '@enclosurejs/core';
import { createSchedulerModule, SchedulerToken } from '@enclosurejs/scheduler';
const app = createApp({
modules: [createSchedulerModule()],
});
await app.start();
const scheduler = app.context.use(SchedulerToken);
scheduler.schedule('frame', () => render());How It Works
Queue Dispatch
schedule(queue, job) wraps the job in a PendingJob with a cancelled flag, then dispatches it to the appropriate platform API:
- micro —
queueMicrotask()— runs after the current task, before the next render. Lowest latency, highest priority. - frame —
requestAnimationFrame()— runs before the next repaint (~16ms cadence). Ideal for UI updates, canvas draws, WebGPU frame prep. - idle —
requestIdleCallback()— runs when the browser has no other work. Best for analytics, cache cleanup, non-urgent persistence.
The flush() function executes a job only if it hasn't been cancelled and the scheduler hasn't been disposed. After execution, the cancelled flag is set to prevent double-execution from stale platform callbacks.
Fallback Detection
Platform API availability is checked once at module load via typeof globalThis.requestAnimationFrame === 'function'. In environments without rAF/rIC (Node.js, test runners), fallbacks fire:
frame→setTimeout(fn, 16)— approximates 60fps timingidle→setTimeout(fn, 1)— low-priority background
Micro queue always uses queueMicrotask() (available in all modern runtimes).
Pause Buffering
When pause() is called, new schedule() calls push jobs into per-queue buffers (pausedMicro, pausedFrame, pausedIdle) instead of dispatching to platform APIs. resume() iterates all buffers, dispatches non-cancelled jobs, then clears the buffers. Jobs cancelled during pause are silently skipped on resume.
Timer Tracking
Frame and idle platform timer IDs are tracked in Set<number>. On dispose(), all pending timers are cancelled via cancelAnimationFrame / cancelIdleCallback / clearTimeout, and all paused jobs are marked cancelled. This prevents orphaned callbacks after teardown.
API
| Export | Kind | Purpose |
| ----------------------- | --------- | ---------------------------------------------------- |
| createScheduler() | factory | Creates a standalone Scheduler instance |
| Scheduler | interface | Schedule, pause/resume, stats, dispose |
| SchedulerQueue | type | 'micro' \| 'frame' \| 'idle' |
| SchedulerStats | type | { scheduled, executed, cancelled } counters |
| SchedulerToken | token | Resolve Scheduler from DI |
| createSchedulerModule | factory | Creates a Module that wires scheduler into the app |
Configuration
No configuration — createScheduler() and createSchedulerModule() take no options. The scheduler auto-detects requestAnimationFrame and requestIdleCallback availability and falls back to setTimeout.
Types Exported
| Type | Used by |
| ---------------- | ------------------------------------- |
| Scheduler | Any code scheduling work |
| SchedulerQueue | Queue selection in schedule() calls |
| SchedulerStats | Monitoring and debugging |
Safety
Cancellation
handle.dispose()is idempotent — double cancel does not double-count.- Cancelling after execution is a no-op (the
cancelledflag is already set byflush). schedule()on a disposed scheduler returns a no-op handle, never throws.
Dispose
dispose()is idempotent — second call does nothing.- Cancels all pending platform timers (
cancelAnimationFrame,cancelIdleCallback,clearTimeout). - Cancels all paused-but-not-yet-dispatched jobs.
- Already-queued microtasks are guarded by the
disposedflag inflush().
Pause/Resume
resume()when not paused is a no-op.- Jobs cancelled during pause are skipped on resume.
- Pause does not cancel already-dispatched platform callbacks — they are guarded by the
cancelledflag.
Benchmarks
No benchmarks — scheduler overhead is negligible (one closure + one Set.add per job). The real cost is in the user's job callbacks.
Bundle Size
| Output | File | Size |
| ------------ | ------------ | ------- |
| Runtime (JS) | index.js | 3.66 KB |
| Types (DTS) | index.d.ts | 1.35 KB |
| Total | | 5.01 KB |
External dependency (@enclosurejs/core) is not bundled — it is a peer dependency.
Quality
| Metric | Value |
| --------------------- | ------------------------------------------------------------------ |
| Unit tests | 26 (all pass) |
| Test files | 2 (scheduler.test.ts, module.test.ts) |
| Source files | 3 (scheduler.ts, module.ts, index.ts) |
| External dependencies | 0 |
| Peer dependencies | @enclosurejs/core |
| Coverage thresholds | statements >= 90%, branches >= 85%, functions >= 95%, lines >= 90% |
Quality Layers
Layer 1: STATIC ANALYSIS (every commit)
tsc --noEmit strict mode, zero errors
eslint ESLint 9 flat config, zero warnings
prettier --check formatting
Layer 2: UNIT TESTS (every commit)
26 tests micro/frame/idle queues, pause/resume, stats, dispose,
cancel idempotency, disposed scheduler no-op, platform fallbacks
v8 coverage statements >= 90%, branches >= 85%, functions >= 95%, lines >= 90%
Layer 3: BENCHMARKS
N/A scheduling overhead is one closure + one Set.add per job
Layer 4: PACKAGE HEALTH
0 external deps pure TypeScript + @enclosurejs/core
tsup build ESM + DTS output, single entrypointFile Structure
packages/scheduler/
├── src/
│ ├── index.ts Barrel: all public exports
│ ├── scheduler.ts createScheduler, Scheduler, SchedulerQueue, SchedulerStats
│ ├── module.ts createSchedulerModule, SchedulerToken
│ └── __tests__/
│ ├── scheduler.test.ts 22 tests — micro/frame/idle queues, pause/resume, stats, dispose
│ └── module.test.ts 4 tests — module wiring, dispose cleanup
├── package.json
├── tsconfig.json
└── tsup.config.tsLicense
MIT
