diff --git a/packages/reactivity/__tests__/computed.spec.ts b/packages/reactivity/__tests__/computed.spec.ts index db2984cc1ef..31562cf22b3 100644 --- a/packages/reactivity/__tests__/computed.spec.ts +++ b/packages/reactivity/__tests__/computed.spec.ts @@ -27,7 +27,7 @@ import { } from '../src' import type { ComputedRef, ComputedRefImpl } from '../src/computed' import { pauseTracking, resetTracking } from '../src/effect' -import { SubscriberFlags } from '../src/system' +import { ReactiveFlags } from '../src/system' describe('reactivity/computed', () => { it('should return updated value', () => { @@ -467,12 +467,8 @@ describe('reactivity/computed', () => { const c2 = computed(() => c1.value) as unknown as ComputedRefImpl c2.value - expect( - c1.flags & (SubscriberFlags.Dirty | SubscriberFlags.PendingComputed), - ).toBe(0) - expect( - c2.flags & (SubscriberFlags.Dirty | SubscriberFlags.PendingComputed), - ).toBe(0) + expect(c1.flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending)).toBe(0) + expect(c2.flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending)).toBe(0) }) it('should chained computeds dirtyLevel update with first computed effect', () => { diff --git a/packages/reactivity/__tests__/effect.spec.ts b/packages/reactivity/__tests__/effect.spec.ts index 20f0244a7bc..5d7a1e39cef 100644 --- a/packages/reactivity/__tests__/effect.spec.ts +++ b/packages/reactivity/__tests__/effect.spec.ts @@ -22,7 +22,7 @@ import { stop, toRaw, } from '../src/index' -import { type Dependency, endBatch, startBatch } from '../src/system' +import { type ReactiveNode, endBatch, startBatch } from '../src/system' describe('reactivity/effect', () => { it('should run the passed function once (wrapped by a effect)', () => { @@ -1178,7 +1178,7 @@ describe('reactivity/effect', () => { }) describe('dep unsubscribe', () => { - function getSubCount(dep: Dependency | undefined) { + function getSubCount(dep: ReactiveNode | undefined) { let count = 0 let sub = dep!.subs while (sub) { diff --git a/packages/reactivity/__tests__/effectScope.spec.ts b/packages/reactivity/__tests__/effectScope.spec.ts index 84310b985f2..93ee648e2df 100644 --- a/packages/reactivity/__tests__/effectScope.spec.ts +++ b/packages/reactivity/__tests__/effectScope.spec.ts @@ -2,6 +2,7 @@ import { nextTick, watch, watchEffect } from '@vue/runtime-core' import { type ComputedRef, EffectScope, + ReactiveEffect, computed, effect, effectScope, @@ -9,6 +10,7 @@ import { onScopeDispose, reactive, ref, + setCurrentScope, } from '../src' describe('reactivity/effect/scope', () => { @@ -20,7 +22,7 @@ describe('reactivity/effect/scope', () => { it('should accept zero argument', () => { const scope = effectScope() - expect(scope.effects.length).toBe(0) + expect(getEffectsCount(scope)).toBe(0) }) it('should return run value', () => { @@ -29,7 +31,8 @@ describe('reactivity/effect/scope', () => { it('should work w/ active property', () => { const scope = effectScope() - scope.run(() => 1) + const src = computed(() => 1) + scope.run(() => src.value) expect(scope.active).toBe(true) scope.stop() expect(scope.active).toBe(false) @@ -47,7 +50,7 @@ describe('reactivity/effect/scope', () => { expect(dummy).toBe(7) }) - expect(scope.effects.length).toBe(1) + expect(getEffectsCount(scope)).toBe(1) }) it('stop', () => { @@ -60,7 +63,7 @@ describe('reactivity/effect/scope', () => { effect(() => (doubled = counter.num * 2)) }) - expect(scope.effects.length).toBe(2) + expect(getEffectsCount(scope)).toBe(2) expect(dummy).toBe(0) counter.num = 7 @@ -87,9 +90,8 @@ describe('reactivity/effect/scope', () => { }) }) - expect(scope.effects.length).toBe(1) - expect(scope.scopes!.length).toBe(1) - expect(scope.scopes![0]).toBeInstanceOf(EffectScope) + expect(getEffectsCount(scope)).toBe(1) + expect(scope.deps?.nextDep?.dep).toBeInstanceOf(EffectScope) expect(dummy).toBe(0) counter.num = 7 @@ -117,7 +119,7 @@ describe('reactivity/effect/scope', () => { }) }) - expect(scope.effects.length).toBe(1) + expect(getEffectsCount(scope)).toBe(1) expect(dummy).toBe(0) counter.num = 7 @@ -142,13 +144,13 @@ describe('reactivity/effect/scope', () => { effect(() => (dummy = counter.num)) }) - expect(scope.effects.length).toBe(1) + expect(getEffectsCount(scope)).toBe(1) scope.run(() => { effect(() => (doubled = counter.num * 2)) }) - expect(scope.effects.length).toBe(2) + expect(getEffectsCount(scope)).toBe(2) counter.num = 7 expect(dummy).toBe(7) @@ -166,21 +168,21 @@ describe('reactivity/effect/scope', () => { effect(() => (dummy = counter.num)) }) - expect(scope.effects.length).toBe(1) + expect(getEffectsCount(scope)).toBe(1) scope.stop() + expect(getEffectsCount(scope)).toBe(0) + scope.run(() => { effect(() => (doubled = counter.num * 2)) }) - expect('[Vue warn] cannot run an inactive effect scope.').toHaveBeenWarned() - - expect(scope.effects.length).toBe(0) + expect(getEffectsCount(scope)).toBe(1) counter.num = 7 expect(dummy).toBe(0) - expect(doubled).toBe(undefined) + expect(doubled).toBe(14) }) it('should fire onScopeDispose hook', () => { @@ -224,9 +226,9 @@ describe('reactivity/effect/scope', () => { it('should dereference child scope from parent scope after stopping child scope (no memleaks)', () => { const parent = effectScope() const child = parent.run(() => effectScope())! - expect(parent.scopes!.includes(child)).toBe(true) + expect(parent.deps?.dep).toBe(child) child.stop() - expect(parent.scopes!.includes(child)).toBe(false) + expect(parent.deps).toBeUndefined() }) it('test with higher level APIs', async () => { @@ -290,21 +292,7 @@ describe('reactivity/effect/scope', () => { parentScope.run(() => { const childScope = effectScope(true) - childScope.on() - childScope.off() - expect(getCurrentScope()).toBe(parentScope) - }) - }) - - it('calling on() and off() multiple times inside an active scope should not break currentScope', () => { - const parentScope = effectScope() - parentScope.run(() => { - const childScope = effectScope(true) - childScope.on() - childScope.on() - childScope.off() - childScope.off() - childScope.off() + setCurrentScope(setCurrentScope(childScope)) expect(getCurrentScope()).toBe(parentScope) }) }) @@ -372,7 +360,17 @@ describe('reactivity/effect/scope', () => { expect(watcherCalls).toBe(3) expect(cleanupCalls).toBe(1) - expect(scope.effects.length).toBe(0) - expect(scope.cleanups.length).toBe(0) + expect(getEffectsCount(scope)).toBe(0) + expect(scope.cleanupsLength).toBe(0) }) }) + +function getEffectsCount(scope: EffectScope): number { + let n = 0 + for (let dep = scope.deps; dep !== undefined; dep = dep.nextDep) { + if (dep.dep instanceof ReactiveEffect) { + n++ + } + } + return n +} diff --git a/packages/reactivity/__tests__/watch.spec.ts b/packages/reactivity/__tests__/watch.spec.ts index 245acfd63be..4b614be2e7a 100644 --- a/packages/reactivity/__tests__/watch.spec.ts +++ b/packages/reactivity/__tests__/watch.spec.ts @@ -3,40 +3,12 @@ import { type Ref, WatchErrorCodes, type WatchOptions, - type WatchScheduler, computed, onWatcherCleanup, ref, watch, } from '../src' -const queue: (() => void)[] = [] - -// a simple scheduler for testing purposes -let isFlushPending = false -const resolvedPromise = /*@__PURE__*/ Promise.resolve() as Promise -const nextTick = (fn?: () => any) => - fn ? resolvedPromise.then(fn) : resolvedPromise - -const scheduler: WatchScheduler = (job, isFirstRun) => { - if (isFirstRun) { - job() - } else { - queue.push(job) - flushJobs() - } -} - -const flushJobs = () => { - if (isFlushPending) return - isFlushPending = true - resolvedPromise.then(() => { - queue.forEach(job => job()) - queue.length = 0 - isFlushPending = false - }) -} - describe('watch', () => { test('effect', () => { let dummy: any @@ -147,54 +119,6 @@ describe('watch', () => { expect(dummy).toBe(30) }) - test('nested calls to baseWatch and onWatcherCleanup', async () => { - let calls: string[] = [] - let source: Ref - let copyist: Ref - const scope = new EffectScope() - - scope.run(() => { - source = ref(0) - copyist = ref(0) - // sync by default - watch( - () => { - const current = (copyist.value = source.value) - onWatcherCleanup(() => calls.push(`sync ${current}`)) - }, - null, - {}, - ) - // with scheduler - watch( - () => { - const current = copyist.value - onWatcherCleanup(() => calls.push(`post ${current}`)) - }, - null, - { scheduler }, - ) - }) - - await nextTick() - expect(calls).toEqual([]) - - scope.run(() => source.value++) - expect(calls).toEqual(['sync 0']) - await nextTick() - expect(calls).toEqual(['sync 0', 'post 0']) - calls.length = 0 - - scope.run(() => source.value++) - expect(calls).toEqual(['sync 1']) - await nextTick() - expect(calls).toEqual(['sync 1', 'post 1']) - calls.length = 0 - - scope.stop() - expect(calls).toEqual(['sync 2', 'post 2']) - }) - test('once option should be ignored by simple watch', async () => { let dummy: any const source = ref(0) diff --git a/packages/reactivity/src/arrayInstrumentations.ts b/packages/reactivity/src/arrayInstrumentations.ts index 8d578c7d860..5e35230efdf 100644 --- a/packages/reactivity/src/arrayInstrumentations.ts +++ b/packages/reactivity/src/arrayInstrumentations.ts @@ -1,9 +1,8 @@ import { isArray } from '@vue/shared' import { TrackOpTypes } from './constants' import { ARRAY_ITERATE_KEY, track } from './dep' -import { pauseTracking, resetTracking } from './effect' import { isProxy, isShallow, toRaw, toReactive } from './reactive' -import { endBatch, startBatch } from './system' +import { endBatch, setActiveSub, startBatch } from './system' /** * Track array iteration and return: @@ -320,10 +319,10 @@ function noTracking( method: keyof Array, args: unknown[] = [], ) { - pauseTracking() startBatch() + const prevSub = setActiveSub() const res = (toRaw(self) as any)[method].apply(self, args) + setActiveSub(prevSub) endBatch() - resetTracking() return res } diff --git a/packages/reactivity/src/computed.ts b/packages/reactivity/src/computed.ts index ad518f3c5e6..cb367a274b3 100644 --- a/packages/reactivity/src/computed.ts +++ b/packages/reactivity/src/computed.ts @@ -1,24 +1,19 @@ import { hasChanged, isFunction } from '@vue/shared' import { ReactiveFlags, TrackOpTypes } from './constants' import { onTrack, setupOnTrigger } from './debug' -import { - type DebuggerEvent, - type DebuggerOptions, - activeSub, - setActiveSub, -} from './effect' +import type { DebuggerEvent, DebuggerOptions } from './effect' import { activeEffectScope } from './effectScope' import type { Ref } from './ref' import { - type Dependency, type Link, - type Subscriber, - SubscriberFlags, + type ReactiveNode, + ReactiveFlags as SystemReactiveFlags, + activeSub, + checkDirty, endTracking, link, - processComputedUpdate, + shallowPropagate, startTracking, - updateDirtyFlag, } from './system' import { warn } from './warning' @@ -53,20 +48,18 @@ export interface WritableComputedOptions { * @private exported by @vue/reactivity for Vue core use, but not exported from * the main vue package */ -export class ComputedRefImpl implements Dependency, Subscriber { +export class ComputedRefImpl implements ReactiveNode { /** * @internal */ _value: T | undefined = undefined - // Dependency subs: Link | undefined = undefined subsTail: Link | undefined = undefined - - // Subscriber deps: Link | undefined = undefined depsTail: Link | undefined = undefined - flags: SubscriberFlags = SubscriberFlags.Computed | SubscriberFlags.Dirty + flags: SystemReactiveFlags = + SystemReactiveFlags.Mutable | SystemReactiveFlags.Dirty /** * @internal @@ -84,7 +77,7 @@ export class ComputedRefImpl implements Dependency, Subscriber { return this } // for backwards compat - get dep(): Dependency { + get dep(): ReactiveNode { return this } /** @@ -93,13 +86,17 @@ export class ComputedRefImpl implements Dependency, Subscriber { */ get _dirty(): boolean { const flags = this.flags - if ( - flags & SubscriberFlags.Dirty || - (flags & SubscriberFlags.PendingComputed && - updateDirtyFlag(this, this.flags)) - ) { + if (flags & SystemReactiveFlags.Dirty) { return true } + if (flags & SystemReactiveFlags.Pending) { + if (checkDirty(this.deps!, this)) { + this.flags = flags | SystemReactiveFlags.Dirty + return true + } else { + this.flags = flags & ~SystemReactiveFlags.Pending + } + } return false } /** @@ -108,9 +105,9 @@ export class ComputedRefImpl implements Dependency, Subscriber { */ set _dirty(v: boolean) { if (v) { - this.flags |= SubscriberFlags.Dirty + this.flags |= SystemReactiveFlags.Dirty } else { - this.flags &= ~(SubscriberFlags.Dirty | SubscriberFlags.PendingComputed) + this.flags &= ~(SystemReactiveFlags.Dirty | SystemReactiveFlags.Pending) } } @@ -128,8 +125,18 @@ export class ComputedRefImpl implements Dependency, Subscriber { get value(): T { const flags = this.flags - if (flags & (SubscriberFlags.Dirty | SubscriberFlags.PendingComputed)) { - processComputedUpdate(this, flags) + if ( + flags & SystemReactiveFlags.Dirty || + (flags & SystemReactiveFlags.Pending && checkDirty(this.deps!, this)) + ) { + if (this.update()) { + const subs = this.subs + if (subs !== undefined) { + shallowPropagate(subs) + } + } + } else if (flags & SystemReactiveFlags.Pending) { + this.flags = flags & ~SystemReactiveFlags.Pending } if (activeSub !== undefined) { if (__DEV__) { @@ -155,9 +162,7 @@ export class ComputedRefImpl implements Dependency, Subscriber { } update(): boolean { - const prevSub = activeSub - setActiveSub(this) - startTracking(this) + const prevSub = startTracking(this) try { const oldValue = this._value const newValue = this.fn(oldValue) @@ -167,8 +172,7 @@ export class ComputedRefImpl implements Dependency, Subscriber { } return false } finally { - setActiveSub(prevSub) - endTracking(this) + endTracking(this, prevSub) } } } diff --git a/packages/reactivity/src/debug.ts b/packages/reactivity/src/debug.ts index 5503dc8a11b..ba323d8993c 100644 --- a/packages/reactivity/src/debug.ts +++ b/packages/reactivity/src/debug.ts @@ -1,6 +1,6 @@ import { extend } from '@vue/shared' import type { DebuggerEventExtraInfo, ReactiveEffectOptions } from './effect' -import { type Link, type Subscriber, SubscriberFlags } from './system' +import { type Link, ReactiveFlags, type ReactiveNode } from './system' export const triggerEventInfos: DebuggerEventExtraInfo[] = [] @@ -61,7 +61,7 @@ export function setupOnTrigger(target: { new (...args: any[]): any }): void { }) } -function setupFlagsHandler(target: Subscriber): void { +function setupFlagsHandler(target: ReactiveNode): void { ;(target as any)._flags = target.flags Object.defineProperty(target, 'flags', { get() { @@ -69,8 +69,11 @@ function setupFlagsHandler(target: Subscriber): void { }, set(value) { if ( - !((target as any)._flags & SubscriberFlags.Propagated) && - !!(value & SubscriberFlags.Propagated) + !( + (target as any)._flags & + (ReactiveFlags.Dirty | ReactiveFlags.Pending) + ) && + !!(value & (ReactiveFlags.Dirty | ReactiveFlags.Pending)) ) { onTrigger(this) } diff --git a/packages/reactivity/src/dep.ts b/packages/reactivity/src/dep.ts index 184964c17b8..a24000329c1 100644 --- a/packages/reactivity/src/dep.ts +++ b/packages/reactivity/src/dep.ts @@ -1,19 +1,22 @@ import { isArray, isIntegerKey, isMap, isSymbol } from '@vue/shared' import { type TrackOpTypes, TriggerOpTypes } from './constants' import { onTrack, triggerEventInfos } from './debug' -import { activeSub } from './effect' import { - type Dependency, type Link, + ReactiveFlags, + type ReactiveNode, + activeSub, endBatch, link, propagate, + shallowPropagate, startBatch, } from './system' -class Dep implements Dependency { +class Dep implements ReactiveNode { _subs: Link | undefined = undefined subsTail: Link | undefined = undefined + flags: ReactiveFlags = ReactiveFlags.None constructor( private map: KeyToDepMap, @@ -103,7 +106,7 @@ export function trigger( return } - const run = (dep: Dependency | undefined) => { + const run = (dep: ReactiveNode | undefined) => { if (dep !== undefined && dep.subs !== undefined) { if (__DEV__) { triggerEventInfos.push({ @@ -116,6 +119,7 @@ export function trigger( }) } propagate(dep.subs) + shallowPropagate(dep.subs) if (__DEV__) { triggerEventInfos.pop() } @@ -190,7 +194,7 @@ export function trigger( export function getDepFromReactive( object: any, key: string | number | symbol, -): Dependency | undefined { +): ReactiveNode | undefined { const depMap = targetMap.get(object) return depMap && depMap.get(key) } diff --git a/packages/reactivity/src/effect.ts b/packages/reactivity/src/effect.ts index a77c4bf2b18..af8ebea89a1 100644 --- a/packages/reactivity/src/effect.ts +++ b/packages/reactivity/src/effect.ts @@ -4,18 +4,22 @@ import { setupOnTrigger } from './debug' import { activeEffectScope } from './effectScope' import { type Link, - type Subscriber, - SubscriberFlags, + ReactiveFlags, + type ReactiveNode, + activeSub, + checkDirty, endTracking, + link, + setActiveSub, startTracking, - updateDirtyFlag, + unlink, } from './system' import { warn } from './warning' export type EffectScheduler = (...args: any[]) => any export type DebuggerEvent = { - effect: Subscriber + effect: ReactiveNode } & DebuggerEventExtraInfo export type DebuggerEventExtraInfo = { @@ -48,118 +52,111 @@ export enum EffectFlags { */ ALLOW_RECURSE = 1 << 7, PAUSED = 1 << 8, - NOTIFIED = 1 << 9, - STOP = 1 << 10, } -export class ReactiveEffect implements ReactiveEffectOptions { - // Subscriber +export class ReactiveEffect + implements ReactiveEffectOptions, ReactiveNode +{ deps: Link | undefined = undefined depsTail: Link | undefined = undefined - flags: number = SubscriberFlags.Effect + subs: Link | undefined = undefined + subsTail: Link | undefined = undefined + flags: number = ReactiveFlags.Watching | ReactiveFlags.Dirty /** * @internal */ - cleanup?: () => void = undefined + cleanups: (() => void)[] = [] + /** + * @internal + */ + cleanupsLength = 0 - onStop?: () => void + // dev only onTrack?: (event: DebuggerEvent) => void + // dev only onTrigger?: (event: DebuggerEvent) => void - constructor(public fn: () => T) { - if (activeEffectScope && activeEffectScope.active) { - activeEffectScope.effects.push(this) + // @ts-expect-error + fn(): T {} + + constructor(fn?: () => T) { + if (fn !== undefined) { + this.fn = fn + } + if (activeEffectScope) { + link(this, activeEffectScope) } } get active(): boolean { - return !(this.flags & EffectFlags.STOP) + return !!this.flags || this.deps !== undefined } pause(): void { - if (!(this.flags & EffectFlags.PAUSED)) { - this.flags |= EffectFlags.PAUSED - } + this.flags |= EffectFlags.PAUSED } resume(): void { - const flags = this.flags - if (flags & EffectFlags.PAUSED) { - this.flags &= ~EffectFlags.PAUSED - } - if (flags & EffectFlags.NOTIFIED) { - this.flags &= ~EffectFlags.NOTIFIED + const flags = (this.flags &= ~EffectFlags.PAUSED) + if (flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending)) { this.notify() } } notify(): void { - const flags = this.flags - if (!(flags & EffectFlags.PAUSED)) { - this.scheduler() - } else { - this.flags |= EffectFlags.NOTIFIED - } - } - - scheduler(): void { - if (this.dirty) { + if (!(this.flags & EffectFlags.PAUSED) && this.dirty) { this.run() } } run(): T { - // TODO cleanupEffect - if (!this.active) { - // stopped during cleanup return this.fn() } - cleanupEffect(this) - const prevSub = activeSub - setActiveSub(this) - startTracking(this) - + cleanup(this) + const prevSub = startTracking(this) try { return this.fn() } finally { - if (__DEV__ && activeSub !== this) { - warn( - 'Active effect was not restored correctly - ' + - 'this is likely a Vue internal bug.', - ) - } - setActiveSub(prevSub) - endTracking(this) + endTracking(this, prevSub) + const flags = this.flags if ( - this.flags & SubscriberFlags.Recursed && - this.flags & EffectFlags.ALLOW_RECURSE + (flags & (ReactiveFlags.Recursed | EffectFlags.ALLOW_RECURSE)) === + (ReactiveFlags.Recursed | EffectFlags.ALLOW_RECURSE) ) { - this.flags &= ~SubscriberFlags.Recursed + this.flags = flags & ~ReactiveFlags.Recursed this.notify() } } } stop(): void { - if (this.active) { - startTracking(this) - endTracking(this) - cleanupEffect(this) - this.onStop && this.onStop() - this.flags |= EffectFlags.STOP + let dep = this.deps + while (dep !== undefined) { + dep = unlink(dep, this) + } + const sub = this.subs + if (sub !== undefined) { + unlink(sub) } + this.flags = 0 + cleanup(this) } get dirty(): boolean { const flags = this.flags - if ( - flags & SubscriberFlags.Dirty || - (flags & SubscriberFlags.PendingComputed && updateDirtyFlag(this, flags)) - ) { + if (flags & ReactiveFlags.Dirty) { return true } + if (flags & ReactiveFlags.Pending) { + if (checkDirty(this.deps!, this)) { + this.flags = flags | ReactiveFlags.Dirty + return true + } else { + this.flags = flags & ~ReactiveFlags.Pending + } + } return false } } @@ -183,6 +180,23 @@ export function effect( const e = new ReactiveEffect(fn) if (options) { + const { onStop, scheduler } = options + if (onStop) { + options.onStop = undefined + const stop = e.stop.bind(e) + e.stop = () => { + stop() + onStop() + } + } + if (scheduler) { + options.scheduler = undefined + e.notify = () => { + if (!(e.flags & EffectFlags.PAUSED)) { + scheduler() + } + } + } extend(e, options) } try { @@ -205,14 +219,14 @@ export function stop(runner: ReactiveEffectRunner): void { runner.effect.stop() } -const resetTrackingStack: (Subscriber | undefined)[] = [] +const resetTrackingStack: (ReactiveNode | undefined)[] = [] /** * Temporarily pauses tracking. */ export function pauseTracking(): void { resetTrackingStack.push(activeSub) - activeSub = undefined + setActiveSub() } /** @@ -230,7 +244,7 @@ export function enableTracking(): void { resetTrackingStack.push(undefined) for (let i = resetTrackingStack.length - 1; i >= 0; i--) { if (resetTrackingStack[i] !== undefined) { - activeSub = resetTrackingStack[i] + setActiveSub(resetTrackingStack[i]) break } } @@ -248,9 +262,21 @@ export function resetTracking(): void { ) } if (resetTrackingStack.length) { - activeSub = resetTrackingStack.pop()! + setActiveSub(resetTrackingStack.pop()!) } else { - activeSub = undefined + setActiveSub() + } +} + +export function cleanup( + sub: ReactiveNode & { cleanups: (() => void)[]; cleanupsLength: number }, +): void { + const l = sub.cleanupsLength + if (l) { + for (let i = 0; i < l; i++) { + sub.cleanups[i]() + } + sub.cleanupsLength = 0 } } @@ -268,7 +294,7 @@ export function resetTracking(): void { */ export function onEffectCleanup(fn: () => void, failSilently = false): void { if (activeSub instanceof ReactiveEffect) { - activeSub.cleanup = fn + activeSub.cleanups[activeSub.cleanupsLength++] = () => cleanupEffect(fn) } else if (__DEV__ && !failSilently) { warn( `onEffectCleanup() was called when there was no active effect` + @@ -277,23 +303,12 @@ export function onEffectCleanup(fn: () => void, failSilently = false): void { } } -function cleanupEffect(e: ReactiveEffect) { - const { cleanup } = e - e.cleanup = undefined - if (cleanup !== undefined) { - // run cleanup without active effect - const prevSub = activeSub - activeSub = undefined - try { - cleanup() - } finally { - activeSub = prevSub - } +function cleanupEffect(fn: () => void) { + // run cleanup without active effect + const prevSub = setActiveSub() + try { + fn() + } finally { + setActiveSub(prevSub) } } - -export let activeSub: Subscriber | undefined = undefined - -export function setActiveSub(sub: Subscriber | undefined): void { - activeSub = sub -} diff --git a/packages/reactivity/src/effectScope.ts b/packages/reactivity/src/effectScope.ts index 00fa403b02e..819eb1ef73b 100644 --- a/packages/reactivity/src/effectScope.ts +++ b/packages/reactivity/src/effectScope.ts @@ -1,76 +1,50 @@ -import { EffectFlags, type ReactiveEffect } from './effect' +import { EffectFlags, cleanup } from './effect' import { type Link, - type Subscriber, - endTracking, - startTracking, + type ReactiveNode, + link, + setActiveSub, + unlink, } from './system' import { warn } from './warning' export let activeEffectScope: EffectScope | undefined -export class EffectScope implements Subscriber { - // Subscriber: In order to collect orphans computeds +export class EffectScope implements ReactiveNode { deps: Link | undefined = undefined depsTail: Link | undefined = undefined + subs: Link | undefined = undefined + subsTail: Link | undefined = undefined flags: number = 0 - /** - * @internal track `on` calls, allow `on` call multiple times - */ - private _on = 0 - /** - * @internal - */ - effects: ReactiveEffect[] = [] /** * @internal */ cleanups: (() => void)[] = [] - - /** - * only assigned by undetached scope - * @internal - */ - parent: EffectScope | undefined - /** - * record undetached scopes - * @internal - */ - scopes: EffectScope[] | undefined /** - * track a child scope's index in its parent's scopes array for optimized - * removal * @internal */ - private index: number | undefined + cleanupsLength = 0 - constructor( - public detached = false, - parent: EffectScope | undefined = activeEffectScope, - ) { - this.parent = parent - if (!detached && parent) { - this.index = (parent.scopes || (parent.scopes = [])).push(this) - 1 + constructor(detached = false) { + if (!detached && activeEffectScope) { + link(this, activeEffectScope) } } get active(): boolean { - return !(this.flags & EffectFlags.STOP) + return !!this.flags || this.deps !== undefined } pause(): void { if (!(this.flags & EffectFlags.PAUSED)) { this.flags |= EffectFlags.PAUSED - let i, l - if (this.scopes) { - for (i = 0, l = this.scopes.length; i < l; i++) { - this.scopes[i].pause() + for (let link = this.deps; link !== undefined; link = link.nextDep) { + const dep = link.dep + if ('pause' in dep) { + dep.pause() } } - for (i = 0, l = this.effects.length; i < l; i++) { - this.effects[i].pause() - } } } @@ -78,91 +52,47 @@ export class EffectScope implements Subscriber { * Resumes the effect scope, including all child scopes and effects. */ resume(): void { - if (this.flags & EffectFlags.PAUSED) { - this.flags &= ~EffectFlags.PAUSED - let i, l - if (this.scopes) { - for (i = 0, l = this.scopes.length; i < l; i++) { - this.scopes[i].resume() + const flags = this.flags + if (flags & EffectFlags.PAUSED) { + this.flags = flags & ~EffectFlags.PAUSED + for (let link = this.deps; link !== undefined; link = link.nextDep) { + const dep = link.dep + if ('resume' in dep) { + dep.resume() } } - for (i = 0, l = this.effects.length; i < l; i++) { - this.effects[i].resume() - } } } run(fn: () => T): T | undefined { - if (this.active) { - const prevEffectScope = activeEffectScope - try { - activeEffectScope = this - return fn() - } finally { - activeEffectScope = prevEffectScope - } - } else if (__DEV__) { - warn(`cannot run an inactive effect scope.`) - } - } - - prevScope: EffectScope | undefined - /** - * This should only be called on non-detached scopes - * @internal - */ - on(): void { - if (++this._on === 1) { - this.prevScope = activeEffectScope + const prevSub = setActiveSub() + const prevScope = activeEffectScope + try { activeEffectScope = this + return fn() + } finally { + activeEffectScope = prevScope + setActiveSub(prevSub) } } - /** - * This should only be called on non-detached scopes - * @internal - */ - off(): void { - if (this._on > 0 && --this._on === 0) { - activeEffectScope = this.prevScope - this.prevScope = undefined - } - } - - stop(fromParent?: boolean): void { - if (this.active) { - this.flags |= EffectFlags.STOP - startTracking(this) - endTracking(this) - let i, l - for (i = 0, l = this.effects.length; i < l; i++) { - this.effects[i].stop() + stop(): void { + let dep = this.deps + while (dep !== undefined) { + const node = dep.dep + if ('stop' in node) { + dep = dep.nextDep + node.stop() + } else { + dep = unlink(dep, this) } - this.effects.length = 0 - - for (i = 0, l = this.cleanups.length; i < l; i++) { - this.cleanups[i]() - } - this.cleanups.length = 0 - - if (this.scopes) { - for (i = 0, l = this.scopes.length; i < l; i++) { - this.scopes[i].stop(true) - } - this.scopes.length = 0 - } - - // nested scope, dereference from parent to avoid memory leaks - if (!this.detached && this.parent && !fromParent) { - // optimized O(1) removal - const last = this.parent.scopes!.pop() - if (last && last !== this) { - this.parent.scopes![this.index!] = last - last.index = this.index! - } - } - this.parent = undefined } + const sub = this.subs + if (sub !== undefined) { + unlink(sub) + } + this.flags = 0 + cleanup(this) } } @@ -188,6 +118,14 @@ export function getCurrentScope(): EffectScope | undefined { return activeEffectScope } +export function setCurrentScope(scope?: EffectScope): EffectScope | undefined { + try { + return activeEffectScope + } finally { + activeEffectScope = scope + } +} + /** * Registers a dispose callback on the current active effect scope. The * callback will be invoked when the associated effect scope is stopped. @@ -196,8 +134,8 @@ export function getCurrentScope(): EffectScope | undefined { * @see {@link https://vuejs.org/api/reactivity-advanced.html#onscopedispose} */ export function onScopeDispose(fn: () => void, failSilently = false): void { - if (activeEffectScope) { - activeEffectScope.cleanups.push(fn) + if (activeEffectScope !== undefined) { + activeEffectScope.cleanups[activeEffectScope.cleanupsLength++] = fn } else if (__DEV__ && !failSilently) { warn( `onScopeDispose() is called when there is no active effect scope` + diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index f0445e87da0..ef643940b00 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -76,6 +76,10 @@ export { effectScope, EffectScope, getCurrentScope, + /** + * @internal + */ + setCurrentScope, onScopeDispose, } from './effectScope' export { reactiveReadArray, shallowReadArray } from './arrayInstrumentations' @@ -86,8 +90,11 @@ export { traverse, onWatcherCleanup, WatchErrorCodes, + /** + * @internal + */ + WatcherEffect, type WatchOptions, - type WatchScheduler, type WatchStopHandle, type WatchHandle, type WatchEffect, @@ -95,3 +102,7 @@ export { type WatchCallback, type OnCleanup, } from './watch' +/** + * @internal + */ +export { setActiveSub } from './system' diff --git a/packages/reactivity/src/ref.ts b/packages/reactivity/src/ref.ts index 5239f34bf3f..92a4ba7f1de 100644 --- a/packages/reactivity/src/ref.ts +++ b/packages/reactivity/src/ref.ts @@ -9,7 +9,6 @@ import type { ComputedRef, WritableComputedRef } from './computed' import { ReactiveFlags, TrackOpTypes, TriggerOpTypes } from './constants' import { onTrack, triggerEventInfos } from './debug' import { getDepFromReactive } from './dep' -import { activeSub } from './effect' import { type Builtin, type ShallowReactiveMarker, @@ -19,7 +18,17 @@ import { toRaw, toReactive, } from './reactive' -import { type Dependency, type Link, link, propagate } from './system' +import { + type Link, + type ReactiveNode, + ReactiveFlags as _ReactiveFlags, + activeSub, + batchDepth, + flush, + link, + propagate, + shallowPropagate, +} from './system' declare const RefSymbol: unique symbol export declare const RawSymbol: unique symbol @@ -106,31 +115,46 @@ function createRef(rawValue: unknown, wrap?: (v: T) => T) { /** * @internal */ -class RefImpl implements Dependency { - // Dependency +class RefImpl implements ReactiveNode { subs: Link | undefined = undefined subsTail: Link | undefined = undefined + flags: _ReactiveFlags = _ReactiveFlags.Mutable _value: T _wrap?: (v: T) => T + private _oldValue: T private _rawValue: T - public readonly [ReactiveFlags.IS_REF] = true - public readonly [ReactiveFlags.IS_SHALLOW]: boolean = false + /** + * @internal + */ + readonly __v_isRef = true + // TODO isolatedDeclarations ReactiveFlags.IS_REF + /** + * @internal + */ + readonly __v_isShallow: boolean = false + // TODO isolatedDeclarations ReactiveFlags.IS_SHALLOW constructor(value: T, wrap: ((v: T) => T) | undefined) { - this._rawValue = wrap ? toRaw(value) : value + this._oldValue = this._rawValue = wrap ? toRaw(value) : value this._value = wrap ? wrap(value) : value this._wrap = wrap this[ReactiveFlags.IS_SHALLOW] = !wrap } - get dep() { + get dep(): this { return this } - get value() { + get value(): T { trackRef(this) + if (this.flags & _ReactiveFlags.Dirty && this.update()) { + const subs = this.subs + if (subs !== undefined) { + shallowPropagate(subs) + } + } return this._value } @@ -142,24 +166,36 @@ class RefImpl implements Dependency { isReadonly(newValue) newValue = useDirectValue ? newValue : toRaw(newValue) if (hasChanged(newValue, oldValue)) { + this.flags |= _ReactiveFlags.Dirty this._rawValue = newValue this._value = - this._wrap && !useDirectValue ? this._wrap(newValue) : newValue - if (__DEV__) { - triggerEventInfos.push({ - target: this, - type: TriggerOpTypes.SET, - key: 'value', - newValue, - oldValue, - }) - } - triggerRef(this as unknown as Ref) - if (__DEV__) { - triggerEventInfos.pop() + !useDirectValue && this._wrap ? this._wrap(newValue) : newValue + const subs = this.subs + if (subs !== undefined) { + if (__DEV__) { + triggerEventInfos.push({ + target: this, + type: TriggerOpTypes.SET, + key: 'value', + newValue, + oldValue, + }) + } + propagate(subs) + if (!batchDepth) { + flush() + } + if (__DEV__) { + triggerEventInfos.pop() + } } } } + + update(): boolean { + this.flags &= ~_ReactiveFlags.Dirty + return hasChanged(this._oldValue, (this._oldValue = this._rawValue)) + } } /** @@ -192,10 +228,14 @@ export function triggerRef(ref: Ref): void { const dep = (ref as unknown as RefImpl).dep if (dep !== undefined && dep.subs !== undefined) { propagate(dep.subs) + shallowPropagate(dep.subs) + if (!batchDepth) { + flush() + } } } -function trackRef(dep: Dependency) { +function trackRef(dep: ReactiveNode) { if (activeSub !== undefined) { if (__DEV__) { onTrack(activeSub!, { @@ -296,10 +336,10 @@ export type CustomRefFactory = ( set: (value: T) => void } -class CustomRefImpl implements Dependency { - // Dependency +class CustomRefImpl implements ReactiveNode { subs: Link | undefined = undefined subsTail: Link | undefined = undefined + flags: _ReactiveFlags = _ReactiveFlags.None private readonly _get: ReturnType>['get'] private readonly _set: ReturnType>['set'] @@ -380,7 +420,7 @@ class ObjectRefImpl { this._object[this._key] = newVal } - get dep(): Dependency | undefined { + get dep(): ReactiveNode | undefined { return getDepFromReactive(toRaw(this._object), this._key) } } diff --git a/packages/reactivity/src/system.ts b/packages/reactivity/src/system.ts index b3699f727c8..cc3eaad4565 100644 --- a/packages/reactivity/src/system.ts +++ b/packages/reactivity/src/system.ts @@ -1,220 +1,255 @@ /* eslint-disable */ -// Ported from https://github.com/stackblitz/alien-signals/blob/v1.0.13/src/system.ts +// Ported from https://github.com/stackblitz/alien-signals/blob/v2.0.4/src/system.ts import type { ComputedRefImpl as Computed } from './computed.js' import type { ReactiveEffect as Effect } from './effect.js' - -export interface Dependency { - subs: Link | undefined - subsTail: Link | undefined -} - -export interface Subscriber { - flags: SubscriberFlags - deps: Link | undefined - depsTail: Link | undefined +import type { EffectScope } from './effectScope.js' +import { warn } from './warning.js' + +export interface ReactiveNode { + deps?: Link + depsTail?: Link + subs?: Link + subsTail?: Link + flags: ReactiveFlags } export interface Link { - dep: Dependency | Computed - sub: Subscriber | Computed | Effect + dep: ReactiveNode | Computed | Effect | EffectScope + sub: ReactiveNode | Computed | Effect | EffectScope prevSub: Link | undefined nextSub: Link | undefined + prevDep: Link | undefined nextDep: Link | undefined } -export const enum SubscriberFlags { - Computed = 1 << 0, - Effect = 1 << 1, - Tracking = 1 << 2, - Recursed = 1 << 4, - Dirty = 1 << 5, - PendingComputed = 1 << 6, - Propagated = Dirty | PendingComputed, +interface Stack { + value: T + prev: Stack | undefined } -interface OneWayLink { - target: T - linked: OneWayLink | undefined +export const enum ReactiveFlags { + None = 0, + Mutable = 1 << 0, + Watching = 1 << 1, + RecursedCheck = 1 << 2, + Recursed = 1 << 3, + Dirty = 1 << 4, + Pending = 1 << 5, } const notifyBuffer: (Effect | undefined)[] = [] -let batchDepth = 0 +export let batchDepth = 0 +export let activeSub: ReactiveNode | undefined = undefined + let notifyIndex = 0 let notifyBufferLength = 0 +export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined { + try { + return activeSub + } finally { + activeSub = sub + } +} + export function startBatch(): void { ++batchDepth } export function endBatch(): void { - if (!--batchDepth) { - processEffectNotifications() + if (!--batchDepth && notifyBufferLength) { + flush() } } -export function link(dep: Dependency, sub: Subscriber): Link | undefined { - const currentDep = sub.depsTail - if (currentDep !== undefined && currentDep.dep === dep) { +export function link(dep: ReactiveNode, sub: ReactiveNode): void { + const prevDep = sub.depsTail + if (prevDep !== undefined && prevDep.dep === dep) { return } - const nextDep = currentDep !== undefined ? currentDep.nextDep : sub.deps - if (nextDep !== undefined && nextDep.dep === dep) { - sub.depsTail = nextDep - return + let nextDep: Link | undefined = undefined + const recursedCheck = sub.flags & ReactiveFlags.RecursedCheck + if (recursedCheck) { + nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps + if (nextDep !== undefined && nextDep.dep === dep) { + sub.depsTail = nextDep + return + } } - const depLastSub = dep.subsTail + const prevSub = dep.subsTail if ( - depLastSub !== undefined && - depLastSub.sub === sub && - isValidLink(depLastSub, sub) + prevSub !== undefined && + prevSub.sub === sub && + (!recursedCheck || isValidLink(prevSub, sub)) ) { return } - return linkNewDep(dep, sub, nextDep, currentDep) + const newLink = + (sub.depsTail = + dep.subsTail = + { + dep, + sub, + prevDep, + nextDep, + prevSub, + nextSub: undefined, + }) + if (nextDep !== undefined) { + nextDep.prevDep = newLink + } + if (prevDep !== undefined) { + prevDep.nextDep = newLink + } else { + sub.deps = newLink + } + if (prevSub !== undefined) { + prevSub.nextSub = newLink + } else { + dep.subs = newLink + } +} + +export function unlink( + link: Link, + sub: ReactiveNode = link.sub, +): Link | undefined { + const dep = link.dep + const prevDep = link.prevDep + const nextDep = link.nextDep + const nextSub = link.nextSub + const prevSub = link.prevSub + if (nextDep !== undefined) { + nextDep.prevDep = prevDep + } else { + sub.depsTail = prevDep + } + if (prevDep !== undefined) { + prevDep.nextDep = nextDep + } else { + sub.deps = nextDep + } + if (nextSub !== undefined) { + nextSub.prevSub = prevSub + } else { + dep.subsTail = prevSub + } + if (prevSub !== undefined) { + prevSub.nextSub = nextSub + } else if ((dep.subs = nextSub) === undefined) { + let toRemove = dep.deps + if (toRemove !== undefined) { + do { + toRemove = unlink(toRemove, dep) + } while (toRemove !== undefined) + dep.flags |= ReactiveFlags.Dirty + } + } + return nextDep } -export function propagate(current: Link): void { - let next = current.nextSub - let branchs: OneWayLink | undefined - let branchDepth = 0 - let targetFlag = SubscriberFlags.Dirty +export function propagate(link: Link): void { + let next = link.nextSub + let stack: Stack | undefined top: do { - const sub = current.sub - const subFlags = sub.flags + const sub = link.sub - let shouldNotify = false + let flags = sub.flags - if ( - !( - subFlags & - (SubscriberFlags.Tracking | - SubscriberFlags.Recursed | - SubscriberFlags.Propagated) - ) - ) { - sub.flags = subFlags | targetFlag - shouldNotify = true - } else if ( - subFlags & SubscriberFlags.Recursed && - !(subFlags & SubscriberFlags.Tracking) - ) { - sub.flags = (subFlags & ~SubscriberFlags.Recursed) | targetFlag - shouldNotify = true - } else if ( - !(subFlags & SubscriberFlags.Propagated) && - isValidLink(current, sub) - ) { - sub.flags = subFlags | SubscriberFlags.Recursed | targetFlag - shouldNotify = (sub as Dependency).subs !== undefined - } - - if (shouldNotify) { - const subSubs = (sub as Dependency).subs - if (subSubs !== undefined) { - current = subSubs - if (subSubs.nextSub !== undefined) { - branchs = { target: next, linked: branchs } - ++branchDepth - next = current.nextSub - } - targetFlag = SubscriberFlags.PendingComputed - continue + if (flags & (ReactiveFlags.Mutable | ReactiveFlags.Watching)) { + if ( + !( + flags & + (ReactiveFlags.RecursedCheck | + ReactiveFlags.Recursed | + ReactiveFlags.Dirty | + ReactiveFlags.Pending) + ) + ) { + sub.flags = flags | ReactiveFlags.Pending + } else if ( + !(flags & (ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed)) + ) { + flags = ReactiveFlags.None + } else if (!(flags & ReactiveFlags.RecursedCheck)) { + sub.flags = (flags & ~ReactiveFlags.Recursed) | ReactiveFlags.Pending + } else if ( + !(flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending)) && + isValidLink(link, sub) + ) { + sub.flags = flags | ReactiveFlags.Recursed | ReactiveFlags.Pending + flags &= ReactiveFlags.Mutable + } else { + flags = ReactiveFlags.None } - if (subFlags & SubscriberFlags.Effect) { + + if (flags & ReactiveFlags.Watching) { notifyBuffer[notifyBufferLength++] = sub as Effect } - } else if (!(subFlags & (SubscriberFlags.Tracking | targetFlag))) { - sub.flags = subFlags | targetFlag - } else if ( - !(subFlags & targetFlag) && - subFlags & SubscriberFlags.Propagated && - isValidLink(current, sub) - ) { - sub.flags = subFlags | targetFlag + + if (flags & ReactiveFlags.Mutable) { + const subSubs = sub.subs + if (subSubs !== undefined) { + link = subSubs + if (subSubs.nextSub !== undefined) { + stack = { value: next, prev: stack } + next = link.nextSub + } + continue + } + } } - if ((current = next!) !== undefined) { - next = current.nextSub - targetFlag = branchDepth - ? SubscriberFlags.PendingComputed - : SubscriberFlags.Dirty + if ((link = next!) !== undefined) { + next = link.nextSub continue } - while (branchDepth--) { - current = branchs!.target! - branchs = branchs!.linked - if (current !== undefined) { - next = current.nextSub - targetFlag = branchDepth - ? SubscriberFlags.PendingComputed - : SubscriberFlags.Dirty + while (stack !== undefined) { + link = stack.value! + stack = stack.prev + if (link !== undefined) { + next = link.nextSub continue top } } break } while (true) - - if (!batchDepth) { - processEffectNotifications() - } } -export function startTracking(sub: Subscriber): void { +export function startTracking(sub: ReactiveNode): ReactiveNode | undefined { sub.depsTail = undefined sub.flags = - (sub.flags & ~(SubscriberFlags.Recursed | SubscriberFlags.Propagated)) | - SubscriberFlags.Tracking -} - -export function endTracking(sub: Subscriber): void { - const depsTail = sub.depsTail - if (depsTail !== undefined) { - const nextDep = depsTail.nextDep - if (nextDep !== undefined) { - clearTracking(nextDep) - depsTail.nextDep = undefined - } - } else if (sub.deps !== undefined) { - clearTracking(sub.deps) - sub.deps = undefined - } - sub.flags &= ~SubscriberFlags.Tracking + (sub.flags & + ~(ReactiveFlags.Recursed | ReactiveFlags.Dirty | ReactiveFlags.Pending)) | + ReactiveFlags.RecursedCheck + return setActiveSub(sub) } -export function updateDirtyFlag( - sub: Subscriber, - flags: SubscriberFlags, -): boolean { - if (checkDirty(sub.deps!)) { - sub.flags = flags | SubscriberFlags.Dirty - return true - } else { - sub.flags = flags & ~SubscriberFlags.PendingComputed - return false +export function endTracking( + sub: ReactiveNode, + prevSub: ReactiveNode | undefined, +): void { + if (__DEV__ && activeSub !== sub) { + warn( + 'Active effect was not restored correctly - ' + + 'this is likely a Vue internal bug.', + ) } -} + activeSub = prevSub -export function processComputedUpdate( - computed: Computed, - flags: SubscriberFlags, -): void { - if (flags & SubscriberFlags.Dirty || checkDirty(computed.deps!)) { - if (computed.update()) { - const subs = computed.subs - if (subs !== undefined) { - shallowPropagate(subs) - } - } - } else { - computed.flags = flags & ~SubscriberFlags.PendingComputed + const depsTail = sub.depsTail + let toRemove = depsTail !== undefined ? depsTail.nextDep : sub.deps + while (toRemove !== undefined) { + toRemove = unlink(toRemove, sub) } + sub.flags &= ~ReactiveFlags.RecursedCheck } -export function processEffectNotifications(): void { +export function flush(): void { while (notifyIndex < notifyBufferLength) { const effect = notifyBuffer[notifyIndex]! notifyBuffer[notifyIndex++] = undefined @@ -224,109 +259,71 @@ export function processEffectNotifications(): void { notifyBufferLength = 0 } -function linkNewDep( - dep: Dependency, - sub: Subscriber, - nextDep: Link | undefined, - depsTail: Link | undefined, -): Link { - const newLink: Link = { - dep, - sub, - nextDep, - prevSub: undefined, - nextSub: undefined, - } - - if (depsTail === undefined) { - sub.deps = newLink - } else { - depsTail.nextDep = newLink - } - - if (dep.subs === undefined) { - dep.subs = newLink - } else { - const oldTail = dep.subsTail! - newLink.prevSub = oldTail - oldTail.nextSub = newLink - } - - sub.depsTail = newLink - dep.subsTail = newLink - - return newLink -} - -function checkDirty(current: Link): boolean { - let prevLinks: OneWayLink | undefined +export function checkDirty(link: Link, sub: ReactiveNode): boolean { + let stack: Stack | undefined let checkDepth = 0 - let dirty: boolean top: do { - dirty = false - const dep = current.dep + const dep = link.dep + const depFlags = dep.flags + + let dirty = false - if (current.sub.flags & SubscriberFlags.Dirty) { + if (sub.flags & ReactiveFlags.Dirty) { dirty = true - } else if ('flags' in dep) { - const depFlags = dep.flags - if ( - (depFlags & (SubscriberFlags.Computed | SubscriberFlags.Dirty)) === - (SubscriberFlags.Computed | SubscriberFlags.Dirty) - ) { - if ((dep as Computed).update()) { - const subs = dep.subs! - if (subs.nextSub !== undefined) { - shallowPropagate(subs) - } - dirty = true - } - } else if ( - (depFlags & - (SubscriberFlags.Computed | SubscriberFlags.PendingComputed)) === - (SubscriberFlags.Computed | SubscriberFlags.PendingComputed) - ) { - if (current.nextSub !== undefined || current.prevSub !== undefined) { - prevLinks = { target: current, linked: prevLinks } + } else if ( + (depFlags & (ReactiveFlags.Mutable | ReactiveFlags.Dirty)) === + (ReactiveFlags.Mutable | ReactiveFlags.Dirty) + ) { + if ((dep as Computed).update()) { + const subs = dep.subs! + if (subs.nextSub !== undefined) { + shallowPropagate(subs) } - current = dep.deps! - ++checkDepth - continue + dirty = true + } + } else if ( + (depFlags & (ReactiveFlags.Mutable | ReactiveFlags.Pending)) === + (ReactiveFlags.Mutable | ReactiveFlags.Pending) + ) { + if (link.nextSub !== undefined || link.prevSub !== undefined) { + stack = { value: link, prev: stack } } + link = dep.deps! + sub = dep + ++checkDepth + continue } - if (!dirty && current.nextDep !== undefined) { - current = current.nextDep + if (!dirty && link.nextDep !== undefined) { + link = link.nextDep continue } while (checkDepth) { --checkDepth - const sub = current.sub as Computed const firstSub = sub.subs! + const hasMultipleSubs = firstSub.nextSub !== undefined + if (hasMultipleSubs) { + link = stack!.value + stack = stack!.prev + } else { + link = firstSub + } if (dirty) { - if (sub.update()) { - if (firstSub.nextSub !== undefined) { - current = prevLinks!.target - prevLinks = prevLinks!.linked + if ((sub as Computed).update()) { + if (hasMultipleSubs) { shallowPropagate(firstSub) - } else { - current = firstSub } + sub = link.sub continue } } else { - sub.flags &= ~SubscriberFlags.PendingComputed + sub.flags &= ~ReactiveFlags.Pending } - if (firstSub.nextSub !== undefined) { - current = prevLinks!.target - prevLinks = prevLinks!.linked - } else { - current = firstSub - } - if (current.nextDep !== undefined) { - current = current.nextDep + sub = link.sub + if (link.nextDep !== undefined) { + link = link.nextDep continue top } dirty = false @@ -336,21 +333,22 @@ function checkDirty(current: Link): boolean { } while (true) } -function shallowPropagate(link: Link): void { +export function shallowPropagate(link: Link): void { do { const sub = link.sub + const nextSub = link.nextSub const subFlags = sub.flags if ( - (subFlags & (SubscriberFlags.PendingComputed | SubscriberFlags.Dirty)) === - SubscriberFlags.PendingComputed + (subFlags & (ReactiveFlags.Pending | ReactiveFlags.Dirty)) === + ReactiveFlags.Pending ) { - sub.flags = subFlags | SubscriberFlags.Dirty + sub.flags = subFlags | ReactiveFlags.Dirty } - link = link.nextSub! + link = nextSub! } while (link !== undefined) } -function isValidLink(checkLink: Link, sub: Subscriber): boolean { +function isValidLink(checkLink: Link, sub: ReactiveNode): boolean { const depsTail = sub.depsTail if (depsTail !== undefined) { let link = sub.deps! @@ -366,40 +364,3 @@ function isValidLink(checkLink: Link, sub: Subscriber): boolean { } return false } - -function clearTracking(link: Link): void { - do { - const dep = link.dep - const nextDep = link.nextDep - const nextSub = link.nextSub - const prevSub = link.prevSub - - if (nextSub !== undefined) { - nextSub.prevSub = prevSub - } else { - dep.subsTail = prevSub - } - - if (prevSub !== undefined) { - prevSub.nextSub = nextSub - } else { - dep.subs = nextSub - } - - if (dep.subs === undefined && 'deps' in dep) { - const depFlags = dep.flags - if (!(depFlags & SubscriberFlags.Dirty)) { - dep.flags = depFlags | SubscriberFlags.Dirty - } - const depDeps = dep.deps - if (depDeps !== undefined) { - link = depDeps - dep.depsTail!.nextDep = nextDep - dep.deps = undefined - dep.depsTail = undefined - continue - } - } - link = nextDep! - } while (link !== undefined) -} diff --git a/packages/reactivity/src/watch.ts b/packages/reactivity/src/watch.ts index 094bf226ca8..532169a2dde 100644 --- a/packages/reactivity/src/watch.ts +++ b/packages/reactivity/src/watch.ts @@ -8,20 +8,13 @@ import { isObject, isPlainObject, isSet, - remove, } from '@vue/shared' import type { ComputedRef } from './computed' import { ReactiveFlags } from './constants' -import { - type DebuggerOptions, - type EffectScheduler, - ReactiveEffect, - pauseTracking, - resetTracking, -} from './effect' -import { getCurrentScope } from './effectScope' +import { type DebuggerOptions, ReactiveEffect, cleanup } from './effect' import { isReactive, isShallow } from './reactive' import { type Ref, isRef } from './ref' +import { setActiveSub } from './system' import { warn } from './warning' // These errors were transferred from `packages/runtime-core/src/errorHandling.ts` @@ -49,12 +42,7 @@ export interface WatchOptions extends DebuggerOptions { immediate?: Immediate deep?: boolean | number once?: boolean - scheduler?: WatchScheduler onWarn?: (msg: string, ...args: any[]) => void - /** - * @internal - */ - augmentJob?: (job: (...args: any[]) => void) => void /** * @internal */ @@ -76,10 +64,7 @@ export interface WatchHandle extends WatchStopHandle { // initial value for watchers to trigger on undefined initial values const INITIAL_WATCHER_VALUE = {} -export type WatchScheduler = (job: () => void, isFirstRun: boolean) => void - -const cleanupMap: WeakMap void)[]> = new WeakMap() -let activeWatcher: ReactiveEffect | undefined = undefined +let activeWatcher: WatcherEffect | undefined = undefined /** * Returns the current active effect if there is one. @@ -102,12 +87,16 @@ export function getCurrentWatcher(): ReactiveEffect | undefined { export function onWatcherCleanup( cleanupFn: () => void, failSilently = false, - owner: ReactiveEffect | undefined = activeWatcher, + owner: WatcherEffect | undefined = activeWatcher, ): void { if (owner) { - let cleanups = cleanupMap.get(owner) - if (!cleanups) cleanupMap.set(owner, (cleanups = [])) - cleanups.push(cleanupFn) + const { call } = owner.options + if (call) { + owner.cleanups[owner.cleanupsLength++] = () => + call(cleanupFn, WatchErrorCodes.WATCH_CLEANUP) + } else { + owner.cleanups[owner.cleanupsLength++] = cleanupFn + } } else if (__DEV__ && !failSilently) { warn( `onWatcherCleanup() was called when there was no active watcher` + @@ -116,212 +105,187 @@ export function onWatcherCleanup( } } -export function watch( - source: WatchSource | WatchSource[] | WatchEffect | object, - cb?: WatchCallback | null, - options: WatchOptions = EMPTY_OBJ, -): WatchHandle { - const { immediate, deep, once, scheduler, augmentJob, call } = options - - const warnInvalidSource = (s: unknown) => { - ;(options.onWarn || warn)( - `Invalid watch source: `, - s, - `A watch source can only be a getter/effect function, a ref, ` + - `a reactive object, or an array of these types.`, - ) - } - - const reactiveGetter = (source: object) => { - // traverse will happen in wrapped getter below - if (deep) return source - // for `deep: false | 0` or shallow reactive, only traverse root-level properties - if (isShallow(source) || deep === false || deep === 0) - return traverse(source, 1) - // for `deep: undefined` on a reactive object, deeply traverse all properties - return traverse(source) - } - - let effect: ReactiveEffect - let getter: () => any - let cleanup: (() => void) | undefined - let boundCleanup: typeof onWatcherCleanup - let forceTrigger = false - let isMultiSource = false - - if (isRef(source)) { - getter = () => source.value - forceTrigger = isShallow(source) - } else if (isReactive(source)) { - getter = () => reactiveGetter(source) - forceTrigger = true - } else if (isArray(source)) { - isMultiSource = true - forceTrigger = source.some(s => isReactive(s) || isShallow(s)) - getter = () => - source.map(s => { - if (isRef(s)) { - return s.value - } else if (isReactive(s)) { - return reactiveGetter(s) - } else if (isFunction(s)) { - return call ? call(s, WatchErrorCodes.WATCH_GETTER) : s() - } else { - __DEV__ && warnInvalidSource(s) - } - }) - } else if (isFunction(source)) { - if (cb) { - // getter with cb - getter = call - ? () => call(source, WatchErrorCodes.WATCH_GETTER) - : (source as () => any) - } else { - // no cb -> simple effect - getter = () => { - if (cleanup) { - pauseTracking() +export class WatcherEffect extends ReactiveEffect { + forceTrigger: boolean + isMultiSource: boolean + oldValue: any + boundCleanup: typeof onWatcherCleanup = fn => + onWatcherCleanup(fn, false, this) + + constructor( + source: WatchSource | WatchSource[] | WatchEffect | object, + public cb?: WatchCallback | null | undefined, + public options: WatchOptions = EMPTY_OBJ, + ) { + const { deep, once, call, onWarn } = options + + let getter: () => any + let forceTrigger = false + let isMultiSource = false + + if (isRef(source)) { + getter = () => source.value + forceTrigger = isShallow(source) + } else if (isReactive(source)) { + getter = () => reactiveGetter(source, deep) + forceTrigger = true + } else if (isArray(source)) { + isMultiSource = true + forceTrigger = source.some(s => isReactive(s) || isShallow(s)) + getter = () => + source.map(s => { + if (isRef(s)) { + return s.value + } else if (isReactive(s)) { + return reactiveGetter(s, deep) + } else if (isFunction(s)) { + return call ? call(s, WatchErrorCodes.WATCH_GETTER) : s() + } else { + __DEV__ && warnInvalidSource(s, onWarn) + } + }) + } else if (isFunction(source)) { + if (cb) { + // getter with cb + getter = call + ? () => call(source, WatchErrorCodes.WATCH_GETTER) + : (source as () => any) + } else { + // no cb -> simple effect + getter = () => { + if (this.cleanupsLength) { + const prevSub = setActiveSub() + try { + cleanup(this) + } finally { + setActiveSub(prevSub) + } + } + const currentEffect = activeWatcher + activeWatcher = this try { - cleanup() + return call + ? call(source, WatchErrorCodes.WATCH_CALLBACK, [ + this.boundCleanup, + ]) + : source(this.boundCleanup) } finally { - resetTracking() + activeWatcher = currentEffect } } - const currentEffect = activeWatcher - activeWatcher = effect - try { - return call - ? call(source, WatchErrorCodes.WATCH_CALLBACK, [boundCleanup]) - : source(boundCleanup) - } finally { - activeWatcher = currentEffect - } } + } else { + getter = NOOP + __DEV__ && warnInvalidSource(source, onWarn) } - } else { - getter = NOOP - __DEV__ && warnInvalidSource(source) - } - if (cb && deep) { - const baseGetter = getter - const depth = deep === true ? Infinity : deep - getter = () => traverse(baseGetter(), depth) - } + if (cb && deep) { + const baseGetter = getter + const depth = deep === true ? Infinity : deep + getter = () => traverse(baseGetter(), depth) + } + + super(getter) + this.forceTrigger = forceTrigger + this.isMultiSource = isMultiSource - const scope = getCurrentScope() - const watchHandle: WatchHandle = () => { - effect.stop() - if (scope && scope.active) { - remove(scope.effects, effect) + if (once && cb) { + const _cb = cb + cb = (...args) => { + _cb(...args) + this.stop() + } } - } - if (once && cb) { - const _cb = cb - cb = (...args) => { - _cb(...args) - watchHandle() + this.cb = cb + + this.oldValue = isMultiSource + ? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE) + : INITIAL_WATCHER_VALUE + + if (__DEV__) { + this.onTrack = options.onTrack + this.onTrigger = options.onTrigger } } - let oldValue: any = isMultiSource - ? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE) - : INITIAL_WATCHER_VALUE - - const job = (immediateFirstRun?: boolean) => { - if (!effect.active || (!immediateFirstRun && !effect.dirty)) { + run(initialRun = false): void { + const oldValue = this.oldValue + const newValue = (this.oldValue = super.run()) + if (!this.cb) { return } - if (cb) { - // watch(source, cb) - const newValue = effect.run() - if ( - deep || - forceTrigger || - (isMultiSource - ? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i])) - : hasChanged(newValue, oldValue)) - ) { - // cleanup before running cb again - if (cleanup) { - cleanup() - } - const currentWatcher = activeWatcher - activeWatcher = effect - try { - const args = [ - newValue, - // pass undefined as the old value when it's changed for the first time - oldValue === INITIAL_WATCHER_VALUE - ? undefined - : isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE - ? [] - : oldValue, - boundCleanup, - ] - call - ? call(cb!, WatchErrorCodes.WATCH_CALLBACK, args) - : // @ts-expect-error - cb!(...args) - oldValue = newValue - } finally { - activeWatcher = currentWatcher - } + const { immediate, deep, call } = this.options + if (initialRun && !immediate) { + return + } + if ( + deep || + this.forceTrigger || + (this.isMultiSource + ? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i])) + : hasChanged(newValue, oldValue)) + ) { + // cleanup before running cb again + cleanup(this) + const currentWatcher = activeWatcher + activeWatcher = this + try { + const args = [ + newValue, + // pass undefined as the old value when it's changed for the first time + oldValue === INITIAL_WATCHER_VALUE + ? undefined + : this.isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE + ? [] + : oldValue, + this.boundCleanup, + ] + call + ? call(this.cb, WatchErrorCodes.WATCH_CALLBACK, args) + : // @ts-expect-error + this.cb(...args) + } finally { + activeWatcher = currentWatcher } - } else { - // watchEffect - effect.run() } } +} - if (augmentJob) { - augmentJob(job) - } - - effect = new ReactiveEffect(getter) - - effect.scheduler = scheduler - ? () => scheduler(job, false) - : (job as EffectScheduler) - - boundCleanup = fn => onWatcherCleanup(fn, false, effect) +function reactiveGetter(source: object, deep: WatchOptions['deep']): unknown { + // traverse will happen in wrapped getter below + if (deep) return source + // for `deep: false | 0` or shallow reactive, only traverse root-level properties + if (isShallow(source) || deep === false || deep === 0) + return traverse(source, 1) + // for `deep: undefined` on a reactive object, deeply traverse all properties + return traverse(source) +} - cleanup = effect.onStop = () => { - const cleanups = cleanupMap.get(effect) - if (cleanups) { - if (call) { - call(cleanups, WatchErrorCodes.WATCH_CLEANUP) - } else { - for (const cleanup of cleanups) cleanup() - } - cleanupMap.delete(effect) - } - } +function warnInvalidSource(s: object, onWarn: WatchOptions['onWarn']): void { + ;(onWarn || warn)( + `Invalid watch source: `, + s, + `A watch source can only be a getter/effect function, a ref, ` + + `a reactive object, or an array of these types.`, + ) +} - if (__DEV__) { - effect.onTrack = options.onTrack - effect.onTrigger = options.onTrigger - } +export function watch( + source: WatchSource | WatchSource[] | WatchEffect | object, + cb?: WatchCallback | null, + options: WatchOptions = EMPTY_OBJ, +): WatchHandle { + const effect = new WatcherEffect(source, cb, options) - // initial run - if (cb) { - if (immediate) { - job(true) - } else { - oldValue = effect.run() - } - } else if (scheduler) { - scheduler(job.bind(null, true), true) - } else { - effect.run() - } + effect.run(true) - watchHandle.pause = effect.pause.bind(effect) - watchHandle.resume = effect.resume.bind(effect) - watchHandle.stop = watchHandle + const stop = effect.stop.bind(effect) as WatchHandle + stop.pause = effect.pause.bind(effect) + stop.resume = effect.resume.bind(effect) + stop.stop = stop - return watchHandle + return stop } export function traverse( diff --git a/packages/runtime-core/__tests__/apiWatch.spec.ts b/packages/runtime-core/__tests__/apiWatch.spec.ts index 39032a63699..d6666441cd7 100644 --- a/packages/runtime-core/__tests__/apiWatch.spec.ts +++ b/packages/runtime-core/__tests__/apiWatch.spec.ts @@ -25,7 +25,9 @@ import { } from '@vue/runtime-test' import { type DebuggerEvent, + type EffectScope, ITERATE_KEY, + ReactiveEffect, type Ref, type ShallowRef, TrackOpTypes, @@ -503,6 +505,52 @@ describe('api: watch', () => { expect(cleanupWatch).toHaveBeenCalledTimes(2) }) + it('nested calls to baseWatch and onWatcherCleanup', async () => { + let calls: string[] = [] + let source: Ref + let copyist: Ref + const scope = effectScope() + + scope.run(() => { + source = ref(0) + copyist = ref(0) + // sync flush + watchEffect( + () => { + const current = (copyist.value = source.value) + onWatcherCleanup(() => calls.push(`sync ${current}`)) + }, + { flush: 'sync' }, + ) + // post flush + watchEffect( + () => { + const current = copyist.value + onWatcherCleanup(() => calls.push(`post ${current}`)) + }, + { flush: 'post' }, + ) + }) + + await nextTick() + expect(calls).toEqual([]) + + scope.run(() => source.value++) + expect(calls).toEqual(['sync 0']) + await nextTick() + expect(calls).toEqual(['sync 0', 'post 0']) + calls.length = 0 + + scope.run(() => source.value++) + expect(calls).toEqual(['sync 1']) + await nextTick() + expect(calls).toEqual(['sync 1', 'post 1']) + calls.length = 0 + + scope.stop() + expect(calls).toEqual(['sync 2', 'post 2']) + }) + it('flush timing: pre (default)', async () => { const count = ref(0) const count2 = ref(0) @@ -1332,16 +1380,15 @@ describe('api: watch', () => { render(h(Comp), nodeOps.createElement('div')) expect(instance!).toBeDefined() - expect(instance!.scope.effects).toBeInstanceOf(Array) // includes the component's own render effect AND the watcher effect - expect(instance!.scope.effects.length).toBe(2) + expect(getEffectsCount(instance!.scope)).toBe(2) _show!.value = false await nextTick() await nextTick() - expect(instance!.scope.effects.length).toBe(0) + expect(getEffectsCount(instance!.scope)).toBe(0) }) test('this.$watch should pass `this.proxy` to watch source as the first argument ', () => { @@ -1489,7 +1536,7 @@ describe('api: watch', () => { createApp(Comp).mount(root) // should not record watcher in detached scope and only the instance's // own update effect - expect(instance!.scope.effects.length).toBe(1) + expect(getEffectsCount(instance!.scope)).toBe(1) }) test('watchEffect should keep running if created in a detached scope', async () => { @@ -1796,9 +1843,9 @@ describe('api: watch', () => { } const root = nodeOps.createElement('div') createApp(Comp).mount(root) - expect(instance!.scope.effects.length).toBe(2) + expect(getEffectsCount(instance!.scope)).toBe(2) unwatch!() - expect(instance!.scope.effects.length).toBe(1) + expect(getEffectsCount(instance!.scope)).toBe(1) const scope = effectScope() scope.run(() => { @@ -1806,14 +1853,14 @@ describe('api: watch', () => { console.log(num.value) }) }) - expect(scope.effects.length).toBe(1) + expect(getEffectsCount(scope)).toBe(1) unwatch!() - expect(scope.effects.length).toBe(0) + expect(getEffectsCount(scope)).toBe(0) scope.run(() => { watch(num, () => {}, { once: true, immediate: true }) }) - expect(scope.effects.length).toBe(0) + expect(getEffectsCount(scope)).toBe(0) }) // simplified case of VueUse syncRef @@ -2011,3 +2058,13 @@ describe('api: watch', () => { expect(onCleanup).toBeCalledTimes(0) }) }) + +function getEffectsCount(scope: EffectScope): number { + let n = 0 + for (let dep = scope.deps; dep !== undefined; dep = dep.nextDep) { + if (dep.dep instanceof ReactiveEffect) { + n++ + } + } + return n +} diff --git a/packages/runtime-core/__tests__/scheduler.spec.ts b/packages/runtime-core/__tests__/scheduler.spec.ts index f2de08a4032..90b27eaf4e3 100644 --- a/packages/runtime-core/__tests__/scheduler.spec.ts +++ b/packages/runtime-core/__tests__/scheduler.spec.ts @@ -49,8 +49,8 @@ describe('scheduler', () => { const job1 = () => { calls.push('job1') - queueJob(job2) - queueJob(job3) + queueJob(job2, 10) + queueJob(job3, 1) } const job2 = () => { @@ -58,12 +58,10 @@ describe('scheduler', () => { queueJob(job4) queueJob(job5) } - job2.id = 10 const job3 = () => { calls.push('job3') } - job3.id = 1 const job4 = () => { calls.push('job4') @@ -125,9 +123,8 @@ describe('scheduler', () => { calls.push('cb1') queueJob(job1) } - cb1.flags! |= SchedulerJobFlags.PRE - queueJob(cb1) + queueJob(cb1, undefined, true) await nextTick() expect(calls).toEqual(['cb1', 'job1']) }) @@ -137,30 +134,23 @@ describe('scheduler', () => { const job1 = () => { calls.push('job1') } - job1.id = 1 const cb1: SchedulerJob = () => { calls.push('cb1') - queueJob(job1) + queueJob(job1, 1) // cb2 should execute before the job - queueJob(cb2) - queueJob(cb3) + queueJob(cb2, 1, true) + queueJob(cb3, 1, true) } - cb1.flags! |= SchedulerJobFlags.PRE const cb2: SchedulerJob = () => { calls.push('cb2') } - cb2.flags! |= SchedulerJobFlags.PRE - cb2.id = 1 - const cb3: SchedulerJob = () => { calls.push('cb3') } - cb3.flags! |= SchedulerJobFlags.PRE - cb3.id = 1 - queueJob(cb1) + queueJob(cb1, undefined, true) await nextTick() expect(calls).toEqual(['cb1', 'cb2', 'cb3', 'job1']) }) @@ -170,41 +160,30 @@ describe('scheduler', () => { const job1: SchedulerJob = () => { calls.push('job1') } - job1.id = 1 - job1.flags! |= SchedulerJobFlags.PRE const job2: SchedulerJob = () => { calls.push('job2') - queueJob(job5) - queueJob(job6) + queueJob(job5, 2) + queueJob(job6, 2, true) } - job2.id = 2 - job2.flags! |= SchedulerJobFlags.PRE const job3: SchedulerJob = () => { calls.push('job3') } - job3.id = 2 - job3.flags! |= SchedulerJobFlags.PRE const job4: SchedulerJob = () => { calls.push('job4') } - job4.id = 3 - job4.flags! |= SchedulerJobFlags.PRE const job5: SchedulerJob = () => { calls.push('job5') } - job5.id = 2 const job6: SchedulerJob = () => { calls.push('job6') } - job6.id = 2 - job6.flags! |= SchedulerJobFlags.PRE // We need several jobs to test this properly, otherwise // findInsertionIndex can yield the correct index by chance - queueJob(job4) - queueJob(job2) - queueJob(job3) - queueJob(job1) + queueJob(job4, 3, true) + queueJob(job2, 2, true) + queueJob(job3, 2, true) + queueJob(job1, 1, true) await nextTick() expect(calls).toEqual(['job1', 'job2', 'job3', 'job6', 'job5', 'job4']) @@ -217,8 +196,8 @@ describe('scheduler', () => { // when updating the props of a child component. This is handled // directly inside `updateComponentPreRender` to avoid non atomic // cb triggers (#1763) - queueJob(cb1) - queueJob(cb2) + queueJob(cb1, undefined, true) + queueJob(cb2, undefined, true) flushPreFlushCbs() calls.push('job1') } @@ -227,11 +206,9 @@ describe('scheduler', () => { // a cb triggers its parent job, which should be skipped queueJob(job1) } - cb1.flags! |= SchedulerJobFlags.PRE const cb2: SchedulerJob = () => { calls.push('cb2') } - cb2.flags! |= SchedulerJobFlags.PRE queueJob(job1) await nextTick() @@ -242,29 +219,24 @@ describe('scheduler', () => { const calls: string[] = [] const job1: SchedulerJob = () => { calls.push('job1') - queueJob(job3) - queueJob(job4) + queueJob(job3, undefined, true) + queueJob(job4, undefined, true) } // job1 has no id - job1.flags! |= SchedulerJobFlags.PRE const job2: SchedulerJob = () => { calls.push('job2') } - job2.id = 1 - job2.flags! |= SchedulerJobFlags.PRE const job3: SchedulerJob = () => { calls.push('job3') } // job3 has no id - job3.flags! |= SchedulerJobFlags.PRE const job4: SchedulerJob = () => { calls.push('job4') } // job4 has no id - job4.flags! |= SchedulerJobFlags.PRE - queueJob(job1) - queueJob(job2) + queueJob(job1, undefined, true) + queueJob(job2, 1, true) await nextTick() expect(calls).toEqual(['job1', 'job3', 'job4', 'job2']) }) @@ -273,9 +245,8 @@ describe('scheduler', () => { it('queue preFlushCb inside postFlushCb', async () => { const spy = vi.fn() const cb: SchedulerJob = () => spy() - cb.flags! |= SchedulerJobFlags.PRE queuePostFlushCb(() => { - queueJob(cb) + queueJob(cb, undefined, true) }) await nextTick() expect(spy).toHaveBeenCalled() @@ -448,16 +419,13 @@ describe('scheduler', () => { const job1: SchedulerJob = () => { calls.push('job1') } - job1.id = 1 - const job2: SchedulerJob = () => { calls.push('job2') } - job2.id = 2 queuePostFlushCb(() => { - queueJob(job2) - queueJob(job1) + queueJob(job2, 2) + queueJob(job1, 1) }) await nextTick() @@ -471,21 +439,16 @@ describe('scheduler', () => { const job1 = () => calls.push('job1') // job1 has no id const job2 = () => calls.push('job2') - job2.id = 2 const job3 = () => calls.push('job3') - job3.id = 1 const job4: SchedulerJob = () => calls.push('job4') - job4.id = 2 - job4.flags! |= SchedulerJobFlags.PRE const job5: SchedulerJob = () => calls.push('job5') // job5 has no id - job5.flags! |= SchedulerJobFlags.PRE queueJob(job1) - queueJob(job2) - queueJob(job3) - queueJob(job4) - queueJob(job5) + queueJob(job2, 2) + queueJob(job3, 1) + queueJob(job4, 2, true) + queueJob(job5, undefined, true) await nextTick() expect(calls).toEqual(['job5', 'job3', 'job4', 'job2', 'job1']) }) @@ -495,13 +458,11 @@ describe('scheduler', () => { const cb1 = () => calls.push('cb1') // cb1 has no id const cb2 = () => calls.push('cb2') - cb2.id = 2 const cb3 = () => calls.push('cb3') - cb3.id = 1 queuePostFlushCb(cb1) - queuePostFlushCb(cb2) - queuePostFlushCb(cb3) + queuePostFlushCb(cb2, 2) + queuePostFlushCb(cb3, 1) await nextTick() expect(calls).toEqual(['cb3', 'cb2', 'cb1']) }) @@ -550,13 +511,10 @@ describe('scheduler', () => { throw err } }) - job1.id = 1 - const job2: SchedulerJob = vi.fn() - job2.id = 2 - queueJob(job1) - queueJob(job2) + queueJob(job1, 1) + queueJob(job2, 2) try { await nextTick() @@ -570,8 +528,8 @@ describe('scheduler', () => { expect(job1).toHaveBeenCalledTimes(1) expect(job2).toHaveBeenCalledTimes(0) - queueJob(job1) - queueJob(job2) + queueJob(job1, 1) + queueJob(job2, 2) await nextTick() @@ -622,11 +580,10 @@ describe('scheduler', () => { test('recursive jobs can only be queued once non-recursively', async () => { const job: SchedulerJob = vi.fn() - job.id = 1 job.flags = SchedulerJobFlags.ALLOW_RECURSE - queueJob(job) - queueJob(job) + queueJob(job, 1) + queueJob(job, 1) await nextTick() @@ -638,15 +595,14 @@ describe('scheduler', () => { const job: SchedulerJob = vi.fn(() => { if (recurse) { - queueJob(job) - queueJob(job) + queueJob(job, 1) + queueJob(job, 1) recurse = false } }) - job.id = 1 job.flags = SchedulerJobFlags.ALLOW_RECURSE - queueJob(job) + queueJob(job, 1) await nextTick() @@ -659,22 +615,19 @@ describe('scheduler', () => { const job1: SchedulerJob = () => { if (recurse) { // job2 is already queued, so this shouldn't do anything - queueJob(job2) + queueJob(job2, 2) recurse = false } } - job1.id = 1 - const job2: SchedulerJob = vi.fn(() => { if (recurse) { - queueJob(job1) - queueJob(job2) + queueJob(job1, 1) + queueJob(job2, 2) } }) - job2.id = 2 job2.flags = SchedulerJobFlags.ALLOW_RECURSE - queueJob(job2) + queueJob(job2, 2) await nextTick() @@ -685,40 +638,35 @@ describe('scheduler', () => { let recurse = true const job1: SchedulerJob = vi.fn(() => { - queueJob(job3) - queueJob(job3) + queueJob(job3, 3, true) + queueJob(job3, 3, true) flushPreFlushCbs() }) - job1.id = 1 - job1.flags = SchedulerJobFlags.PRE const job2: SchedulerJob = vi.fn(() => { if (recurse) { // job2 does not allow recurse, so this shouldn't do anything - queueJob(job2) + queueJob(job2, 2, true) // job3 is already queued, so this shouldn't do anything - queueJob(job3) + queueJob(job3, 3, true) recurse = false } }) - job2.id = 2 - job2.flags = SchedulerJobFlags.PRE const job3: SchedulerJob = vi.fn(() => { if (recurse) { - queueJob(job2) - queueJob(job3) + queueJob(job2, 2, true) + queueJob(job3, 3, true) // The jobs are already queued, so these should have no effect - queueJob(job2) - queueJob(job3) + queueJob(job2, 2, true) + queueJob(job3, 3, true) } }) - job3.id = 3 - job3.flags = SchedulerJobFlags.ALLOW_RECURSE | SchedulerJobFlags.PRE + job3.flags = SchedulerJobFlags.ALLOW_RECURSE - queueJob(job1) + queueJob(job1, 1, true) await nextTick() @@ -775,8 +723,7 @@ describe('scheduler', () => { spy() flushPreFlushCbs() } - job.flags! |= SchedulerJobFlags.PRE - queueJob(job) + queueJob(job, undefined, true) await nextTick() expect(spy).toHaveBeenCalledTimes(1) }) @@ -788,18 +735,14 @@ describe('scheduler', () => { const job1: SchedulerJob = () => { calls.push('job1') } - job1.id = 1 - job1.flags! |= SchedulerJobFlags.PRE const job2: SchedulerJob = () => { calls.push('job2') } - job2.id = 2 - job2.flags! |= SchedulerJobFlags.PRE queuePostFlushCb(() => { - queueJob(job2) - queueJob(job1) + queueJob(job2, 2, true) + queueJob(job1, 1, true) // e.g. nested app.mount() call flushPreFlushCbs() @@ -830,14 +773,14 @@ describe('scheduler', () => { const cb1 = () => calls.push('cb1') // cb1 has no id const cb2 = () => calls.push('cb2') - cb2.id = -1 const queueAndFlush = (hook: Function) => { queuePostFlushCb(hook) flushPostFlushCbs() } queueAndFlush(() => { - queuePostFlushCb([cb1, cb2]) + queuePostFlushCb(cb1) + queuePostFlushCb(cb2, -1) flushPostFlushCbs() }) diff --git a/packages/runtime-core/src/apiLifecycle.ts b/packages/runtime-core/src/apiLifecycle.ts index 93af3a2b01c..dce4d852def 100644 --- a/packages/runtime-core/src/apiLifecycle.ts +++ b/packages/runtime-core/src/apiLifecycle.ts @@ -8,11 +8,7 @@ import type { ComponentPublicInstance } from './componentPublicInstance' import { ErrorTypeStrings, callWithAsyncErrorHandling } from './errorHandling' import { warn } from './warning' import { toHandlerKey } from '@vue/shared' -import { - type DebuggerEvent, - pauseTracking, - resetTracking, -} from '@vue/reactivity' +import { type DebuggerEvent, setActiveSub } from '@vue/reactivity' import { LifecycleHooks } from './enums' export { onActivated, onDeactivated } from './components/KeepAlive' @@ -33,16 +29,16 @@ export function injectHook( (hook.__weh = (...args: unknown[]) => { // disable tracking inside all lifecycle hooks // since they can potentially be called inside effects. - pauseTracking() + const prevSub = setActiveSub() // Set currentInstance during hook invocation. // This assumes the hook does not synchronously trigger other hooks, which // can only be false when the user does something really funky. - const reset = setCurrentInstance(target) + const prev = setCurrentInstance(target) try { return callWithAsyncErrorHandling(hook, target, type, args) } finally { - reset() - resetTracking() + setCurrentInstance(...prev) + setActiveSub(prevSub) } }) if (prepend) { diff --git a/packages/runtime-core/src/apiSetupHelpers.ts b/packages/runtime-core/src/apiSetupHelpers.ts index 6a5532ad555..45b1d28f807 100644 --- a/packages/runtime-core/src/apiSetupHelpers.ts +++ b/packages/runtime-core/src/apiSetupHelpers.ts @@ -14,7 +14,6 @@ import { createSetupContext, getCurrentGenericInstance, setCurrentInstance, - unsetCurrentInstance, } from './component' import type { EmitFn, EmitsOptions, ObjectEmitsOptions } from './componentEmits' import type { @@ -511,7 +510,7 @@ export function withAsyncContext(getAwaitable: () => any): [any, () => void] { ) } let awaitable = getAwaitable() - unsetCurrentInstance() + setCurrentInstance(null, undefined) if (isPromise(awaitable)) { awaitable = awaitable.catch(e => { setCurrentInstance(ctx) diff --git a/packages/runtime-core/src/apiWatch.ts b/packages/runtime-core/src/apiWatch.ts index 8f6168cdf29..7dce012d90b 100644 --- a/packages/runtime-core/src/apiWatch.ts +++ b/packages/runtime-core/src/apiWatch.ts @@ -1,17 +1,19 @@ import { type WatchOptions as BaseWatchOptions, type DebuggerOptions, + EffectFlags, type ReactiveMarker, type WatchCallback, type WatchEffect, type WatchHandle, type WatchSource, - watch as baseWatch, + WatcherEffect, } from '@vue/reactivity' import { type SchedulerJob, SchedulerJobFlags, queueJob } from './scheduler' import { EMPTY_OBJ, NOOP, extend, isFunction, isString } from '@vue/shared' import { type ComponentInternalInstance, + type GenericComponentInstance, currentInstance, isInSSRComponentSetup, setCurrentInstance, @@ -125,7 +127,7 @@ export function watch< // implementation export function watch = false>( source: T | WatchSource, - cb: any, + cb: WatchCallback, options?: WatchOptions, ): WatchHandle { if (__DEV__ && !isFunction(cb)) { @@ -138,12 +140,57 @@ export function watch = false>( return doWatch(source as any, cb, options) } +class RenderWatcherEffect extends WatcherEffect { + job: SchedulerJob + + constructor( + instance: GenericComponentInstance | null, + source: WatchSource | WatchSource[] | WatchEffect | object, + cb: WatchCallback | null, + options: BaseWatchOptions, + private flush: 'pre' | 'post' | 'sync', + ) { + super(source, cb, options) + + const job: SchedulerJob = () => { + if (this.dirty) { + this.run() + } + } + // important: mark the job as a watcher callback so that scheduler knows + // it is allowed to self-trigger (#1727) + if (cb) { + this.flags |= EffectFlags.ALLOW_RECURSE + job.flags! |= SchedulerJobFlags.ALLOW_RECURSE + } + if (instance) { + job.i = instance + } + this.job = job + } + + notify(): void { + const flags = this.flags + if (!(flags & EffectFlags.PAUSED)) { + const flush = this.flush + const job = this.job + if (flush === 'post') { + queuePostRenderEffect(job, undefined, job.i ? job.i.suspense : null) + } else if (flush === 'pre') { + queueJob(job, job.i ? job.i.uid : undefined, true) + } else { + job() + } + } + } +} + function doWatch( source: WatchSource | WatchSource[] | WatchEffect | object, cb: WatchCallback | null, options: WatchOptions = EMPTY_OBJ, ): WatchHandle { - const { immediate, deep, flush, once } = options + const { immediate, deep, flush = 'pre', once } = options if (__DEV__ && !cb) { if (immediate !== undefined) { @@ -190,50 +237,37 @@ function doWatch( baseWatchOptions.call = (fn, type, args) => callWithAsyncErrorHandling(fn, instance, type, args) - // scheduler - let isPre = false - if (flush === 'post') { - baseWatchOptions.scheduler = job => { - queuePostRenderEffect(job, instance && instance.suspense) - } - } else if (flush !== 'sync') { - // default: 'pre' - isPre = true - baseWatchOptions.scheduler = (job, isFirstRun) => { - if (isFirstRun) { - job() - } else { - queueJob(job) - } - } - } + const effect = new RenderWatcherEffect( + instance, + source, + cb, + baseWatchOptions, + flush, + ) - baseWatchOptions.augmentJob = (job: SchedulerJob) => { - // important: mark the job as a watcher callback so that scheduler knows - // it is allowed to self-trigger (#1727) - if (cb) { - job.flags! |= SchedulerJobFlags.ALLOW_RECURSE - } - if (isPre) { - job.flags! |= SchedulerJobFlags.PRE - if (instance) { - job.id = instance.uid - ;(job as SchedulerJob).i = instance - } - } + // initial run + if (cb) { + effect.run(true) + } else if (flush === 'post') { + queuePostRenderEffect(effect.job, undefined, instance && instance.suspense) + } else { + effect.run(true) } - const watchHandle = baseWatch(source, cb, baseWatchOptions) + const stop = effect.stop.bind(effect) as WatchHandle + stop.pause = effect.pause.bind(effect) + stop.resume = effect.resume.bind(effect) + stop.stop = stop if (__SSR__ && isInSSRComponentSetup) { if (ssrCleanup) { - ssrCleanup.push(watchHandle) + ssrCleanup.push(stop) } else if (runsImmediately) { - watchHandle() + stop() } } - return watchHandle + return stop } // this.$watch @@ -256,9 +290,9 @@ export function instanceWatch( cb = value.handler as Function options = value } - const reset = setCurrentInstance(this) + const prev = setCurrentInstance(this) const res = doWatch(getter, cb.bind(publicThis), options) - reset() + setCurrentInstance(...prev) return res } diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index f6ff8803c87..3da21cccde0 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -5,9 +5,8 @@ import { TrackOpTypes, isRef, markRaw, - pauseTracking, proxyRefs, - resetTracking, + setActiveSub, shallowReadonly, track, } from '@vue/reactivity' @@ -97,7 +96,6 @@ import type { RendererElement } from './renderer' import { setCurrentInstance, setInSSRSetupState, - unsetCurrentInstance, } from './componentCurrentInstance' export * from './componentCurrentInstance' @@ -888,10 +886,10 @@ function setupStatefulComponent( // 2. call setup() const { setup } = Component if (setup) { - pauseTracking() + const prevSub = setActiveSub() const setupContext = (instance.setupContext = setup.length > 1 ? createSetupContext(instance) : null) - const reset = setCurrentInstance(instance) + const prev = setCurrentInstance(instance) const setupResult = callWithErrorHandling( setup, instance, @@ -902,8 +900,8 @@ function setupStatefulComponent( ], ) const isAsyncSetup = isPromise(setupResult) - resetTracking() - reset() + setActiveSub(prevSub) + setCurrentInstance(...prev) if ((isAsyncSetup || instance.sp) && !isAsyncWrapper(instance)) { // async setup / serverPrefetch, mark as async boundary for useId() @@ -911,6 +909,9 @@ function setupStatefulComponent( } if (isAsyncSetup) { + const unsetCurrentInstance = (): void => { + setCurrentInstance(null, undefined) + } setupResult.then(unsetCurrentInstance, unsetCurrentInstance) if (isSSR) { // return the promise so server-renderer can wait on it @@ -1083,13 +1084,13 @@ export function finishComponentSetup( // support for 2.x options if (__FEATURE_OPTIONS_API__ && !(__COMPAT__ && skipOptions)) { - const reset = setCurrentInstance(instance) - pauseTracking() + const prevInstance = setCurrentInstance(instance) + const prevSub = setActiveSub() try { applyOptions(instance) } finally { - resetTracking() - reset() + setActiveSub(prevSub) + setCurrentInstance(...prevInstance) } } diff --git a/packages/runtime-core/src/componentCurrentInstance.ts b/packages/runtime-core/src/componentCurrentInstance.ts index c091b9c693d..7ac52a2e997 100644 --- a/packages/runtime-core/src/componentCurrentInstance.ts +++ b/packages/runtime-core/src/componentCurrentInstance.ts @@ -4,6 +4,7 @@ import type { GenericComponentInstance, } from './component' import { currentRenderingInstance } from './componentRenderContext' +import { type EffectScope, setCurrentScope } from '@vue/reactivity' /** * @internal @@ -25,7 +26,10 @@ export let isInSSRComponentSetup = false export let setInSSRSetupState: (state: boolean) => void -let internalSetCurrentInstance: ( +/** + * @internal + */ +export let simpleSetCurrentInstance: ( instance: GenericComponentInstance | null, ) => void @@ -53,7 +57,7 @@ if (__SSR__) { else setters[0](v) } } - internalSetCurrentInstance = registerGlobalSetter( + simpleSetCurrentInstance = registerGlobalSetter( `__VUE_INSTANCE_SETTERS__`, v => (currentInstance = v), ) @@ -66,7 +70,7 @@ if (__SSR__) { v => (isInSSRComponentSetup = v), ) } else { - internalSetCurrentInstance = i => { + simpleSetCurrentInstance = i => { currentInstance = i } setInSSRSetupState = v => { @@ -74,34 +78,15 @@ if (__SSR__) { } } -export const setCurrentInstance = (instance: GenericComponentInstance) => { - const prev = currentInstance - internalSetCurrentInstance(instance) - instance.scope.on() - return (): void => { - instance.scope.off() - internalSetCurrentInstance(prev) - } -} - -export const unsetCurrentInstance = (): void => { - currentInstance && currentInstance.scope.off() - internalSetCurrentInstance(null) -} - -/** - * Exposed for vapor only. Vapor never runs during SSR so we don't want to pay - * for the extra overhead - * @internal - */ -export const simpleSetCurrentInstance = ( - i: GenericComponentInstance | null, - unset?: GenericComponentInstance | null, -): void => { - currentInstance = i - if (unset) { - unset.scope.off() - } else if (i) { - i.scope.on() +export const setCurrentInstance = ( + instance: GenericComponentInstance | null, + scope: EffectScope | undefined = instance !== null + ? instance.scope + : undefined, +): [GenericComponentInstance | null, EffectScope | undefined] => { + try { + return [currentInstance, setCurrentScope(scope)] + } finally { + simpleSetCurrentInstance(instance) } } diff --git a/packages/runtime-core/src/componentProps.ts b/packages/runtime-core/src/componentProps.ts index d0fe97ff03d..bdbb66ccd0e 100644 --- a/packages/runtime-core/src/componentProps.ts +++ b/packages/runtime-core/src/componentProps.ts @@ -522,7 +522,7 @@ function baseResolveDefault( key: string, ) { let value - const reset = setCurrentInstance(instance) + const prev = setCurrentInstance(instance) const props = toRaw(instance.props) value = factory.call( __COMPAT__ && isCompatEnabled(DeprecationTypes.PROPS_DEFAULT_THIS, instance) @@ -530,7 +530,7 @@ function baseResolveDefault( : null, props, ) - reset() + setCurrentInstance(...prev) return value } diff --git a/packages/runtime-core/src/components/KeepAlive.ts b/packages/runtime-core/src/components/KeepAlive.ts index d18d5a48b8f..f4244f360e3 100644 --- a/packages/runtime-core/src/components/KeepAlive.ts +++ b/packages/runtime-core/src/components/KeepAlive.ts @@ -156,16 +156,20 @@ const KeepAliveImpl: ComponentOptions = { vnode.slotScopeIds, optimized, ) - queuePostRenderEffect(() => { - instance.isDeactivated = false - if (instance.a) { - invokeArrayFns(instance.a) - } - const vnodeHook = vnode.props && vnode.props.onVnodeMounted - if (vnodeHook) { - invokeVNodeHook(vnodeHook, instance.parent, vnode) - } - }, parentSuspense) + queuePostRenderEffect( + () => { + instance.isDeactivated = false + if (instance.a) { + invokeArrayFns(instance.a) + } + const vnodeHook = vnode.props && vnode.props.onVnodeMounted + if (vnodeHook) { + invokeVNodeHook(vnodeHook, instance.parent, vnode) + } + }, + undefined, + parentSuspense, + ) if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { // Update components tree @@ -186,16 +190,20 @@ const KeepAliveImpl: ComponentOptions = { keepAliveInstance, parentSuspense, ) - queuePostRenderEffect(() => { - if (instance.da) { - invokeArrayFns(instance.da) - } - const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted - if (vnodeHook) { - invokeVNodeHook(vnodeHook, instance.parent, vnode) - } - instance.isDeactivated = true - }, parentSuspense) + queuePostRenderEffect( + () => { + if (instance.da) { + invokeArrayFns(instance.da) + } + const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted + if (vnodeHook) { + invokeVNodeHook(vnodeHook, instance.parent, vnode) + } + instance.isDeactivated = true + }, + undefined, + parentSuspense, + ) if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { // Update components tree @@ -255,12 +263,16 @@ const KeepAliveImpl: ComponentOptions = { // if KeepAlive child is a Suspense, it needs to be cached after Suspense resolves // avoid caching vnode that not been mounted if (isSuspense(keepAliveInstance.subTree.type)) { - queuePostRenderEffect(() => { - cache.set( - pendingCacheKey!, - getInnerChild(keepAliveInstance.subTree), - ) - }, keepAliveInstance.subTree.suspense) + queuePostRenderEffect( + () => { + cache.set( + pendingCacheKey!, + getInnerChild(keepAliveInstance.subTree), + ) + }, + undefined, + keepAliveInstance.subTree.suspense, + ) } else { cache.set(pendingCacheKey, getInnerChild(keepAliveInstance.subTree)) } @@ -278,7 +290,7 @@ const KeepAliveImpl: ComponentOptions = { resetShapeFlag(vnode) // but invoke its deactivated hook here const da = vnode.component!.da - da && queuePostRenderEffect(da, suspense) + da && queuePostRenderEffect(da, undefined, suspense) return } unmount(cached) diff --git a/packages/runtime-core/src/components/Suspense.ts b/packages/runtime-core/src/components/Suspense.ts index 0f6f69c6526..2539145bd00 100644 --- a/packages/runtime-core/src/components/Suspense.ts +++ b/packages/runtime-core/src/components/Suspense.ts @@ -873,6 +873,7 @@ function normalizeSuspenseSlot(s: any) { export function queueEffectWithSuspense( fn: Function | Function[], + id: number | undefined, suspense: SuspenseBoundary | null, ): void { if (suspense && suspense.pendingBranch) { @@ -882,7 +883,7 @@ export function queueEffectWithSuspense( suspense.effects.push(fn) } } else { - queuePostFlushCb(fn) + queuePostFlushCb(fn, id) } } diff --git a/packages/runtime-core/src/components/Teleport.ts b/packages/runtime-core/src/components/Teleport.ts index a6445df7b05..7f7a3c56e26 100644 --- a/packages/runtime-core/src/components/Teleport.ts +++ b/packages/runtime-core/src/components/Teleport.ts @@ -164,30 +164,38 @@ export const TeleportImpl = { } if (isTeleportDeferred(n2.props)) { - queuePostRenderEffect(() => { - mountToTarget() - n2.el!.__isMounted = true - }, parentSuspense) + queuePostRenderEffect( + () => { + mountToTarget() + n2.el!.__isMounted = true + }, + undefined, + parentSuspense, + ) } else { mountToTarget() } } else { if (isTeleportDeferred(n2.props) && !n1.el!.__isMounted) { - queuePostRenderEffect(() => { - TeleportImpl.process( - n1, - n2, - container, - anchor, - parentComponent, - parentSuspense, - namespace, - slotScopeIds, - optimized, - internals, - ) - delete n1.el!.__isMounted - }, parentSuspense) + queuePostRenderEffect( + () => { + TeleportImpl.process( + n1, + n2, + container, + anchor, + parentComponent, + parentSuspense, + namespace, + slotScopeIds, + optimized, + internals, + ) + delete n1.el!.__isMounted + }, + undefined, + parentSuspense, + ) return } // update content diff --git a/packages/runtime-core/src/customFormatter.ts b/packages/runtime-core/src/customFormatter.ts index dfe39bf4387..9338e8a92f8 100644 --- a/packages/runtime-core/src/customFormatter.ts +++ b/packages/runtime-core/src/customFormatter.ts @@ -4,8 +4,7 @@ import { isReadonly, isRef, isShallow, - pauseTracking, - resetTracking, + setActiveSub, toRaw, } from '@vue/reactivity' import { EMPTY_OBJ, extend, isArray, isFunction, isObject } from '@vue/shared' @@ -37,9 +36,9 @@ export function initCustomFormatter(): void { return ['div', vueStyle, `VueInstance`] } else if (isRef(obj)) { // avoid tracking during debugger accessing - pauseTracking() + const prevSub = setActiveSub() const value = obj.value - resetTracking() + setActiveSub(prevSub) return [ 'div', {}, diff --git a/packages/runtime-core/src/directives.ts b/packages/runtime-core/src/directives.ts index 5897b39df82..5e3902dcb4b 100644 --- a/packages/runtime-core/src/directives.ts +++ b/packages/runtime-core/src/directives.ts @@ -23,7 +23,7 @@ import { currentRenderingInstance } from './componentRenderContext' import { ErrorCodes, callWithAsyncErrorHandling } from './errorHandling' import type { ComponentPublicInstance } from './componentPublicInstance' import { mapCompatDirectiveHook } from './compat/customDirective' -import { pauseTracking, resetTracking, traverse } from '@vue/reactivity' +import { setActiveSub, traverse } from '@vue/reactivity' export interface DirectiveBinding< Value = any, @@ -187,14 +187,14 @@ export function invokeDirectiveHook( if (hook) { // disable tracking inside all lifecycle hooks // since they can potentially be called inside effects. - pauseTracking() + const prevSub = setActiveSub() callWithAsyncErrorHandling(hook, instance, ErrorCodes.DIRECTIVE_HOOK, [ vnode.el, binding, vnode, prevVNode, ]) - resetTracking() + setActiveSub(prevSub) } } } diff --git a/packages/runtime-core/src/errorHandling.ts b/packages/runtime-core/src/errorHandling.ts index f8048c5c0e7..0090b6c16ad 100644 --- a/packages/runtime-core/src/errorHandling.ts +++ b/packages/runtime-core/src/errorHandling.ts @@ -1,4 +1,4 @@ -import { pauseTracking, resetTracking } from '@vue/reactivity' +import { setActiveSub } from '@vue/reactivity' import type { GenericComponentInstance } from './component' import { popWarningContext, pushWarningContext, warn } from './warning' import { EMPTY_OBJ, isArray, isFunction, isPromise } from '@vue/shared' @@ -139,13 +139,13 @@ export function handleError( } // app-level handling if (errorHandler) { - pauseTracking() + const prevSub = setActiveSub() callWithErrorHandling(errorHandler, null, ErrorCodes.APP_ERROR_HANDLER, [ err, exposedInstance, errorInfo, ]) - resetTracking() + setActiveSub(prevSub) return } } diff --git a/packages/runtime-core/src/hmr.ts b/packages/runtime-core/src/hmr.ts index ed5d8b081a0..6483e22416f 100644 --- a/packages/runtime-core/src/hmr.ts +++ b/packages/runtime-core/src/hmr.ts @@ -100,7 +100,7 @@ function rerender(id: string, newRender?: Function): void { } else { const i = instance as ComponentInternalInstance i.renderCache = [] - i.update() + i.effect.run() } nextTick(() => { isHmrUpdating = false @@ -160,7 +160,7 @@ function reload(id: string, newComp: HMRComponent): void { if (parent.vapor) { parent.hmrRerender!() } else { - ;(parent as ComponentInternalInstance).update() + ;(parent as ComponentInternalInstance).effect.run() } nextTick(() => { isHmrUpdating = false diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index ef6f1918c31..0592cab763b 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -545,11 +545,15 @@ export function createHydrationFunctions( dirs || needCallTransitionHooks ) { - queueEffectWithSuspense(() => { - vnodeHooks && invokeVNodeHook(vnodeHooks, parentComponent, vnode) - needCallTransitionHooks && transition!.enter(el) - dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted') - }, parentSuspense) + queueEffectWithSuspense( + () => { + vnodeHooks && invokeVNodeHook(vnodeHooks, parentComponent, vnode) + needCallTransitionHooks && transition!.enter(el) + dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted') + }, + undefined, + parentSuspense, + ) } } diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index e309554f2f6..617b4d4d8d8 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -543,6 +543,7 @@ export { */ export { currentInstance, + setCurrentInstance, simpleSetCurrentInstance, } from './componentCurrentInstance' /** diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 5a18d62a8e1..716e9a16057 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -57,8 +57,8 @@ import { import { EffectFlags, ReactiveEffect, - pauseTracking, - resetTracking, + setActiveSub, + setCurrentScope, } from '@vue/reactivity' import { updateProps } from './componentProps' import { updateSlots } from './componentSlots' @@ -306,12 +306,16 @@ export enum MoveType { export const queuePostRenderEffect: ( fn: SchedulerJobs, + id: number | undefined, suspense: SuspenseBoundary | null, ) => void = __FEATURE_SUSPENSE__ ? __TEST__ ? // vitest can't seem to handle eager circular dependency - (fn: Function | Function[], suspense: SuspenseBoundary | null) => - queueEffectWithSuspense(fn, suspense) + ( + fn: Function | Function[], + id: number | undefined, + suspense: SuspenseBoundary | null, + ) => queueEffectWithSuspense(fn, id, suspense) : queueEffectWithSuspense : queuePostFlushCb @@ -741,11 +745,15 @@ function baseCreateRenderer( needCallTransitionHooks || dirs ) { - queuePostRenderEffect(() => { - vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode) - needCallTransitionHooks && transition!.enter(el) - dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted') - }, parentSuspense) + queuePostRenderEffect( + () => { + vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode) + needCallTransitionHooks && transition!.enter(el) + dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted') + }, + undefined, + parentSuspense, + ) } } @@ -953,10 +961,14 @@ function baseCreateRenderer( } if ((vnodeHook = newProps.onVnodeUpdated) || dirs) { - queuePostRenderEffect(() => { - vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, n2, n1) - dirs && invokeDirectiveHook(n2, n1, parentComponent, 'updated') - }, parentSuspense) + queuePostRenderEffect( + () => { + vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, n2, n1) + dirs && invokeDirectiveHook(n2, n1, parentComponent, 'updated') + }, + undefined, + parentSuspense, + ) } } @@ -1303,7 +1315,7 @@ function baseCreateRenderer( // normal update instance.next = n2 // instance.update is the reactive effect. - instance.update() + instance.effect.run() } } else { // no update needed. just copy over properties @@ -1312,16 +1324,56 @@ function baseCreateRenderer( } } - const setupRenderEffect: SetupRenderEffectFn = ( - instance, - initialVNode, - container, - anchor, - parentSuspense, - namespace: ElementNamespace, - optimized, - ) => { - const componentUpdateFn = () => { + class SetupRenderEffect extends ReactiveEffect { + job: SchedulerJob + + constructor( + private instance: ComponentInternalInstance, + private initialVNode: VNode, + private container: RendererElement, + private anchor: RendererNode | null, + private parentSuspense: SuspenseBoundary | null, + private namespace: ElementNamespace, + private optimized: boolean, + ) { + const prevScope = setCurrentScope(instance.scope) + super() + setCurrentScope(prevScope) + + this.job = instance.job = () => { + if (this.dirty) { + this.run() + } + } + this.job.i = instance + + if (__DEV__) { + this.onTrack = instance.rtc + ? e => invokeArrayFns(instance.rtc!, e) + : void 0 + this.onTrigger = instance.rtg + ? e => invokeArrayFns(instance.rtg!, e) + : void 0 + } + } + + notify(): void { + if (!(this.flags & EffectFlags.PAUSED)) { + const job = this.job + queueJob(job, job.i!.uid) + } + } + + fn() { + const { + instance, + initialVNode, + container, + anchor, + parentSuspense, + namespace, + optimized, + } = this if (!instance.isMounted) { let vnodeHook: VNodeHook | null | undefined const { el, props } = initialVNode @@ -1417,7 +1469,7 @@ function baseCreateRenderer( } // mounted hook if (m) { - queuePostRenderEffect(m, parentSuspense) + queuePostRenderEffect(m, undefined, parentSuspense) } // onVnodeMounted if ( @@ -1427,6 +1479,7 @@ function baseCreateRenderer( const scopedInitialVNode = initialVNode queuePostRenderEffect( () => invokeVNodeHook(vnodeHook!, parent, scopedInitialVNode), + undefined, parentSuspense, ) } @@ -1436,6 +1489,7 @@ function baseCreateRenderer( ) { queuePostRenderEffect( () => instance.emit('hook:mounted'), + undefined, parentSuspense, ) } @@ -1450,13 +1504,15 @@ function baseCreateRenderer( isAsyncWrapper(parent.vnode) && parent.vnode.shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) ) { - instance.a && queuePostRenderEffect(instance.a, parentSuspense) + instance.a && + queuePostRenderEffect(instance.a, undefined, parentSuspense) if ( __COMPAT__ && isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance) ) { queuePostRenderEffect( () => instance.emit('hook:activated'), + undefined, parentSuspense, ) } @@ -1468,7 +1524,7 @@ function baseCreateRenderer( } // #2458: deference mount-only object parameters to prevent memleaks - initialVNode = container = anchor = null as any + this.initialVNode = this.container = this.anchor = null as any } else { let { next, bu, u, parent, vnode } = instance @@ -1486,7 +1542,7 @@ function baseCreateRenderer( nonHydratedAsyncRoot.asyncDep!.then(() => { // the instance may be destroyed during the time period if (!instance.isUnmounted) { - componentUpdateFn() + this.fn() } }) return @@ -1564,12 +1620,13 @@ function baseCreateRenderer( } // updated hook if (u) { - queuePostRenderEffect(u, parentSuspense) + queuePostRenderEffect(u, undefined, parentSuspense) } // onVnodeUpdated if ((vnodeHook = next.props && next.props.onVnodeUpdated)) { queuePostRenderEffect( () => invokeVNodeHook(vnodeHook!, parent, next!, vnode), + undefined, parentSuspense, ) } @@ -1579,6 +1636,7 @@ function baseCreateRenderer( ) { queuePostRenderEffect( () => instance.emit('hook:updated'), + undefined, parentSuspense, ) } @@ -1592,33 +1650,34 @@ function baseCreateRenderer( } } } + } + const setupRenderEffect: SetupRenderEffectFn = ( + instance, + initialVNode, + container, + anchor, + parentSuspense, + namespace: ElementNamespace, + optimized, + ) => { // create reactive effect for rendering - instance.scope.on() - const effect = (instance.effect = new ReactiveEffect(componentUpdateFn)) - instance.scope.off() - - const update = (instance.update = effect.run.bind(effect)) - const job: SchedulerJob = (instance.job = () => - effect.dirty && effect.run()) - job.i = instance - job.id = instance.uid - effect.scheduler = () => queueJob(job) + const effect = (instance.effect = new SetupRenderEffect( + instance, + initialVNode, + container, + anchor, + parentSuspense, + namespace, + optimized, + )) + instance.update = effect.run.bind(effect) // allowRecurse // #1801, #2043 component render effects should allow recursive updates toggleRecurse(instance, true) - if (__DEV__) { - effect.onTrack = instance.rtc - ? e => invokeArrayFns(instance.rtc!, e) - : void 0 - effect.onTrigger = instance.rtg - ? e => invokeArrayFns(instance.rtg!, e) - : void 0 - } - - update() + effect.run() } const updateComponentPreRender = ( @@ -1633,11 +1692,11 @@ function baseCreateRenderer( updateProps(instance, nextVNode.props, prevProps, optimized) updateSlots(instance, nextVNode.children, optimized) - pauseTracking() + const prevSub = setActiveSub() // props update may have triggered pre-flush watchers. // flush them before the render update. flushPreFlushCbs(instance) - resetTracking() + setActiveSub(prevSub) } const patchChildren: PatchChildrenFn = ( @@ -2117,7 +2176,11 @@ function baseCreateRenderer( if (moveType === MoveType.ENTER) { transition!.beforeEnter(el!) hostInsert(el!, container, anchor) - queuePostRenderEffect(() => transition!.enter(el!), parentSuspense) + queuePostRenderEffect( + () => transition!.enter(el!), + undefined, + parentSuspense, + ) } else { const { leave, delayLeave, afterLeave } = transition! const remove = () => { @@ -2262,11 +2325,15 @@ function baseCreateRenderer( (vnodeHook = props && props.onVnodeUnmounted)) || shouldInvokeDirs ) { - queuePostRenderEffect(() => { - vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode) - shouldInvokeDirs && - invokeDirectiveHook(vnode, null, parentComponent, 'unmounted') - }, parentSuspense) + queuePostRenderEffect( + () => { + vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode) + shouldInvokeDirs && + invokeDirectiveHook(vnode, null, parentComponent, 'unmounted') + }, + undefined, + parentSuspense, + ) } } @@ -2343,7 +2410,7 @@ function baseCreateRenderer( unregisterHMR(instance) } - const { bum, scope, job, subTree, um, m, a } = instance + const { bum, scope, effect, subTree, um, m, a } = instance invalidateMount(m) invalidateMount(a) @@ -2364,14 +2431,14 @@ function baseCreateRenderer( // job may be null if a component is unmounted before its async // setup has resolved. - if (job) { + if (effect) { // so that scheduler will no longer invoke it - job.flags! |= SchedulerJobFlags.DISPOSED + effect.stop() unmount(subTree, instance, parentSuspense, doRemove) } // unmounted hook if (um) { - queuePostRenderEffect(um, parentSuspense) + queuePostRenderEffect(um, undefined, parentSuspense) } if ( __COMPAT__ && @@ -2379,12 +2446,15 @@ function baseCreateRenderer( ) { queuePostRenderEffect( () => instance.emit('hook:destroyed'), + undefined, parentSuspense, ) } - queuePostRenderEffect(() => { - instance.isUnmounted = true - }, parentSuspense) + queuePostRenderEffect( + () => (instance.isUnmounted = true), + undefined, + parentSuspense, + ) // A component with async dep inside a pending suspense is unmounted before // its async dep resolves. This should remove the dep from the suspense, and diff --git a/packages/runtime-core/src/rendererTemplateRef.ts b/packages/runtime-core/src/rendererTemplateRef.ts index ca21030dc35..31fcf8c2d5b 100644 --- a/packages/runtime-core/src/rendererTemplateRef.ts +++ b/packages/runtime-core/src/rendererTemplateRef.ts @@ -13,7 +13,6 @@ import { isAsyncWrapper } from './apiAsyncComponent' import { warn } from './warning' import { isRef, toRaw } from '@vue/reactivity' import { ErrorCodes, callWithErrorHandling } from './errorHandling' -import type { SchedulerJob } from './scheduler' import { queuePostRenderEffect } from './renderer' import { type ComponentOptions, getComponentPublicInstance } from './component' import { knownTemplateRefs } from './helpers/useTemplateRef' @@ -153,8 +152,7 @@ export function setRef( // #1789: for non-null values, set them after render // null values means this is unmount and it should not overwrite another // ref with the same key - ;(doSet as SchedulerJob).id = -1 - queuePostRenderEffect(doSet, parentSuspense) + queuePostRenderEffect(doSet, -1, parentSuspense) } else { doSet() } diff --git a/packages/runtime-core/src/scheduler.ts b/packages/runtime-core/src/scheduler.ts index a75eba300f7..f3482aca5e4 100644 --- a/packages/runtime-core/src/scheduler.ts +++ b/packages/runtime-core/src/scheduler.ts @@ -1,10 +1,9 @@ -import { ErrorCodes, callWithErrorHandling, handleError } from './errorHandling' -import { NOOP, isArray } from '@vue/shared' +import { ErrorCodes, handleError } from './errorHandling' +import { isArray } from '@vue/shared' import { type GenericComponentInstance, getComponentName } from './component' export enum SchedulerJobFlags { QUEUED = 1 << 0, - PRE = 1 << 1, /** * Indicates whether the effect is allowed to recursively trigger itself * when managed by the scheduler. @@ -20,12 +19,12 @@ export enum SchedulerJobFlags { * responsibility to perform recursive state mutation that eventually * stabilizes (#1727). */ - ALLOW_RECURSE = 1 << 2, - DISPOSED = 1 << 3, + ALLOW_RECURSE = 1 << 1, + DISPOSED = 1 << 2, } export interface SchedulerJob extends Function { - id?: number + order?: number /** * flags can technically be undefined, but it can still be used in bitwise * operations just like 0. @@ -40,17 +39,18 @@ export interface SchedulerJob extends Function { export type SchedulerJobs = SchedulerJob | SchedulerJob[] -const queue: SchedulerJob[] = [] -let flushIndex = -1 +const jobs: SchedulerJob[] = [] -const pendingPostFlushCbs: SchedulerJob[] = [] -let activePostFlushCbs: SchedulerJob[] | null = null +let postJobs: SchedulerJob[] = [] +let activePostJobs: SchedulerJob[] | null = null +let currentFlushPromise: Promise | null = null +let jobsLength = 0 +let flushIndex = 0 let postFlushIndex = 0 const resolvedPromise = /*@__PURE__*/ Promise.resolve() as Promise -let currentFlushPromise: Promise | null = null - const RECURSION_LIMIT = 100 + type CountMap = Map export function nextTick( @@ -70,48 +70,64 @@ export function nextTick( // A pre watcher will have the same id as its component's update job. The // watcher should be inserted immediately before the update job. This allows // watchers to be skipped if the component is unmounted by the parent update. -function findInsertionIndex(id: number) { - let start = flushIndex + 1 - let end = queue.length - +function findInsertionIndex( + order: number, + queue: SchedulerJob[], + start: number, + end: number, +) { while (start < end) { const middle = (start + end) >>> 1 - const middleJob = queue[middle] - const middleJobId = getId(middleJob) - if ( - middleJobId < id || - (middleJobId === id && middleJob.flags! & SchedulerJobFlags.PRE) - ) { + if (queue[middle].order! <= order) { start = middle + 1 } else { end = middle } } - return start } /** * @internal for runtime-vapor only */ -export function queueJob(job: SchedulerJob): void { - if (!(job.flags! & SchedulerJobFlags.QUEUED)) { - const jobId = getId(job) - const lastJob = queue[queue.length - 1] +export function queueJob(job: SchedulerJob, id?: number, isPre = false): void { + if ( + queueJobWorker( + job, + id === undefined ? (isPre ? -2 : Infinity) : isPre ? id * 2 : id * 2 + 1, + jobs, + jobsLength, + flushIndex, + ) + ) { + jobsLength++ + queueFlush() + } +} + +function queueJobWorker( + job: SchedulerJob, + order: number, + queue: SchedulerJob[], + length: number, + flushIndex: number, +) { + const flags = job.flags! + if (!(flags & SchedulerJobFlags.QUEUED)) { + job.flags! = flags | SchedulerJobFlags.QUEUED + job.order = order if ( - !lastJob || + flushIndex === length || // fast path when the job id is larger than the tail - (!(job.flags! & SchedulerJobFlags.PRE) && jobId >= getId(lastJob)) + order >= queue[length - 1].order! ) { - queue.push(job) + queue[length] = job } else { - queue.splice(findInsertionIndex(jobId), 0, job) + queue.splice(findInsertionIndex(order, queue, flushIndex, length), 0, job) } - - job.flags! |= SchedulerJobFlags.QUEUED - - queueFlush() + return true } + return false } function queueFlush() { @@ -123,19 +139,23 @@ function queueFlush() { } } -export function queuePostFlushCb(cb: SchedulerJobs): void { - if (!isArray(cb)) { - if (activePostFlushCbs && cb.id === -1) { - activePostFlushCbs.splice(postFlushIndex + 1, 0, cb) - } else if (!(cb.flags! & SchedulerJobFlags.QUEUED)) { - pendingPostFlushCbs.push(cb) - cb.flags! |= SchedulerJobFlags.QUEUED +export function queuePostFlushCb( + jobs: SchedulerJobs, + id: number = Infinity, +): void { + if (!isArray(jobs)) { + if (activePostJobs && id === -1) { + activePostJobs.splice(postFlushIndex, 0, jobs) + } else { + queueJobWorker(jobs, id, postJobs, postJobs.length, 0) } } else { // if cb is an array, it is a component lifecycle hook which can only be // triggered by a job, which is already deduped in the main queue, so // we can skip duplicate check here to improve perf - pendingPostFlushCbs.push(...cb) + for (const job of jobs) { + queueJobWorker(job, id, postJobs, postJobs.length, 0) + } } queueFlush() } @@ -143,58 +163,52 @@ export function queuePostFlushCb(cb: SchedulerJobs): void { export function flushPreFlushCbs( instance?: GenericComponentInstance, seen?: CountMap, - // skip the current job - i: number = flushIndex + 1, ): void { if (__DEV__) { seen = seen || new Map() } - for (; i < queue.length; i++) { - const cb = queue[i] - if (cb && cb.flags! & SchedulerJobFlags.PRE) { - if (instance && cb.id !== instance.uid) { - continue - } - if (__DEV__ && checkRecursiveUpdates(seen!, cb)) { - continue - } - queue.splice(i, 1) - i-- - if (cb.flags! & SchedulerJobFlags.ALLOW_RECURSE) { - cb.flags! &= ~SchedulerJobFlags.QUEUED - } - cb() - if (!(cb.flags! & SchedulerJobFlags.ALLOW_RECURSE)) { - cb.flags! &= ~SchedulerJobFlags.QUEUED - } + for (let i = flushIndex; i < jobsLength; i++) { + const cb = jobs[i] + if (cb.order! & 1 || cb.order === Infinity) { + continue + } + if (instance && cb.order !== instance.uid * 2) { + continue + } + if (__DEV__ && checkRecursiveUpdates(seen!, cb)) { + continue + } + jobs.splice(i, 1) + i-- + jobsLength-- + if (cb.flags! & SchedulerJobFlags.ALLOW_RECURSE) { + cb.flags! &= ~SchedulerJobFlags.QUEUED + } + cb() + if (!(cb.flags! & SchedulerJobFlags.ALLOW_RECURSE)) { + cb.flags! &= ~SchedulerJobFlags.QUEUED } } } export function flushPostFlushCbs(seen?: CountMap): void { - if (pendingPostFlushCbs.length) { - const deduped = [...new Set(pendingPostFlushCbs)].sort( - (a, b) => getId(a) - getId(b), - ) - pendingPostFlushCbs.length = 0 - + if (postJobs.length) { // #1947 already has active queue, nested flushPostFlushCbs call - if (activePostFlushCbs) { - activePostFlushCbs.push(...deduped) + if (activePostJobs) { + activePostJobs.push(...postJobs) + postJobs.length = 0 return } - activePostFlushCbs = deduped + activePostJobs = postJobs + postJobs = [] + if (__DEV__) { seen = seen || new Map() } - for ( - postFlushIndex = 0; - postFlushIndex < activePostFlushCbs.length; - postFlushIndex++ - ) { - const cb = activePostFlushCbs[postFlushIndex] + while (postFlushIndex < activePostJobs.length) { + const cb = activePostJobs[postFlushIndex++] if (__DEV__ && checkRecursiveUpdates(seen!, cb)) { continue } @@ -209,7 +223,8 @@ export function flushPostFlushCbs(seen?: CountMap): void { } } } - activePostFlushCbs = null + + activePostJobs = null postFlushIndex = 0 } } @@ -227,60 +242,58 @@ export function flushOnAppMount(): void { } } -const getId = (job: SchedulerJob): number => - job.id == null ? (job.flags! & SchedulerJobFlags.PRE ? -1 : Infinity) : job.id - function flushJobs(seen?: CountMap) { if (__DEV__) { - seen = seen || new Map() + seen ||= new Map() } - // conditional usage of checkRecursiveUpdate must be determined out of - // try ... catch block since Rollup by default de-optimizes treeshaking - // inside try-catch. This can leave all warning code unshaked. Although - // they would get eventually shaken by a minifier like terser, some minifiers - // would fail to do that (e.g. https://github.com/evanw/esbuild/issues/1610) - const check = __DEV__ - ? (job: SchedulerJob) => checkRecursiveUpdates(seen!, job) - : NOOP - try { - for (flushIndex = 0; flushIndex < queue.length; flushIndex++) { - const job = queue[flushIndex] - if (job && !(job.flags! & SchedulerJobFlags.DISPOSED)) { - if (__DEV__ && check(job)) { + while (flushIndex < jobsLength) { + const job = jobs[flushIndex] + jobs[flushIndex++] = undefined as any + + if (!(job.flags! & SchedulerJobFlags.DISPOSED)) { + // conditional usage of checkRecursiveUpdate must be determined out of + // try ... catch block since Rollup by default de-optimizes treeshaking + // inside try-catch. This can leave all warning code unshaked. Although + // they would get eventually shaken by a minifier like terser, some minifiers + // would fail to do that (e.g. https://github.com/evanw/esbuild/issues/1610) + if (__DEV__ && checkRecursiveUpdates(seen!, job)) { continue } if (job.flags! & SchedulerJobFlags.ALLOW_RECURSE) { job.flags! &= ~SchedulerJobFlags.QUEUED } - callWithErrorHandling( - job, - job.i, - job.i ? ErrorCodes.COMPONENT_UPDATE : ErrorCodes.SCHEDULER, - ) - if (!(job.flags! & SchedulerJobFlags.ALLOW_RECURSE)) { - job.flags! &= ~SchedulerJobFlags.QUEUED + try { + job() + } catch (err) { + handleError( + err, + job.i, + job.i ? ErrorCodes.COMPONENT_UPDATE : ErrorCodes.SCHEDULER, + ) + } finally { + if (!(job.flags! & SchedulerJobFlags.ALLOW_RECURSE)) { + job.flags! &= ~SchedulerJobFlags.QUEUED + } } } } } finally { // If there was an error we still need to clear the QUEUED flags - for (; flushIndex < queue.length; flushIndex++) { - const job = queue[flushIndex] - if (job) { - job.flags! &= ~SchedulerJobFlags.QUEUED - } + while (flushIndex < jobsLength) { + jobs[flushIndex].flags! &= ~SchedulerJobFlags.QUEUED + jobs[flushIndex++] = undefined as any } - flushIndex = -1 - queue.length = 0 + flushIndex = 0 + jobsLength = 0 flushPostFlushCbs(seen) currentFlushPromise = null // If new jobs have been added to either queue, keep flushing - if (queue.length || pendingPostFlushCbs.length) { + if (jobsLength || postJobs.length) { flushJobs(seen) } } diff --git a/packages/runtime-core/src/warning.ts b/packages/runtime-core/src/warning.ts index 361a2734ba4..ef4057dd372 100644 --- a/packages/runtime-core/src/warning.ts +++ b/packages/runtime-core/src/warning.ts @@ -4,7 +4,7 @@ import { formatComponentName, } from './component' import { isFunction, isString } from '@vue/shared' -import { isRef, pauseTracking, resetTracking, toRaw } from '@vue/reactivity' +import { isRef, setActiveSub, toRaw } from '@vue/reactivity' import { ErrorCodes, callWithErrorHandling } from './errorHandling' import { type VNode, isVNode } from './vnode' @@ -41,7 +41,7 @@ export function warn(msg: string, ...args: any[]): void { // avoid props formatting or warn handler tracking deps that might be mutated // during patch, leading to infinite recursion. - pauseTracking() + const prevSub = setActiveSub() const entry = stack.length ? stack[stack.length - 1] : null const instance = isVNode(entry) ? entry.component : entry @@ -79,7 +79,7 @@ export function warn(msg: string, ...args: any[]): void { console.warn(...warnArgs) } - resetTracking() + setActiveSub(prevSub) isWarning = false } diff --git a/packages/runtime-vapor/__tests__/apiWatch.spec.ts b/packages/runtime-vapor/__tests__/apiWatch.spec.ts index 068791b8ad2..290c509552c 100644 --- a/packages/runtime-vapor/__tests__/apiWatch.spec.ts +++ b/packages/runtime-vapor/__tests__/apiWatch.spec.ts @@ -1,4 +1,6 @@ import { + type EffectScope, + ReactiveEffect, currentInstance, effectScope, nextTick, @@ -298,7 +300,7 @@ describe('apiWatch', () => { define(Comp).render() // should not record watcher in detached scope // the 1 is the props validation effect - expect(instance!.scope.effects.length).toBe(1) + expect(getEffectsCount(instance!.scope)).toBe(1) }) test('watchEffect should keep running if created in a detached scope', async () => { @@ -336,3 +338,13 @@ describe('apiWatch', () => { expect(countW).toBe(2) }) }) + +function getEffectsCount(scope: EffectScope): number { + let n = 0 + for (let dep = scope.deps; dep !== undefined; dep = dep.nextDep) { + if (dep.dep instanceof ReactiveEffect) { + n++ + } + } + return n +} diff --git a/packages/runtime-vapor/__tests__/component.spec.ts b/packages/runtime-vapor/__tests__/component.spec.ts index 5fdff8eafe4..07699ba0fc4 100644 --- a/packages/runtime-vapor/__tests__/component.spec.ts +++ b/packages/runtime-vapor/__tests__/component.spec.ts @@ -1,4 +1,6 @@ import { + type EffectScope, + ReactiveEffect, type Ref, inject, nextTick, @@ -280,12 +282,12 @@ describe('component', () => { const i = instance as VaporComponentInstance // watchEffect + renderEffect + props validation effect - expect(i.scope.effects.length).toBe(3) + expect(getEffectsCount(i.scope)).toBe(3) expect(host.innerHTML).toBe('
0
') app.unmount() expect(host.innerHTML).toBe('') - expect(i.scope.effects.length).toBe(0) + expect(getEffectsCount(i.scope)).toBe(0) }) test('should mount component only with template in production mode', () => { @@ -328,3 +330,13 @@ describe('component', () => { ).toHaveBeenWarned() }) }) + +function getEffectsCount(scope: EffectScope): number { + let n = 0 + for (let dep = scope.deps; dep !== undefined; dep = dep.nextDep) { + if (dep.dep instanceof ReactiveEffect) { + n++ + } + } + return n +} diff --git a/packages/runtime-vapor/__tests__/dom/prop.spec.ts b/packages/runtime-vapor/__tests__/dom/prop.spec.ts index e879b7103e5..9d07b413541 100644 --- a/packages/runtime-vapor/__tests__/dom/prop.spec.ts +++ b/packages/runtime-vapor/__tests__/dom/prop.spec.ts @@ -12,20 +12,13 @@ import { } from '../../src/dom/prop' import { setStyle } from '../../src/dom/prop' import { VaporComponentInstance } from '../../src/component' -import { - currentInstance, - ref, - simpleSetCurrentInstance, -} from '@vue/runtime-dom' +import { ref, setCurrentInstance } from '@vue/runtime-dom' let removeComponentInstance = NOOP beforeEach(() => { const instance = new VaporComponentInstance({}, {}, null) - const prev = currentInstance - simpleSetCurrentInstance(instance) - removeComponentInstance = () => { - simpleSetCurrentInstance(prev) - } + const prev = setCurrentInstance(instance) + removeComponentInstance = () => setCurrentInstance(...prev) }) afterEach(() => { removeComponentInstance() diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts index 0cd8317532f..ab1897e4d4c 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -3,8 +3,7 @@ import { type ShallowRef, isReactive, isShallow, - pauseTracking, - resetTracking, + setActiveSub, shallowReadArray, shallowRef, toReactive, @@ -95,7 +94,7 @@ export const createFor = ( const oldLength = oldBlocks.length newBlocks = new Array(newLength) - pauseTracking() + const prevSub = setActiveSub() if (!isMounted) { isMounted = true @@ -284,7 +283,7 @@ export const createFor = ( frag.nodes.push(parentAnchor) } - resetTracking() + setActiveSub(prevSub) } const needKey = renderItem.length > 1 diff --git a/packages/runtime-vapor/src/apiTemplateRef.ts b/packages/runtime-vapor/src/apiTemplateRef.ts index c5a6c5fb2b6..367eb8459f3 100644 --- a/packages/runtime-vapor/src/apiTemplateRef.ts +++ b/packages/runtime-vapor/src/apiTemplateRef.ts @@ -119,8 +119,7 @@ export function setRef( warn('Invalid template ref type:', ref, `(${typeof ref})`) } } - doSet.id = -1 - queuePostFlushCb(doSet) + queuePostFlushCb(doSet, -1) // TODO this gets called repeatedly in renderEffect when it's dynamic ref? onScopeDispose(() => { diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index b782afd38d3..e021ce84b05 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -6,7 +6,7 @@ import { unmountComponent, } from './component' import { createComment, createTextNode } from './dom/node' -import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity' +import { EffectScope, setActiveSub } from '@vue/reactivity' import { isHydrating } from './dom/hydration' export type Block = @@ -47,7 +47,7 @@ export class DynamicFragment extends VaporFragment { } this.current = key - pauseTracking() + const prevSub = setActiveSub() const parent = this.anchor.parentNode // teardown previous branch @@ -73,7 +73,7 @@ export class DynamicFragment extends VaporFragment { parent && insert(this.nodes, parent, this.anchor) } - resetTracking() + setActiveSub(prevSub) } } diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 548babebf8b..3939e008d41 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -20,7 +20,7 @@ import { pushWarningContext, queuePostFlushCb, registerHMR, - simpleSetCurrentInstance, + setCurrentInstance, startMeasure, unregisterHMR, warn, @@ -30,9 +30,8 @@ import { type ShallowRef, markRaw, onScopeDispose, - pauseTracking, proxyRefs, - resetTracking, + setActiveSub, unref, } from '@vue/reactivity' import { EMPTY_OBJ, invokeArrayFns, isFunction, isString } from '@vue/shared' @@ -191,9 +190,8 @@ export function createComponent( instance.emitsOptions = normalizeEmitsOptions(component) } - const prev = currentInstance - simpleSetCurrentInstance(instance) - pauseTracking() + const prevInstance = setCurrentInstance(instance) + const prevSub = setActiveSub() if (__DEV__) { setupPropsValidation(instance) @@ -259,8 +257,8 @@ export function createComponent( }) } - resetTracking() - simpleSetCurrentInstance(prev, instance) + setActiveSub(prevSub) + setCurrentInstance(...prevInstance) if (__DEV__) { popWarningContext() diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index a5e9daad229..7aa11694172 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -12,12 +12,11 @@ import type { VaporComponent, VaporComponentInstance } from './component' import { type NormalizedPropsOptions, baseNormalizePropsOptions, - currentInstance, isEmitListener, popWarningContext, pushWarningContext, resolvePropValue, - simpleSetCurrentInstance, + setCurrentInstance, validateProps, warn, } from '@vue/runtime-dom' @@ -257,10 +256,9 @@ function resolveDefault( factory: (props: Record) => unknown, instance: VaporComponentInstance, ) { - const prev = currentInstance - simpleSetCurrentInstance(instance) + const prev = setCurrentInstance(instance) const res = factory.call(null, instance.props) - simpleSetCurrentInstance(prev, instance) + setCurrentInstance(...prev) return res } diff --git a/packages/runtime-vapor/src/hmr.ts b/packages/runtime-vapor/src/hmr.ts index 741f385861d..c96c1afa130 100644 --- a/packages/runtime-vapor/src/hmr.ts +++ b/packages/runtime-vapor/src/hmr.ts @@ -1,8 +1,7 @@ import { - currentInstance, popWarningContext, pushWarningContext, - simpleSetCurrentInstance, + setCurrentInstance, } from '@vue/runtime-dom' import { insert, normalizeBlock, remove } from './block' import { @@ -19,12 +18,11 @@ export function hmrRerender(instance: VaporComponentInstance): void { const parent = normalized[0].parentNode! const anchor = normalized[normalized.length - 1].nextSibling remove(instance.block, parent) - const prev = currentInstance - simpleSetCurrentInstance(instance) + const prev = setCurrentInstance(instance) pushWarningContext(instance) devRender(instance) popWarningContext() - simpleSetCurrentInstance(prev, instance) + setCurrentInstance(...prev) insert(instance.block, parent, anchor) } @@ -36,14 +34,13 @@ export function hmrReload( const parent = normalized[0].parentNode! const anchor = normalized[normalized.length - 1].nextSibling unmountComponent(instance, parent) - const prev = currentInstance - simpleSetCurrentInstance(instance.parent) + const prev = setCurrentInstance(instance.parent) const newInstance = createComponent( newComp, instance.rawProps, instance.rawSlots, instance.isSingleRoot, ) - simpleSetCurrentInstance(prev, instance.parent) + setCurrentInstance(...prev) mountComponent(newInstance, parent, anchor) } diff --git a/packages/runtime-vapor/src/renderEffect.ts b/packages/runtime-vapor/src/renderEffect.ts index a9fa9b33562..ac34e8863d2 100644 --- a/packages/runtime-vapor/src/renderEffect.ts +++ b/packages/runtime-vapor/src/renderEffect.ts @@ -1,70 +1,91 @@ -import { ReactiveEffect, getCurrentScope } from '@vue/reactivity' +import { EffectFlags, type EffectScope, ReactiveEffect } from '@vue/reactivity' import { type SchedulerJob, currentInstance, queueJob, queuePostFlushCb, - simpleSetCurrentInstance, + setCurrentInstance, startMeasure, warn, } from '@vue/runtime-dom' import { type VaporComponentInstance, isVaporComponent } from './component' import { invokeArrayFns } from '@vue/shared' -export function renderEffect(fn: () => void, noLifecycle = false): void { - const instance = currentInstance as VaporComponentInstance | null - const scope = getCurrentScope() - if (__DEV__ && !__TEST__ && !scope && !isVaporComponent(instance)) { - warn('renderEffect called without active EffectScope or Vapor instance.') - } +class RenderEffect extends ReactiveEffect { + i: VaporComponentInstance | null + job: SchedulerJob + updateJob: SchedulerJob + + constructor(public render: () => void) { + super() + const instance = currentInstance as VaporComponentInstance | null + if (__DEV__ && !__TEST__ && !this.subs && !isVaporComponent(instance)) { + warn('renderEffect called without active EffectScope or Vapor instance.') + } + + const job: SchedulerJob = () => { + if (this.dirty) { + this.run() + } + } + this.updateJob = () => { + instance!.isUpdating = false + instance!.u && invokeArrayFns(instance!.u) + } - // renderEffect is always called after user has registered all hooks - const hasUpdateHooks = instance && (instance.bu || instance.u) - const renderEffectFn = noLifecycle - ? fn - : () => { - if (__DEV__ && instance) { - startMeasure(instance, `renderEffect`) - } - const prev = currentInstance - simpleSetCurrentInstance(instance) - if (scope) scope.on() - if (hasUpdateHooks && instance.isMounted && !instance.isUpdating) { - instance.isUpdating = true - instance.bu && invokeArrayFns(instance.bu) - fn() - queuePostFlushCb(() => { - instance.isUpdating = false - instance.u && invokeArrayFns(instance.u) - }) - } else { - fn() - } - if (scope) scope.off() - simpleSetCurrentInstance(prev, instance) - if (__DEV__ && instance) { - startMeasure(instance, `renderEffect`) - } + if (instance) { + if (__DEV__) { + this.onTrack = instance.rtc + ? e => invokeArrayFns(instance.rtc!, e) + : void 0 + this.onTrigger = instance.rtg + ? e => invokeArrayFns(instance.rtg!, e) + : void 0 } + job.i = instance + } + + this.job = job + this.i = instance - const effect = new ReactiveEffect(renderEffectFn) - const job: SchedulerJob = () => effect.dirty && effect.run() + // TODO recurse handling + } - if (instance) { - if (__DEV__) { - effect.onTrack = instance.rtc - ? e => invokeArrayFns(instance.rtc!, e) - : void 0 - effect.onTrigger = instance.rtg - ? e => invokeArrayFns(instance.rtg!, e) - : void 0 + fn(): void { + const instance = this.i + const scope = this.subs ? (this.subs.sub as EffectScope) : undefined + // renderEffect is always called after user has registered all hooks + const hasUpdateHooks = instance && (instance.bu || instance.u) + if (__DEV__ && instance) { + startMeasure(instance, `renderEffect`) + } + const prev = setCurrentInstance(instance, scope) + if (hasUpdateHooks && instance.isMounted && !instance.isUpdating) { + instance.isUpdating = true + instance.bu && invokeArrayFns(instance.bu) + this.render() + queuePostFlushCb(this.updateJob) + } else { + this.render() + } + setCurrentInstance(...prev) + if (__DEV__ && instance) { + startMeasure(instance, `renderEffect`) } - job.i = instance - job.id = instance.uid } - effect.scheduler = () => queueJob(job) - effect.run() + notify(): void { + const flags = this.flags + if (!(flags & EffectFlags.PAUSED)) { + queueJob(this.job, this.i ? this.i.uid : undefined) + } + } +} - // TODO recurse handling +export function renderEffect(fn: () => void, noLifecycle = false): void { + const effect = new RenderEffect(fn) + if (noLifecycle) { + effect.fn = fn + } + effect.run() }