Idle Callbacks
@time-provider/addon-idle adds a scheduler.idle facade exposing request, backed by the host's native requestIdleCallback on a system Time-Provider and by an explicit drain() on a deterministic one. Like every addon it composes in with .use(addon) and ships two entry points:
import { createTimeProvider } from "@time-provider/core";
import { plugin } from "@time-provider/plugin-native";
import { addon } from "@time-provider/addon-idle";
const timeProvider = createTimeProvider.for(plugin).use(addon).create();
const handle = timeProvider.scheduler.idle.request(() => reindexSearchCache());
handle.dispose();request matches the native requestIdleCallback contract: it fires once, not repeatedly, passes the callback an IIdleDeadline (didTimeout, timeRemaining()), and takes an optional { timeout } after which a still-pending callback runs anyway. Call it again from inside the callback to keep polling for idle time. There is no separate cancel method, so cancel through handle.dispose() as you would any other scheduled work in this library. It is a no-op if the callback already ran or the handle was already disposed. The handle is an IScheduledHandle, the same type every other @time-provider/core timer returns.
Idle on a deterministic clock
Nothing about elapsed simulated time says a runtime has spare capacity, so there is no idle to detect. A deterministic request stays pending until a test declares the runtime idle, and moveBy() or a clock read never fires one on its own:
import { createTimeProvider } from "@time-provider/core/deterministic";
import { plugin } from "@time-provider/plugin-native/deterministic";
import { addon } from "@time-provider/addon-idle/deterministic";
const timeProvider = createTimeProvider
.for(plugin)
.use(addon)
.asManual()
.withInitialTime(0)
.create();
const ran: string[] = [];
timeProvider.scheduler.idle.request(() => ran.push("first"));
timeProvider.scheduler.idle.request(() => ran.push("second"));
timeProvider.clock.moveBy({ hours: 1 });
console.log(ran); // [] - moving the clock is not an idle period
timeProvider.scheduler.idle.drain(1); // returns 1
console.log(ran); // ["first"]
timeProvider.scheduler.idle.drain(); // returns 1, running everything still pending
console.log(ran); // ["first", "second"]A drained callback gets didTimeout: false and a timeRemaining() of 50, the most a browser ever grants. A request made with { timeout } runs on its own once the clock has moved that far, with didTimeout: true and timeRemaining() of 0, and is no longer pending after that. Draining it first cancels the timeout.
drain(maxCount?) runs up to maxCount pending callbacks, oldest request first, and returns how many actually ran. Omit the argument to run everything pending. That gives a test the thing a real host never offers: control over how much work one idle period lets through, so you can assert what happens when the host stays busy and only some of the queue drains. scheduler.idle.pendingCount counts the requests still pending.
Every deterministic runtime behaves the same way here. A fixed clock is no exception, even though it disables the runtime's own due-draining, because drain() retrieves this addon's entries through its own tagged index rather than through anything the clock advances.
Where the API isn't available
The system addon constructs eagerly and throws if the host has no requestIdleCallback/cancelIdleCallback. Safari has no native equivalent, and neither does Node.js, which exposes no such global. That surfaces at .create(), not at the first request, so a host without the API fails while you are building the Time-Provider rather than much later.
The deterministic addon never touches the host API, so it works everywhere. Logic written against scheduler.idle stays testable on a runtime that could not run it for real.
The types
Inference covers ordinary use. If you need to write a type down, the root entry point exports IIdleApi (the scheduler.idle facade) and WithIdleApi for naming a Time-Provider with this addon composed in:
import type { IIdleApi, WithIdleApi } from "@time-provider/addon-idle";
function scheduleCleanup(tp: ITimeProvider<Date> & WithIdleApi) {
tp.scheduler.idle.request(() => {});
}IIdleDeadline and IIdleRequestOptions name the callback's argument and the request options. From the /deterministic entry point, IIdleApi and WithIdleApi add drain and pendingCount, so a test helper that takes a Time-Provider can drain it.
The implementation classes are exported too, for the rare case of building a facade outside the addon pipeline: SystemIdleScheduler from the root entry point and DeterministicIdleScheduler from /deterministic.
With the compat addon
Compose addon-compat before this one and it also gets requestIdleCallback/cancelIdleCallback on its facade, delegating to request and to the handle's dispose():
const tp = createTimeProvider.for(plugin).use(compat).use(addon).create();
tp.compat.cancelIdleCallback(tp.compat.requestIdleCallback(() => reconcile()));WithIdleApi declares those as an optional compat?, since they are only there when both addons are composed — and only when compat is composed first, because the facade they land on is its.