diff --git a/packages/reactivity/src/effect.ts b/packages/reactivity/src/effect.ts index 5a4d05268dc..b48463d3bf7 100644 --- a/packages/reactivity/src/effect.ts +++ b/packages/reactivity/src/effect.ts @@ -126,10 +126,6 @@ export class ReactiveEffect * @internal */ nextEffect?: ReactiveEffect = undefined - /** - * @internal - */ - allowRecurse?: boolean scheduler?: EffectScheduler = undefined onStop?: () => void @@ -144,7 +140,10 @@ export class ReactiveEffect * @internal */ notify() { - if (this.flags & EffectFlags.RUNNING && !this.allowRecurse) { + if ( + this.flags & EffectFlags.RUNNING && + !(this.flags & EffectFlags.ALLOW_RECURSE) + ) { return } if (this.flags & EffectFlags.NO_BATCH) { diff --git a/packages/runtime-core/__tests__/scheduler.spec.ts b/packages/runtime-core/__tests__/scheduler.spec.ts index 781aa6eb0b7..079ced4bd1a 100644 --- a/packages/runtime-core/__tests__/scheduler.spec.ts +++ b/packages/runtime-core/__tests__/scheduler.spec.ts @@ -1,4 +1,6 @@ import { + type SchedulerJob, + SchedulerJobFlags, flushPostFlushCbs, flushPreFlushCbs, invalidateJob, @@ -119,12 +121,12 @@ describe('scheduler', () => { const job1 = () => { calls.push('job1') } - const cb1 = () => { + const cb1: SchedulerJob = () => { // queueJob in postFlushCb calls.push('cb1') queueJob(job1) } - cb1.pre = true + cb1.flags! |= SchedulerJobFlags.PRE queueJob(cb1) await nextTick() @@ -138,25 +140,25 @@ describe('scheduler', () => { } job1.id = 1 - const cb1 = () => { + const cb1: SchedulerJob = () => { calls.push('cb1') queueJob(job1) // cb2 should execute before the job queueJob(cb2) queueJob(cb3) } - cb1.pre = true + cb1.flags! |= SchedulerJobFlags.PRE - const cb2 = () => { + const cb2: SchedulerJob = () => { calls.push('cb2') } - cb2.pre = true + cb2.flags! |= SchedulerJobFlags.PRE cb2.id = 1 - const cb3 = () => { + const cb3: SchedulerJob = () => { calls.push('cb3') } - cb3.pre = true + cb3.flags! |= SchedulerJobFlags.PRE cb3.id = 1 queueJob(cb1) @@ -166,37 +168,37 @@ describe('scheduler', () => { it('should insert jobs after pre jobs with the same id', async () => { const calls: string[] = [] - const job1 = () => { + const job1: SchedulerJob = () => { calls.push('job1') } job1.id = 1 - job1.pre = true - const job2 = () => { + job1.flags! |= SchedulerJobFlags.PRE + const job2: SchedulerJob = () => { calls.push('job2') queueJob(job5) queueJob(job6) } job2.id = 2 - job2.pre = true - const job3 = () => { + job2.flags! |= SchedulerJobFlags.PRE + const job3: SchedulerJob = () => { calls.push('job3') } job3.id = 2 - job3.pre = true - const job4 = () => { + job3.flags! |= SchedulerJobFlags.PRE + const job4: SchedulerJob = () => { calls.push('job4') } job4.id = 3 - job4.pre = true - const job5 = () => { + job4.flags! |= SchedulerJobFlags.PRE + const job5: SchedulerJob = () => { calls.push('job5') } job5.id = 2 - const job6 = () => { + const job6: SchedulerJob = () => { calls.push('job6') } job6.id = 2 - job6.pre = true + job6.flags! |= SchedulerJobFlags.PRE // We need several jobs to test this properly, otherwise // findInsertionIndex can yield the correct index by chance @@ -221,16 +223,16 @@ describe('scheduler', () => { flushPreFlushCbs() calls.push('job1') } - const cb1 = () => { + const cb1: SchedulerJob = () => { calls.push('cb1') // a cb triggers its parent job, which should be skipped queueJob(job1) } - cb1.pre = true - const cb2 = () => { + cb1.flags! |= SchedulerJobFlags.PRE + const cb2: SchedulerJob = () => { calls.push('cb2') } - cb2.pre = true + cb2.flags! |= SchedulerJobFlags.PRE queueJob(job1) await nextTick() @@ -240,8 +242,8 @@ describe('scheduler', () => { // #3806 it('queue preFlushCb inside postFlushCb', async () => { const spy = vi.fn() - const cb = () => spy() - cb.pre = true + const cb: SchedulerJob = () => spy() + cb.flags! |= SchedulerJobFlags.PRE queuePostFlushCb(() => { queueJob(cb) }) @@ -521,25 +523,25 @@ describe('scheduler', () => { test('should allow explicitly marked jobs to trigger itself', async () => { // normal job let count = 0 - const job = () => { + const job: SchedulerJob = () => { if (count < 3) { count++ queueJob(job) } } - job.allowRecurse = true + job.flags! |= SchedulerJobFlags.ALLOW_RECURSE queueJob(job) await nextTick() expect(count).toBe(3) // post cb - const cb = () => { + const cb: SchedulerJob = () => { if (count < 5) { count++ queuePostFlushCb(cb) } } - cb.allowRecurse = true + cb.flags! |= SchedulerJobFlags.ALLOW_RECURSE queuePostFlushCb(cb) await nextTick() expect(count).toBe(5) @@ -572,7 +574,7 @@ describe('scheduler', () => { // simulate parent component that toggles child const job1 = () => { // @ts-expect-error - job2.active = false + job2.flags! |= SchedulerJobFlags.DISPOSED } // simulate child that's triggered by the same reactive change that // triggers its toggle @@ -589,11 +591,11 @@ describe('scheduler', () => { it('flushPreFlushCbs inside a pre job', async () => { const spy = vi.fn() - const job = () => { + const job: SchedulerJob = () => { spy() flushPreFlushCbs() } - job.pre = true + job.flags! |= SchedulerJobFlags.PRE queueJob(job) await nextTick() expect(spy).toHaveBeenCalledTimes(1) diff --git a/packages/runtime-core/src/apiWatch.ts b/packages/runtime-core/src/apiWatch.ts index 556688ebf4b..4f69e3068c5 100644 --- a/packages/runtime-core/src/apiWatch.ts +++ b/packages/runtime-core/src/apiWatch.ts @@ -11,7 +11,7 @@ import { isRef, isShallow, } from '@vue/reactivity' -import { type SchedulerJob, queueJob } from './scheduler' +import { type SchedulerJob, SchedulerJobFlags, queueJob } from './scheduler' import { EMPTY_OBJ, NOOP, @@ -382,7 +382,7 @@ function doWatch( // important: mark the job as a watcher callback so that scheduler knows // it is allowed to self-trigger (#1727) - job.allowRecurse = !!cb + if (cb) job.flags! |= SchedulerJobFlags.ALLOW_RECURSE const effect = new ReactiveEffect(getter) @@ -394,7 +394,7 @@ function doWatch( scheduler = () => queuePostRenderEffect(job, instance && instance.suspense) } else { // default: 'pre' - job.pre = true + job.flags! |= SchedulerJobFlags.PRE if (instance) job.id = instance.uid scheduler = () => queueJob(job) } diff --git a/packages/runtime-core/src/components/BaseTransition.ts b/packages/runtime-core/src/components/BaseTransition.ts index bbb9c32f45e..0497b9cb228 100644 --- a/packages/runtime-core/src/components/BaseTransition.ts +++ b/packages/runtime-core/src/components/BaseTransition.ts @@ -19,6 +19,7 @@ import { ErrorCodes, callWithAsyncErrorHandling } from '../errorHandling' import { PatchFlags, ShapeFlags, isArray } from '@vue/shared' import { onBeforeUnmount, onMounted } from '../apiLifecycle' import type { RendererElement } from '../renderer' +import { SchedulerJobFlags } from '../scheduler' type Hook void> = T | T[] @@ -231,7 +232,7 @@ const BaseTransitionImpl: ComponentOptions = { state.isLeaving = false // #6835 // it also needs to be updated when active is undefined - if (instance.job.active !== false) { + if (!(instance.job.flags! & SchedulerJobFlags.DISPOSED)) { instance.update() } } diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index a437771d34a..0bba1bcb0e6 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -39,13 +39,19 @@ import { } from '@vue/shared' import { type SchedulerJob, + SchedulerJobFlags, flushPostFlushCbs, flushPreFlushCbs, invalidateJob, queueJob, queuePostFlushCb, } from './scheduler' -import { ReactiveEffect, pauseTracking, resetTracking } from '@vue/reactivity' +import { + EffectFlags, + ReactiveEffect, + pauseTracking, + resetTracking, +} from '@vue/reactivity' import { updateProps } from './componentProps' import { updateSlots } from './componentSlots' import { popWarningContext, pushWarningContext, warn } from './warning' @@ -2281,7 +2287,7 @@ function baseCreateRenderer( // setup has resolved. if (job) { // so that scheduler will no longer invoke it - job.active = false + job.flags! |= SchedulerJobFlags.DISPOSED unmount(subTree, instance, parentSuspense, doRemove) } // unmounted hook @@ -2419,7 +2425,13 @@ function toggleRecurse( { effect, job }: ComponentInternalInstance, allowed: boolean, ) { - effect.allowRecurse = job.allowRecurse = allowed + if (allowed) { + effect.flags |= EffectFlags.ALLOW_RECURSE + job.flags! |= SchedulerJobFlags.ALLOW_RECURSE + } else { + effect.flags &= ~EffectFlags.ALLOW_RECURSE + job.flags! &= ~SchedulerJobFlags.ALLOW_RECURSE + } } export function needTransition( diff --git a/packages/runtime-core/src/scheduler.ts b/packages/runtime-core/src/scheduler.ts index 866f4de0fd4..e41b9e6a7cb 100644 --- a/packages/runtime-core/src/scheduler.ts +++ b/packages/runtime-core/src/scheduler.ts @@ -2,10 +2,9 @@ import { ErrorCodes, callWithErrorHandling, handleError } from './errorHandling' import { type Awaited, NOOP, isArray } from '@vue/shared' import { type ComponentInternalInstance, getComponentName } from './component' -export interface SchedulerJob extends Function { - id?: number - pre?: boolean - active?: boolean +export enum SchedulerJobFlags { + QUEUED = 1 << 0, + PRE = 1 << 1, /** * Indicates whether the effect is allowed to recursively trigger itself * when managed by the scheduler. @@ -21,7 +20,17 @@ export interface SchedulerJob extends Function { * responsibility to perform recursive state mutation that eventually * stabilizes (#1727). */ - allowRecurse?: boolean + ALLOW_RECURSE = 1 << 2, + DISPOSED = 1 << 3, +} + +export interface SchedulerJob extends Function { + id?: number + /** + * flags can technically be undefined, but it can still be used in bitwise + * operations just like 0. + */ + flags?: SchedulerJobFlags /** * Attached by renderer.ts when setting up a component's render effect * Used to obtain component information when reporting max recursive updates. @@ -69,7 +78,10 @@ function findInsertionIndex(id: number) { const middle = (start + end) >>> 1 const middleJob = queue[middle] const middleJobId = getId(middleJob) - if (middleJobId < id || (middleJobId === id && middleJob.pre)) { + if ( + middleJobId < id || + (middleJobId === id && middleJob.flags! & SchedulerJobFlags.PRE) + ) { start = middle + 1 } else { end = middle @@ -80,24 +92,22 @@ function findInsertionIndex(id: number) { } export function queueJob(job: SchedulerJob) { - // the dedupe search uses the startIndex argument of Array.includes() - // by default the search index includes the current job that is being run - // so it cannot recursively trigger itself again. - // if the job is a watch() callback, the search will start with a +1 index to - // allow it recursively trigger itself - it is the user's responsibility to - // ensure it doesn't end up in an infinite loop. - if ( - !queue.length || - !queue.includes( - job, - isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex, - ) - ) { + if (!(job.flags! & SchedulerJobFlags.QUEUED)) { if (job.id == null) { queue.push(job) + } else if ( + // fast path when the job id is larger than the tail + !(job.flags! & SchedulerJobFlags.PRE) && + job.id >= (queue[queue.length - 1]?.id || 0) + ) { + queue.push(job) } else { queue.splice(findInsertionIndex(job.id), 0, job) } + + if (!(job.flags! & SchedulerJobFlags.ALLOW_RECURSE)) { + job.flags! |= SchedulerJobFlags.QUEUED + } queueFlush() } } @@ -118,14 +128,11 @@ export function invalidateJob(job: SchedulerJob) { export function queuePostFlushCb(cb: SchedulerJobs) { if (!isArray(cb)) { - if ( - !activePostFlushCbs || - !activePostFlushCbs.includes( - cb, - cb.allowRecurse ? postFlushIndex + 1 : postFlushIndex, - ) - ) { + if (!(cb.flags! & SchedulerJobFlags.QUEUED)) { pendingPostFlushCbs.push(cb) + if (!(cb.flags! & SchedulerJobFlags.ALLOW_RECURSE)) { + cb.flags! |= SchedulerJobFlags.QUEUED + } } } else { // if cb is an array, it is a component lifecycle hook which can only be @@ -147,7 +154,7 @@ export function flushPreFlushCbs( } for (; i < queue.length; i++) { const cb = queue[i] - if (cb && cb.pre) { + if (cb && cb.flags! & SchedulerJobFlags.PRE) { if (instance && cb.id !== instance.uid) { continue } @@ -157,6 +164,7 @@ export function flushPreFlushCbs( queue.splice(i, 1) i-- cb() + cb.flags! &= ~SchedulerJobFlags.QUEUED } } } @@ -191,6 +199,7 @@ export function flushPostFlushCbs(seen?: CountMap) { continue } activePostFlushCbs[postFlushIndex]() + activePostFlushCbs[postFlushIndex].flags! &= ~SchedulerJobFlags.QUEUED } activePostFlushCbs = null postFlushIndex = 0 @@ -203,8 +212,10 @@ const getId = (job: SchedulerJob): number => const comparator = (a: SchedulerJob, b: SchedulerJob): number => { const diff = getId(a) - getId(b) if (diff === 0) { - if (a.pre && !b.pre) return -1 - if (b.pre && !a.pre) return 1 + const isAPre = a.flags! & SchedulerJobFlags.PRE + const isBPre = b.flags! & SchedulerJobFlags.PRE + if (isAPre && !isBPre) return -1 + if (isBPre && !isAPre) return 1 } return diff } @@ -237,11 +248,12 @@ function flushJobs(seen?: CountMap) { try { for (flushIndex = 0; flushIndex < queue.length; flushIndex++) { const job = queue[flushIndex] - if (job && job.active !== false) { + if (job && !(job.flags! & SchedulerJobFlags.DISPOSED)) { if (__DEV__ && check(job)) { continue } callWithErrorHandling(job, null, ErrorCodes.SCHEDULER) + job.flags! &= ~SchedulerJobFlags.QUEUED } } } finally {