Skip to content

Instantly share code, notes, and snippets.

@schickling
Created August 3, 2023 11:18
Show Gist options
  • Save schickling/82b318c8b5ca378241972b0730b6c404 to your computer and use it in GitHub Desktop.
Save schickling/82b318c8b5ca378241972b0730b6c404 to your computer and use it in GitHub Desktop.
/* eslint-disable prefer-arrow/prefer-arrow-functions */
export * from '@effect/io/Scheduler'
import * as Scheduler from '@effect/io/Scheduler'
// Based on https://github.com/Effect-TS/io/blob/main/src/Scheduler.ts#L63
export class ReactAwareScheduler implements Scheduler.Scheduler {
private running = false
private tasks = new Array<Scheduler.Task>()
constructor(private maxNextTickBeforeTimer: number) {}
scheduleTask(task: Scheduler.Task) {
this.tasks.push(task)
if (this.running === false) {
this.running = true
this.starve(0, Date.now())
}
}
private starveInternal(depth: number, starveStartedMs: number) {
const tasks = this.tasks
this.tasks = []
// TODO do some metering here to avoid frame drops
for (const task of tasks) {
task!()
}
if (this.tasks.length === 0) {
this.running = false
} else {
this.starve(depth, starveStartedMs)
}
}
private starve(executionDepth: number, starveStartedMs_: number) {
const { shouldYield, starveStartedMs } = this.shouldYieldToNextFrame(executionDepth, starveStartedMs_)
if (shouldYield) {
setTimeout(() => this.starveInternal(0, starveStartedMs), 0)
} else {
// Schedules remaining work as a microtask (using `Promise.resolve()` instead of `queueMicrotask()` for better compatibility (e.g. React Native))
Promise.resolve(void 0).then(() => this.starveInternal(executionDepth + 1, starveStartedMs))
}
}
/** NOTE We're also returning `starveStartedMs` to avoid having to call` Date.now()` twice */
private shouldYieldToNextFrame(
depth: number,
starveStartedMs: number,
): { shouldYield: boolean; starveStartedMs: number } {
// @ts-expect-error TODO improve this (at least the typing)
const reactRenderedThisFrame = window.__debugReactRenderedThisFrame
if (reactRenderedThisFrame) return { shouldYield: true, starveStartedMs }
if (depth >= this.maxNextTickBeforeTimer) return { shouldYield: true, starveStartedMs }
const now = Date.now()
// TODO use actual FPS
if (now - starveStartedMs > 10) return { shouldYield: true, starveStartedMs: now }
return { shouldYield: false, starveStartedMs }
}
}
/** NOTE should only be used on Main Thread */
export class BackgroundScheduler implements Scheduler.Scheduler {
private running = false
private taskBuckets = new Scheduler.PriorityBuckets()
scheduleTask(task: Scheduler.Task, priority: number) {
this.taskBuckets.scheduleTask(task, priority)
if (this.running === false) {
this.running = true
this.starve()
}
}
private async doWork(idleDeadline: IdleDeadline) {
while (true) {
const task = getNextTask(this.taskBuckets)
if (task === undefined) {
this.running = false
return
} else {
task()
if (navigator.scheduling!.isInputPending!() || idleDeadline.timeRemaining() <= 0.5) {
this.starve()
return
}
}
}
}
starve() {
requestIdleCallback((idleDeadline) => {
this.doWork(idleDeadline)
})
}
}
export class FallbackBackgroundScheduler implements Scheduler.Scheduler {
private running = false
private taskBuckets = new Scheduler.PriorityBuckets()
/** Timestamp in milli seconds */
private lastWitnessedFrame = 0
private timePerFrame = 1000 / this.systemFps
constructor(private systemFps = 60) {
// NOTE We keep this loop running (even if no tasks might be scheduled for a while)
// to optimize the latency for the next task that might be scheduled
this.loopRequestAnimationFrame()
}
scheduleTask(task: Scheduler.Task, priority: number) {
this.taskBuckets.scheduleTask(task, priority)
if (this.running === false) {
this.running = true
this.starve()
}
}
// TODO We should investigate a better implementation for Safari if possible
private loopRequestAnimationFrame() {
// TODO use `_now` from requestAnimationFrame
requestAnimationFrame((_now) => {
// setInterval(() => {
this.lastWitnessedFrame = Date.now()
this.loopRequestAnimationFrame()
})
// }, this.timePerFrame)
}
private starve() {
while (true) {
const task = getNextTask(this.taskBuckets)
if (task === undefined) {
this.running = false
return
} else {
task()
if (this.getRemainingTimeMs() <= 0.5) {
this.starve()
return
}
}
}
}
private getRemainingTimeMs() {
const now = Date.now()
const timeSinceLastFrame = now - this.lastWitnessedFrame
return this.timePerFrame - timeSinceLastFrame
}
}
/**
* NOTE This implementation always prioritizes higher-priority tasks over lower priority tasks
* even if the lower priority task was scheduled first
*/
const getNextTask = (taskBuckets: Scheduler.PriorityBuckets): Scheduler.Task | undefined => {
for (const [_, tasks] of taskBuckets.buckets) {
const task = tasks.shift()
if (task !== undefined) return task
}
}
export const reactAwareScheduler = (): Scheduler.Scheduler => new ReactAwareScheduler(2048)
export const backgroundScheduler = (): Scheduler.Scheduler => {
// Safari doesn't yet support `requestIdleCallback` and `navigator.scheduling.isInputPending`
// So we're falling back to a timer based scheduler
if (
typeof window === 'undefined' ||
window.requestIdleCallback === undefined ||
navigator.scheduling?.isInputPending === undefined
) {
return new FallbackBackgroundScheduler()
}
return new BackgroundScheduler()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment