// TODO infinite updates detection
+// A data structure that stores a deferred DOM operation.
+// the first element is the function to call, and the rest of the array
+// stores up to 3 arguments.
type Op = [Function, ...any[]]
-const enum Priorities {
- NORMAL = 500
+// A "job" stands for a unit of work that needs to be performed.
+// Typically, one job corresponds to the mounting or updating of one component
+// instance (including functional ones).
+interface Job<T extends Function = () => void> {
+ // A job is itself a function that performs work. It can contain work such as
+ // calling render functions, running the diff algorithm (patch), mounting new
+ // vnodes, and tearing down old vnodes. However, these work needs to be
+ // performed in several different phases, most importantly to separate
+ // workloads that do not produce side-effects ("stage") vs. those that do
+ // ("commit").
+ // During the stage call it should not perform any direct sife-effects.
+ // Instead, it buffers them. All side effects from multiple jobs queued in the
+ // same tick are flushed together during the "commit" phase. This allows us to
+ // perform side-effect-free work over multiple frames (yielding to the browser
+ // in-between to keep the app responsive), and only flush all the side effects
+ // together when all work is done (AKA time-slicing).
+ (): T
+ // A job's status changes over the different update phaes. See comments for
+ // phases below.
+ status: JobStatus
+ // Any operations performed by the job that directly mutates the DOM are
+ // buffered inside the job's ops queue, and only flushed in the commit phase.
+ // These ops are queued by calling `queueNodeOp` inside the job function.
+ ops: Op[]
+ // Any post DOM mutation side-effects (updated / mounted hooks, refs) are
+ // buffered inside the job's effects queue.
+ // Effects are queued by calling `queueEffect` inside the job function.
+ effects: Function[]
+ // A job may queue other jobs (e.g. a parent component update triggers the
+ // update of a child component). Jobs queued by another job is kept in the
+ // parent's children array, so that in case the parent job is invalidated,
+ // all its children can be invalidated as well (recursively).
+ children: Job[]
+ // Sometimes it's inevitable for a stage fn to produce some side effects
+ // (e.g. a component instance sets up an Autorun). In those cases the stage fn
+ // can return a cleanup function which will be called when the job is
+ // invalidated.
+ cleanup: T | null
+ // The expiration time is a timestamp past which the job needs to
+ // be force-committed regardless of frame budget.
+ // Why do we need an expiration time? Because a job may get invalidated before
+ // it is fully commited. If it keeps getting invalidated, we may "starve" the
+ // system and never apply any commits as jobs keep getting invalidated. The
+ // expiration time sets a limit on how long before a job can keep getting
+ // invalidated before it must be comitted.
+ expiration: number
}
const enum JobStatus {
IDLE = 0,
- PENDING_PATCH,
+ PENDING_STAGE,
PENDING_COMMIT
}
-interface Job extends Function {
- status: JobStatus
- ops: Op[]
- post: Function[]
- children: Job[]
- cleanup: Function | null
- expiration: number
+// Priorities for different types of jobs. This number is added to the
+// current time when a new job is queued to calculate the expiration time
+// for that job.
+//
+// Currently we have only one type which expires 500ms after it is initially
+// queued. There could be higher/lower priorities in the future.
+const enum JobPriorities {
+ NORMAL = 500
}
-type ErrorHandler = (err: Error) => any
-
+// There can be only one job being patched at one time. This allows us to
+// automatically "capture" and buffer the node ops and post effects queued
+// during a job.
let currentJob: Job | null = null
-let start: number = 0
-const getNow = () => performance.now()
+// Indicates we have a flush pending.
+let hasPendingFlush = false
+
+// A timestamp that indicates when a flush was started.
+let flushStartTimestamp: number = 0
+
+// The frame budget is the maximum amount of time passed while performing
+// "stage" work before we need to yield back to the browser.
+// Aiming for 60fps. Maybe we need to dynamically adjust this?
const frameBudget = __JSDOM__ ? Infinity : 1000 / 60
-const patchQueue: Job[] = []
+const getNow = () => performance.now()
+
+// An entire update consists of 4 phases:
+
+// 1. Stage phase. Render functions are called, diffs are performed, new
+// component instances are created. However, no side-effects should be
+// performed (i.e. no lifecycle hooks, no direct DOM operations).
+const stageQueue: Job[] = []
+
+// 2. Commit phase. This is only reached when the stageQueue has been depleted.
+// Node ops are applied - in the browser, this means DOM is actually mutated
+// during this phase. If a job is committed, it's post effects are then
+// queued for the next phase.
const commitQueue: Job[] = []
-const postCommitQueue: Function[] = []
+
+// 3. Post-commit effects phase. Effect callbacks are only queued after a
+// successful commit. These include callbacks that need to be invoked
+// after DOM mutation - i.e. refs, mounted & updated hooks. This queue is
+// flushed in reverse because child component effects are queued after but
+// should be invoked before the parent's.
+const postEffectsQueue: Function[] = []
+
+// 4. NextTick phase. This is the user's catch-all mechanism for deferring
+// work after a complete update cycle.
const nextTickQueue: Function[] = []
+const pendingRejectors: ErrorHandler[] = []
+
+// Error handling --------------------------------------------------------------
+
+type ErrorHandler = (err: Error) => any
let globalHandler: ErrorHandler
-const pendingRejectors: ErrorHandler[] = []
-// Microtask for batching state mutations
+export function handleSchedulerError(handler: ErrorHandler) {
+ globalHandler = handler
+}
+
+function handleError(err: Error) {
+ if (globalHandler) globalHandler(err)
+ pendingRejectors.forEach(handler => {
+ handler(err)
+ })
+}
+
+// Microtask defer -------------------------------------------------------------
+// For batching state mutations before we start an update. This does
+// NOT yield to the browser.
+
const p = Promise.resolve()
function flushAfterMicroTask() {
- start = getNow()
+ flushStartTimestamp = getNow()
return p.then(flush).catch(handleError)
}
-// Macrotask for time slicing
+// Macrotask defer -------------------------------------------------------------
+// For time slicing. This uses the window postMessage event to "yield"
+// to the browser so that other user events can trigger in between. This keeps
+// the app responsive even when performing large amount of JavaScript work.
+
const key = `$vueTick`
window.addEventListener(
if (event.source !== window || event.data !== key) {
return
}
- start = getNow()
+ flushStartTimestamp = getNow()
try {
flush()
} catch (e) {
window.postMessage(key, `*`)
}
-export function nextTick<T>(fn?: () => T): Promise<T> {
- return new Promise((resolve, reject) => {
- p.then(() => {
- if (hasPendingFlush) {
- nextTickQueue.push(() => {
- resolve(fn ? fn() : undefined)
- })
- pendingRejectors.push(err => {
- if (fn) fn()
- reject(err)
- })
- } else {
- resolve(fn ? fn() : undefined)
- }
- }).catch(reject)
- })
-}
-
-function handleError(err: Error) {
- if (globalHandler) globalHandler(err)
- pendingRejectors.forEach(handler => {
- handler(err)
- })
-}
-
-export function handleSchedulerError(handler: ErrorHandler) {
- globalHandler = handler
-}
-
-let hasPendingFlush = false
+// API -------------------------------------------------------------------------
+// This is the main API of the scheduler. The raw job can actually be any
+// function, but since they are invalidated by identity, it is important that
+// a component's update job is a consistent function across its lifecycle -
+// in the renderer, it's actually instance._updateHandle which is in turn
+// an Autorun function.
export function queueJob(rawJob: Function) {
const job = rawJob as Job
if (currentJob) {
currentJob.children.push(job)
}
- // 1. let's see if this invalidates any work that
- // has already been done.
+ // Let's see if this invalidates any work that
+ // has already been staged.
if (job.status === JobStatus.PENDING_COMMIT) {
- // pending commit job invalidated
+ // staged job invalidated
invalidateJob(job)
+ // re-insert it into the stage queue
requeueInvalidatedJob(job)
- } else if (job.status !== JobStatus.PENDING_PATCH) {
+ } else if (job.status !== JobStatus.PENDING_STAGE) {
// a new job
- insertNewJob(job)
+ queueJobForStaging(job)
}
if (!hasPendingFlush) {
hasPendingFlush = true
}
}
-function requeueInvalidatedJob(job: Job) {
- // With varying priorities we should insert job at correct position
- // based on expiration time.
- for (let i = 0; i < patchQueue.length; i++) {
- if (job.expiration < patchQueue[i].expiration) {
- patchQueue.splice(i, 0, job)
- job.status = JobStatus.PENDING_PATCH
- return
- }
- }
- patchQueue.push(job)
- job.status = JobStatus.PENDING_PATCH
-}
-
-export function queuePostCommitCb(fn: Function) {
+export function queueEffect(fn: Function) {
if (currentJob) {
- currentJob.post.push(fn)
+ currentJob.effects.push(fn)
} else {
- postCommitQueue.push(fn)
+ postEffectsQueue.push(fn)
}
}
-export function flushPostCommitCbs() {
+export function flushEffects() {
// post commit hooks (updated, mounted)
// this queue is flushed in reverse becuase these hooks should be invoked
// child first
- let i = postCommitQueue.length
+ let i = postEffectsQueue.length
while (i--) {
- postCommitQueue[i]()
+ postEffectsQueue[i]()
}
- postCommitQueue.length = 0
+ postEffectsQueue.length = 0
}
export function queueNodeOp(op: Op) {
}
}
+// The original nextTick now needs to be reworked so that the callback only
+// triggers after the next commit, when all node ops and post effects have been
+// completed.
+export function nextTick<T>(fn?: () => T): Promise<T> {
+ return new Promise((resolve, reject) => {
+ p.then(() => {
+ if (hasPendingFlush) {
+ nextTickQueue.push(() => {
+ resolve(fn ? fn() : undefined)
+ })
+ pendingRejectors.push(err => {
+ if (fn) fn()
+ reject(err)
+ })
+ } else {
+ resolve(fn ? fn() : undefined)
+ }
+ }).catch(reject)
+ })
+}
+
+// Internals -------------------------------------------------------------------
+
function flush(): void {
let job
while (true) {
- job = patchQueue.shift()
+ job = stageQueue.shift()
if (job) {
- patchJob(job)
+ stageJob(job)
} else {
break
}
if (!__COMPAT__) {
const now = getNow()
- if (now - start > frameBudget && job.expiration > now) {
+ if (now - flushStartTimestamp > frameBudget && job.expiration > now) {
break
}
}
}
- if (patchQueue.length === 0) {
+ if (stageQueue.length === 0) {
// all done, time to commit!
for (let i = 0; i < commitQueue.length; i++) {
commitJob(commitQueue[i])
}
commitQueue.length = 0
- flushPostCommitCbs()
+ flushEffects()
// some post commit hook triggered more updates...
- if (patchQueue.length > 0) {
- if (!__COMPAT__ && getNow() - start > frameBudget) {
+ if (stageQueue.length > 0) {
+ if (!__COMPAT__ && getNow() - flushStartTimestamp > frameBudget) {
return flushAfterMacroTask()
} else {
// not out of budget yet, flush sync
nextTickQueue.length = 0
} else {
// got more job to do
- // shouldn't reach here in compat mode, because the patchQueue is
- // guarunteed to be drained
+ // shouldn't reach here in compat mode, because the stageQueue is
+ // guarunteed to have been depleted
flushAfterMacroTask()
}
}
function resetJob(job: Job) {
job.ops.length = 0
- job.post.length = 0
+ job.effects.length = 0
job.children.length = 0
}
-function insertNewJob(job: Job) {
+function queueJobForStaging(job: Job) {
job.ops = job.ops || []
- job.post = job.post || []
+ job.effects = job.effects || []
job.children = job.children || []
resetJob(job)
// inherit parent job's expiration deadline
job.expiration = currentJob
? currentJob.expiration
- : getNow() + Priorities.NORMAL
- patchQueue.push(job)
- job.status = JobStatus.PENDING_PATCH
+ : getNow() + JobPriorities.NORMAL
+ stageQueue.push(job)
+ job.status = JobStatus.PENDING_STAGE
}
function invalidateJob(job: Job) {
const child = children[i]
if (child.status === JobStatus.PENDING_COMMIT) {
invalidateJob(child)
- } else if (child.status === JobStatus.PENDING_PATCH) {
- patchQueue.splice(patchQueue.indexOf(child), 1)
+ } else if (child.status === JobStatus.PENDING_STAGE) {
+ stageQueue.splice(stageQueue.indexOf(child), 1)
child.status = JobStatus.IDLE
}
}
job.status = JobStatus.IDLE
}
-function patchJob(job: Job) {
+function requeueInvalidatedJob(job: Job) {
+ // With varying priorities we should insert job at correct position
+ // based on expiration time.
+ for (let i = 0; i < stageQueue.length; i++) {
+ if (job.expiration < stageQueue[i].expiration) {
+ stageQueue.splice(i, 0, job)
+ job.status = JobStatus.PENDING_STAGE
+ return
+ }
+ }
+ stageQueue.push(job)
+ job.status = JobStatus.PENDING_STAGE
+}
+
+function stageJob(job: Job) {
// job with existing ops means it's already been patched in a low priority queue
if (job.ops.length === 0) {
currentJob = job
}
function commitJob(job: Job) {
- const { ops, post } = job
+ const { ops, effects } = job
for (let i = 0; i < ops.length; i++) {
applyOp(ops[i])
}
// queue post commit cbs
- if (post) {
- postCommitQueue.push(...post)
+ if (effects) {
+ postEffectsQueue.push(...effects)
}
resetJob(job)
job.status = JobStatus.IDLE