@zedux/atoms
Version:
A Molecular State Engine for React
218 lines (182 loc) • 6.11 kB
text/typescript
import { Job, Scheduler as SchedulerInterface } from '@zedux/core'
import { Ecosystem } from './Ecosystem'
export class Scheduler implements SchedulerInterface {
/**
* We set this to true internally when the scheduler starts flushing. We also
* set it to true when batching updates, to prevent anything from flushing.
*/
public _isRunning?: boolean
public _isRunningNows?: boolean
// private _runStartTime?: number
/**
* The dynamic list of "full" jobs to run. Full jobs are:
*
* - EvaluateGraphNode (2)
* - UpdateExternalDependent (3)
* - RunEffect (4)
*/
private jobs: Job[] = []
/**
* The dynamic list of "now" jobs to run. Now jobs are:
*
* - UpdateStore (0)
* - InformSubscribers(1)
*/
private nows: Job[] = []
private _handle?: ReturnType<typeof setTimeout>
private _runAfterNows?: boolean
constructor(private readonly ecosystem: Ecosystem) {}
/**
* Kill any current timeout and run all jobs immediately.
*
* IMPORTANT: Setting and clearing timeouts is expensive. We need to always
* pass `shouldSetTimeout: false` to scheduler.schedule() when we're going
* to immediately flush
*/
public flush() {
if (this._isRunning) return // already flushing
if (this._handle) {
clearTimeout(this._handle)
this._handle = undefined
}
this.runJobs()
}
/**
* Insert a job into the queue. Insertion point depends on job's type and
* weight.
*
* IMPORTANT: Setting and clearing timeouts is expensive. We need to always
* pass `shouldSetTimeout: false` when we're going to immediately flush
*/
public schedule(newJob: Job, shouldSetTimeout = true) {
if (newJob.type === 4) {
// RunEffect (4) jobs run in any order, after everything else
this.jobs.push(newJob)
} else {
this.insertJob(newJob)
}
// we just pushed the first job onto the queue
if (shouldSetTimeout && this.jobs.length === 1) {
this.setTimeout()
}
}
/**
* UpdateStore (0) jobs must run immediately but also need the scheduler to be
* running all "now" jobs.
*
* InformSubscriber (1) jobs must run immediately after the current task.
*/
public scheduleNow(newJob: Job) {
if (this._isRunningNows && newJob.type === 0) return newJob.task()
this.nows[newJob.type === 1 ? 'push' : 'unshift'](newJob)
this.runJobs(true)
}
public unschedule(task: () => void) {
const index = this.jobs.findIndex(job => job.task === task)
if (index !== -1) this.jobs.splice(index, 1)
}
public wipe() {
// allow external jobs to proceed. TODO: should we flush here?
this.jobs = this.jobs.filter(
job => job.type === 3 // UpdateExternalDependent (3)
)
}
// An O(log n) replacement for this.jobs.findIndex()
private findIndex(
cb: (job: Job) => number,
index = Math.ceil(this.jobs.length / 2) - 1,
iteration = 1
): number {
const job = this.jobs[index]
if (job == null) return index
const direction = cb(job)
if (!direction) return index
const divisor = 2 ** iteration
const isDone = divisor > this.jobs.length
if (isDone) {
return index + (direction === 1 ? 1 : 0)
}
const effectualSize = Math.round(this.jobs.length / divisor)
const newIndex = Math.min(
this.jobs.length - 1,
Math.max(0, index + Math.ceil(effectualSize / 2) * direction)
)
return this.findIndex(cb, newIndex, iteration + 1)
}
/**
* Schedule an EvaluateGraphNode (2) or UpdateExternalDependent (3) job
*/
private insertJob(newJob: Job) {
const { nodes } = this.ecosystem._graph
const flags = newJob.flags ?? 0
const weight = newJob.id ? nodes[newJob.id].weight : 0
const index = this.findIndex(job => {
if (job.type !== newJob.type) return +(newJob.type - job.type > 0) || -1 // 1 or -1
// EvaluateGraphNode (2) jobs use weight comparison
if (job.id) {
const jobWeight = nodes[job.id].weight
return weight < jobWeight ? -1 : +(weight > jobWeight) // + = 0 or 1
}
// UpdateExternalDependent (3) jobs use flags comparison
return flags < (job.flags as number)
? -1
: +(flags > (job.flags as number))
})
if (index === -1) {
this.jobs.push(newJob)
} else {
this.jobs.splice(index, 0, newJob)
}
}
/**
* Run either all "full" jobs or all "now" jobs. Since the jobs are split, we
* can essentially have two schedulers running at once. "Now" jobs must always
* run before any "full" jobs, so the "full" jobs runner has to flush any
* "now"s that come up while it's flushing "full"s.
*
* Don't run "full" jobs while "now"s are running. It leads to "now"s being
* deferred until after "full"s finish. This is backwards and can lead to
* reevaluation loops.
*/
private runJobs(isNows?: boolean) {
// we prevent this function from running at all if no "full" jobs are
// scheduled
if (this._isRunningNows) {
// schedule a "full" jobs run after "now"s finish
this._runAfterNows = !isNows
return
}
const jobs = isNows ? this.nows : this.jobs
const runningKey: keyof Pick<this, '_isRunning' | '_isRunningNows'> = isNows
? '_isRunningNows'
: '_isRunning'
const nows = this.nows
// this._runStartTime = performance.now()
// let counter = 0
this[runningKey] = true
try {
while (jobs.length) {
const job = (nows.length ? nows : jobs).shift() as Job
job.task()
// this "break" idea could only break for "full" jobs, not "now" jobs
// if (!(++counter % 20) && performance.now() - this._runStartTime >= 100) {
// setTimeout(() => this.runJobs())
// break
// }
}
} finally {
this[runningKey] = false
}
if (this._runAfterNows) {
this._runAfterNows = false
this.runJobs()
}
}
private setTimeout() {
if (this._isRunning) return
this._handle = setTimeout(() => {
this._handle = undefined
this.runJobs()
})
}
}