diff --git a/packages/runtime-core/src/createRenderer.ts b/packages/runtime-core/src/createRenderer.ts index 215b1a62c..9b9021ce9 100644 --- a/packages/runtime-core/src/createRenderer.ts +++ b/packages/runtime-core/src/createRenderer.ts @@ -9,8 +9,8 @@ import { queueJob, handleSchedulerError, nextTick, - queuePostCommitCb, - flushPostCommitCbs, + queueEffect, + flushEffects, queueNodeOp } from '@vue/scheduler' import { VNodeFlags, ChildrenFlags } from './flags' @@ -188,12 +188,12 @@ export function createRenderer(options: RendererOptions) { insertOrAppend(container, el, endNode) } if (ref) { - queuePostCommitCb(() => { + queueEffect(() => { ref(el) }) } if (data != null && data.vnodeMounted) { - queuePostCommitCb(() => { + queueEffect(() => { data.vnodeMounted(vnode) }) } @@ -268,7 +268,7 @@ export function createRenderer(options: RendererOptions) { const subTree = (handle.prevTree = vnode.children = renderFunctionalRoot( vnode )) - queuePostCommitCb(() => { + queueEffect(() => { vnode.el = subTree.el as RenderNode }) mount(subTree, container, vnode as MountedVNode, isSVG, endNode) @@ -308,7 +308,7 @@ export function createRenderer(options: RendererOptions) { const nextTree = (handle.prevTree = current.children = renderFunctionalRoot( current )) - queuePostCommitCb(() => { + queueEffect(() => { current.el = nextTree.el }) patch( @@ -344,7 +344,7 @@ export function createRenderer(options: RendererOptions) { const { children, childFlags } = vnode switch (childFlags) { case ChildrenFlags.SINGLE_VNODE: - queuePostCommitCb(() => { + queueEffect(() => { vnode.el = (children as MountedVNode).el }) mount(children as VNode, container, contextVNode, isSVG, endNode) @@ -355,7 +355,7 @@ export function createRenderer(options: RendererOptions) { vnode.el = placeholder.el break default: - queuePostCommitCb(() => { + queueEffect(() => { vnode.el = (children as MountedVNode[])[0].el }) mountArrayChildren( @@ -392,7 +392,7 @@ export function createRenderer(options: RendererOptions) { ) } if (ref) { - queuePostCommitCb(() => { + queueEffect(() => { ref(target) }) } @@ -607,7 +607,7 @@ export function createRenderer(options: RendererOptions) { // then retrieve its next sibling to use as the end node for patchChildren. const endNode = platformNextSibling(getVNodeLastEl(prevVNode)) const { childFlags, children } = nextVNode - queuePostCommitCb(() => { + queueEffect(() => { switch (childFlags) { case ChildrenFlags.SINGLE_VNODE: nextVNode.el = (children as MountedVNode).el @@ -1280,7 +1280,7 @@ export function createRenderer(options: RendererOptions) { instance.$vnode = renderInstanceRoot(instance) as MountedVNode - queuePostCommitCb(() => { + queueEffect(() => { vnode.el = instance.$vnode.el if (__COMPAT__) { // expose __vue__ for devtools @@ -1337,7 +1337,7 @@ export function createRenderer(options: RendererOptions) { const nextVNode = renderInstanceRoot(instance) as MountedVNode - queuePostCommitCb(() => { + queueEffect(() => { instance.$vnode = nextVNode const el = nextVNode.el as RenderNode if (__COMPAT__) { @@ -1426,7 +1426,7 @@ export function createRenderer(options: RendererOptions) { if (__DEV__) { popWarningContext() } - queuePostCommitCb(() => { + queueEffect(() => { callActivatedHook(instance, true) }) } @@ -1510,7 +1510,7 @@ export function createRenderer(options: RendererOptions) { } } if (__COMPAT__) { - flushPostCommitCbs() + flushEffects() return vnode && vnode.flags & VNodeFlags.COMPONENT_STATEFUL ? (vnode.children as ComponentInstance).$proxy : null diff --git a/packages/scheduler/__tests__/scheduler.spec.ts b/packages/scheduler/__tests__/scheduler.spec.ts index 159e0e614..e18881e60 100644 --- a/packages/scheduler/__tests__/scheduler.spec.ts +++ b/packages/scheduler/__tests__/scheduler.spec.ts @@ -1,4 +1,4 @@ -import { queueJob, queuePostCommitCb, nextTick } from '../src/index' +import { queueJob, queueEffect, nextTick } from '../src/index' describe('scheduler', () => { it('queueJob', async () => { @@ -36,11 +36,11 @@ describe('scheduler', () => { const calls: any = [] const job1 = () => { calls.push('job1') - queuePostCommitCb(cb1) + queueEffect(cb1) } const job2 = () => { calls.push('job2') - queuePostCommitCb(cb2) + queueEffect(cb2) } const cb1 = () => { calls.push('cb1') @@ -59,13 +59,13 @@ describe('scheduler', () => { const calls: any = [] const job1 = () => { calls.push('job1') - queuePostCommitCb(cb1) + queueEffect(cb1) // job1 queues job2 queueJob(job2) } const job2 = () => { calls.push('job2') - queuePostCommitCb(cb2) + queueEffect(cb2) } const cb1 = () => { calls.push('cb1') @@ -100,7 +100,7 @@ describe('scheduler', () => { const calls: any = [] const job1 = () => { calls.push('job1') - queuePostCommitCb(cb1) + queueEffect(cb1) } const cb1 = () => { // queue another job in postFlushCb @@ -109,7 +109,7 @@ describe('scheduler', () => { } const job2 = () => { calls.push('job2') - queuePostCommitCb(cb2) + queueEffect(cb2) } const cb2 = () => { calls.push('cb2') diff --git a/packages/scheduler/src/index.ts b/packages/scheduler/src/index.ts index aaadb567e..73ae5a883 100644 --- a/packages/scheduler/src/index.ts +++ b/packages/scheduler/src/index.ts @@ -1,51 +1,150 @@ // 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 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 } +// 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 + +// 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 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[] = [] + +// 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 currentJob: Job | null = null - -let start: number = 0 -const getNow = () => performance.now() -const frameBudget = __JSDOM__ ? Infinity : 1000 / 60 - -const patchQueue: Job[] = [] -const commitQueue: Job[] = [] -const postCommitQueue: Function[] = [] -const nextTickQueue: Function[] = [] - 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( @@ -54,7 +153,7 @@ window.addEventListener( if (event.source !== window || event.data !== key) { return } - start = getNow() + flushStartTimestamp = getNow() try { flush() } catch (e) { @@ -68,6 +167,65 @@ function flushAfterMacroTask() { window.postMessage(key, `*`) } +// 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) + } + // Let's see if this invalidates any work that + // has already been staged. + if (job.status === JobStatus.PENDING_COMMIT) { + // staged job invalidated + invalidateJob(job) + // re-insert it into the stage queue + requeueInvalidatedJob(job) + } else if (job.status !== JobStatus.PENDING_STAGE) { + // a new job + queueJobForStaging(job) + } + if (!hasPendingFlush) { + hasPendingFlush = true + flushAfterMicroTask() + } +} + +export function queueEffect(fn: Function) { + if (currentJob) { + currentJob.effects.push(fn) + } else { + postEffectsQueue.push(fn) + } +} + +export function flushEffects() { + // post commit hooks (updated, mounted) + // this queue is flushed in reverse becuase these hooks should be invoked + // child first + let i = postEffectsQueue.length + while (i--) { + postEffectsQueue[i]() + } + postEffectsQueue.length = 0 +} + +export function queueNodeOp(op: Op) { + if (currentJob) { + currentJob.ops.push(op) + } else { + applyOp(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(fn?: () => T): Promise { return new Promise((resolve, reject) => { p.then(() => { @@ -86,108 +244,35 @@ export function nextTick(fn?: () => T): Promise { }) } -function handleError(err: Error) { - if (globalHandler) globalHandler(err) - pendingRejectors.forEach(handler => { - handler(err) - }) -} - -export function handleSchedulerError(handler: ErrorHandler) { - globalHandler = handler -} - -let hasPendingFlush = false - -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. - if (job.status === JobStatus.PENDING_COMMIT) { - // pending commit job invalidated - invalidateJob(job) - requeueInvalidatedJob(job) - } else if (job.status !== JobStatus.PENDING_PATCH) { - // a new job - insertNewJob(job) - } - if (!hasPendingFlush) { - hasPendingFlush = true - flushAfterMicroTask() - } -} - -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) { - if (currentJob) { - currentJob.post.push(fn) - } else { - postCommitQueue.push(fn) - } -} - -export function flushPostCommitCbs() { - // post commit hooks (updated, mounted) - // this queue is flushed in reverse becuase these hooks should be invoked - // child first - let i = postCommitQueue.length - while (i--) { - postCommitQueue[i]() - } - postCommitQueue.length = 0 -} - -export function queueNodeOp(op: Op) { - if (currentJob) { - currentJob.ops.push(op) - } else { - applyOp(op) - } -} +// 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 @@ -203,29 +288,29 @@ function flush(): void { 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) { @@ -235,8 +320,8 @@ 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 } } @@ -250,7 +335,21 @@ function invalidateJob(job: Job) { 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 @@ -262,13 +361,13 @@ function patchJob(job: 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