diff --git a/packages/reactivity/__tests__/computed.spec.ts b/packages/reactivity/__tests__/computed.spec.ts index c044b5feb35..d9b8f888caf 100644 --- a/packages/reactivity/__tests__/computed.spec.ts +++ b/packages/reactivity/__tests__/computed.spec.ts @@ -184,7 +184,7 @@ describe('reactivity/computed', () => { // mutate n n.value++ // on the 2nd run, plusOne.value should have already updated. - expect(plusOneValues).toMatchObject([1, 2, 2]) + expect(plusOneValues).toMatchObject([1, 2]) }) it('should warn if trying to set a readonly computed', () => { @@ -288,4 +288,167 @@ describe('reactivity/computed', () => { oldValue: 2 }) }) + + // https://github.com/vuejs/core/pull/5912#issuecomment-1497596875 + it('should query deps dirty sequentially', () => { + const cSpy = vi.fn() + + const a = ref({ + v: 1 + }) + const b = computed(() => { + return a.value + }) + const c = computed(() => { + cSpy() + return b.value?.v + }) + const d = computed(() => { + if (b.value) { + return c.value + } + return 0 + }) + + d.value + a.value!.v = 2 + a.value = null + d.value + expect(cSpy).toHaveBeenCalledTimes(1) + }) + + // https://github.com/vuejs/core/pull/5912#issuecomment-1738257692 + it('chained computed dirty reallocation after querying dirty', () => { + let _msg: string | undefined + + const items = ref() + const isLoaded = computed(() => { + return !!items.value + }) + const msg = computed(() => { + if (isLoaded.value) { + return 'The items are loaded' + } else { + return 'The items are not loaded' + } + }) + + effect(() => { + _msg = msg.value + }) + + items.value = [1, 2, 3] + items.value = [1, 2, 3] + items.value = undefined + + expect(_msg).toBe('The items are not loaded') + }) + + it('chained computed dirty reallocation after trigger computed getter', () => { + let _msg: string | undefined + + const items = ref() + const isLoaded = computed(() => { + return !!items.value + }) + const msg = computed(() => { + if (isLoaded.value) { + return 'The items are loaded' + } else { + return 'The items are not loaded' + } + }) + + _msg = msg.value + items.value = [1, 2, 3] + isLoaded.value // <- trigger computed getter + _msg = msg.value + items.value = undefined + _msg = msg.value + + expect(_msg).toBe('The items are not loaded') + }) + + // https://github.com/vuejs/core/pull/5912#issuecomment-1739159832 + it('deps order should be consistent with the last time get value', () => { + const cSpy = vi.fn() + + const a = ref(0) + const b = computed(() => { + return a.value % 3 !== 0 + }) + const c = computed(() => { + cSpy() + if (a.value % 3 === 2) { + return 'expensive' + } + return 'cheap' + }) + const d = computed(() => { + return a.value % 3 === 2 + }) + const e = computed(() => { + if (b.value) { + if (d.value) { + return 'Avoiding expensive calculation' + } + } + return c.value + }) + + e.value + a.value++ + e.value + + expect(e.effect.deps.length).toBe(3) + expect(e.effect.deps.indexOf((b as any).dep)).toBe(0) + expect(e.effect.deps.indexOf((d as any).dep)).toBe(1) + expect(e.effect.deps.indexOf((c as any).dep)).toBe(2) + expect(cSpy).toHaveBeenCalledTimes(2) + + a.value++ + e.value + + expect(cSpy).toHaveBeenCalledTimes(2) + }) + + it('should trigger by the second computed that maybe dirty', () => { + const cSpy = vi.fn() + + const src1 = ref(0) + const src2 = ref(0) + const c1 = computed(() => src1.value) + const c2 = computed(() => (src1.value % 2) + src2.value) + const c3 = computed(() => { + cSpy() + c1.value + c2.value + }) + + c3.value + src1.value = 2 + c3.value + expect(cSpy).toHaveBeenCalledTimes(2) + src2.value = 1 + c3.value + expect(cSpy).toHaveBeenCalledTimes(3) + }) + + it('should trigger the second effect', () => { + const fnSpy = vi.fn() + const v = ref(1) + const c = computed(() => v.value) + + effect(() => { + c.value + }) + effect(() => { + c.value + fnSpy() + }) + + expect(fnSpy).toBeCalledTimes(1) + v.value = 2 + expect(fnSpy).toBeCalledTimes(2) + }) }) diff --git a/packages/reactivity/__tests__/deferredComputed.spec.ts b/packages/reactivity/__tests__/deferredComputed.spec.ts index 100f14ae358..8e78ba959c3 100644 --- a/packages/reactivity/__tests__/deferredComputed.spec.ts +++ b/packages/reactivity/__tests__/deferredComputed.spec.ts @@ -1,57 +1,32 @@ -import { computed, deferredComputed, effect, ref } from '../src' +import { computed, effect, ref } from '../src' describe('deferred computed', () => { - const tick = Promise.resolve() - - test('should only trigger once on multiple mutations', async () => { + test('should not trigger if value did not change', () => { const src = ref(0) - const c = deferredComputed(() => src.value) + const c = computed(() => src.value % 2) const spy = vi.fn() effect(() => { spy(c.value) }) expect(spy).toHaveBeenCalledTimes(1) - src.value = 1 src.value = 2 - src.value = 3 - // not called yet - expect(spy).toHaveBeenCalledTimes(1) - await tick - // should only trigger once - expect(spy).toHaveBeenCalledTimes(2) - expect(spy).toHaveBeenCalledWith(c.value) - }) - test('should not trigger if value did not change', async () => { - const src = ref(0) - const c = deferredComputed(() => src.value % 2) - const spy = vi.fn() - effect(() => { - spy(c.value) - }) - expect(spy).toHaveBeenCalledTimes(1) - src.value = 1 - src.value = 2 - - await tick // should not trigger expect(spy).toHaveBeenCalledTimes(1) src.value = 3 - src.value = 4 src.value = 5 - await tick // should trigger because latest value changes expect(spy).toHaveBeenCalledTimes(2) }) - test('chained computed trigger', async () => { + test('chained computed trigger', () => { const effectSpy = vi.fn() const c1Spy = vi.fn() const c2Spy = vi.fn() const src = ref(0) - const c1 = deferredComputed(() => { + const c1 = computed(() => { c1Spy() return src.value % 2 }) @@ -69,19 +44,18 @@ describe('deferred computed', () => { expect(effectSpy).toHaveBeenCalledTimes(1) src.value = 1 - await tick expect(c1Spy).toHaveBeenCalledTimes(2) expect(c2Spy).toHaveBeenCalledTimes(2) expect(effectSpy).toHaveBeenCalledTimes(2) }) - test('chained computed avoid re-compute', async () => { + test('chained computed avoid re-compute', () => { const effectSpy = vi.fn() const c1Spy = vi.fn() const c2Spy = vi.fn() const src = ref(0) - const c1 = deferredComputed(() => { + const c1 = computed(() => { c1Spy() return src.value % 2 }) @@ -98,26 +72,24 @@ describe('deferred computed', () => { src.value = 2 src.value = 4 src.value = 6 - await tick - // c1 should re-compute once. - expect(c1Spy).toHaveBeenCalledTimes(2) + expect(c1Spy).toHaveBeenCalledTimes(4) // c2 should not have to re-compute because c1 did not change. expect(c2Spy).toHaveBeenCalledTimes(1) // effect should not trigger because c2 did not change. expect(effectSpy).toHaveBeenCalledTimes(1) }) - test('chained computed value invalidation', async () => { + test('chained computed value invalidation', () => { const effectSpy = vi.fn() const c1Spy = vi.fn() const c2Spy = vi.fn() const src = ref(0) - const c1 = deferredComputed(() => { + const c1 = computed(() => { c1Spy() return src.value % 2 }) - const c2 = deferredComputed(() => { + const c2 = computed(() => { c2Spy() return c1.value + 1 }) @@ -139,17 +111,17 @@ describe('deferred computed', () => { expect(c2Spy).toHaveBeenCalledTimes(2) }) - test('sync access of invalidated chained computed should not prevent final effect from running', async () => { + test('sync access of invalidated chained computed should not prevent final effect from running', () => { const effectSpy = vi.fn() const c1Spy = vi.fn() const c2Spy = vi.fn() const src = ref(0) - const c1 = deferredComputed(() => { + const c1 = computed(() => { c1Spy() return src.value % 2 }) - const c2 = deferredComputed(() => { + const c2 = computed(() => { c2Spy() return c1.value + 1 }) @@ -162,14 +134,13 @@ describe('deferred computed', () => { src.value = 1 // sync access c2 c2.value - await tick expect(effectSpy).toHaveBeenCalledTimes(2) }) - test('should not compute if deactivated before scheduler is called', async () => { + test('should not compute if deactivated before scheduler is called', () => { const c1Spy = vi.fn() const src = ref(0) - const c1 = deferredComputed(() => { + const c1 = computed(() => { c1Spy() return src.value % 2 }) @@ -179,7 +150,6 @@ describe('deferred computed', () => { c1.effect.stop() // trigger src.value++ - await tick expect(c1Spy).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/reactivity/__tests__/effect.spec.ts b/packages/reactivity/__tests__/effect.spec.ts index e34c7b31e40..2ebb2edea8a 100644 --- a/packages/reactivity/__tests__/effect.spec.ts +++ b/packages/reactivity/__tests__/effect.spec.ts @@ -1,5 +1,4 @@ import { - ref, reactive, effect, stop, @@ -12,7 +11,8 @@ import { readonly, ReactiveEffectRunner } from '../src/index' -import { ITERATE_KEY } from '../src/effect' +import { pauseScheduling, resetScheduling } from '../src/effect' +import { ITERATE_KEY, getDepFromReactive } from '../src/reactiveEffect' describe('reactivity/effect', () => { it('should run the passed function once (wrapped by a effect)', () => { @@ -574,8 +574,8 @@ describe('reactivity/effect', () => { expect(output.fx2).toBe(1 + 3 + 3) expect(fx1Spy).toHaveBeenCalledTimes(1) - // Invoked twice due to change of fx1. - expect(fx2Spy).toHaveBeenCalledTimes(2) + // Invoked due to change of fx1. + expect(fx2Spy).toHaveBeenCalledTimes(1) fx1Spy.mockClear() fx2Spy.mockClear() @@ -821,26 +821,6 @@ describe('reactivity/effect', () => { expect(dummy).toBe(3) }) - // #5707 - // when an effect completes its run, it should clear the tracking bits of - // its tracked deps. However, if the effect stops itself, the deps list is - // emptied so their bits are never cleared. - it('edge case: self-stopping effect tracking ref', () => { - const c = ref(true) - const runner = effect(() => { - // reference ref - if (!c.value) { - // stop itself while running - stop(runner) - } - }) - // trigger run - c.value = !c.value - // should clear bits - expect((c as any).dep.w).toBe(0) - expect((c as any).dep.n).toBe(0) - }) - it('events: onStop', () => { const onStop = vi.fn() const runner = effect(() => {}, { @@ -1015,4 +995,83 @@ describe('reactivity/effect', () => { expect(has).toBe(false) }) }) + + it('should be triggered once with pauseScheduling', () => { + const counter = reactive({ num: 0 }) + + const counterSpy = vi.fn(() => counter.num) + effect(counterSpy) + + counterSpy.mockClear() + + pauseScheduling() + counter.num++ + counter.num++ + resetScheduling() + expect(counterSpy).toHaveBeenCalledTimes(1) + }) + + describe('empty dep cleanup', () => { + it('should remove the dep when the effect is stopped', () => { + const obj = reactive({ prop: 1 }) + expect(getDepFromReactive(toRaw(obj), 'prop')).toBeUndefined() + const runner = effect(() => obj.prop) + const dep = getDepFromReactive(toRaw(obj), 'prop') + expect(dep).toHaveLength(1) + obj.prop = 2 + expect(getDepFromReactive(toRaw(obj), 'prop')).toBe(dep) + expect(dep).toHaveLength(1) + stop(runner) + expect(getDepFromReactive(toRaw(obj), 'prop')).toBeUndefined() + obj.prop = 3 + runner() + expect(getDepFromReactive(toRaw(obj), 'prop')).toBeUndefined() + }) + + it('should only remove the dep when the last effect is stopped', () => { + const obj = reactive({ prop: 1 }) + expect(getDepFromReactive(toRaw(obj), 'prop')).toBeUndefined() + const runner1 = effect(() => obj.prop) + const dep = getDepFromReactive(toRaw(obj), 'prop') + expect(dep).toHaveLength(1) + const runner2 = effect(() => obj.prop) + expect(getDepFromReactive(toRaw(obj), 'prop')).toBe(dep) + expect(dep).toHaveLength(2) + obj.prop = 2 + expect(getDepFromReactive(toRaw(obj), 'prop')).toBe(dep) + expect(dep).toHaveLength(2) + stop(runner1) + expect(getDepFromReactive(toRaw(obj), 'prop')).toBe(dep) + expect(dep).toHaveLength(1) + obj.prop = 3 + expect(getDepFromReactive(toRaw(obj), 'prop')).toBe(dep) + expect(dep).toHaveLength(1) + stop(runner2) + expect(getDepFromReactive(toRaw(obj), 'prop')).toBeUndefined() + obj.prop = 4 + runner1() + runner2() + expect(getDepFromReactive(toRaw(obj), 'prop')).toBeUndefined() + }) + + it('should remove the dep when it is no longer used by the effect', () => { + const obj = reactive<{ a: number; b: number; c: 'a' | 'b' }>({ + a: 1, + b: 2, + c: 'a' + }) + expect(getDepFromReactive(toRaw(obj), 'prop')).toBeUndefined() + effect(() => obj[obj.c]) + const depC = getDepFromReactive(toRaw(obj), 'c') + expect(getDepFromReactive(toRaw(obj), 'a')).toHaveLength(1) + expect(getDepFromReactive(toRaw(obj), 'b')).toBeUndefined() + expect(depC).toHaveLength(1) + obj.c = 'b' + obj.a = 4 + expect(getDepFromReactive(toRaw(obj), 'a')).toBeUndefined() + expect(getDepFromReactive(toRaw(obj), 'b')).toHaveLength(1) + expect(getDepFromReactive(toRaw(obj), 'c')).toBe(depC) + expect(depC).toHaveLength(1) + }) + }) }) diff --git a/packages/reactivity/__tests__/gc.spec.ts b/packages/reactivity/__tests__/gc.spec.ts new file mode 100644 index 00000000000..7676a0e12d0 --- /dev/null +++ b/packages/reactivity/__tests__/gc.spec.ts @@ -0,0 +1,81 @@ +import { + ComputedRef, + computed, + effect, + reactive, + shallowRef as ref, + toRaw +} from '../src/index' +import { getDepFromReactive } from '../src/reactiveEffect' + +describe.skipIf(!global.gc)('reactivity/gc', () => { + const gc = () => { + return new Promise(resolve => { + setTimeout(() => { + global.gc!() + resolve() + }) + }) + } + + // #9233 + it('should release computed cache', async () => { + const src = ref<{} | undefined>({}) + const srcRef = new WeakRef(src.value!) + + let c: ComputedRef | undefined = computed(() => src.value) + + c.value // cache src value + src.value = undefined // release value + c = undefined // release computed + + await gc() + expect(srcRef.deref()).toBeUndefined() + }) + + it('should release reactive property dep', async () => { + const src = reactive({ foo: 1 }) + + let c: ComputedRef | undefined = computed(() => src.foo) + + c.value + expect(getDepFromReactive(toRaw(src), 'foo')).not.toBeUndefined() + + c = undefined + await gc() + await gc() + expect(getDepFromReactive(toRaw(src), 'foo')).toBeUndefined() + }) + + it('should not release effect for ref', async () => { + const spy = vi.fn() + const src = ref(0) + + effect(() => { + spy() + src.value + }) + + expect(spy).toHaveBeenCalledTimes(1) + + await gc() + src.value++ + expect(spy).toHaveBeenCalledTimes(2) + }) + + it('should not release effect for reactive', async () => { + const spy = vi.fn() + const src = reactive({ foo: 1 }) + + effect(() => { + spy() + src.foo + }) + + expect(spy).toHaveBeenCalledTimes(1) + + await gc() + src.foo++ + expect(spy).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/reactivity/__tests__/reactiveArray.spec.ts b/packages/reactivity/__tests__/reactiveArray.spec.ts index 808c5aa5529..f4eb7b58384 100644 --- a/packages/reactivity/__tests__/reactiveArray.spec.ts +++ b/packages/reactivity/__tests__/reactiveArray.spec.ts @@ -99,6 +99,39 @@ describe('reactivity/reactive/Array', () => { expect(fn).toHaveBeenCalledTimes(1) }) + test('shift on Array should trigger dependency once', () => { + const arr = reactive([1, 2, 3]) + const fn = vi.fn() + effect(() => { + for (let i = 0; i < arr.length; i++) { + arr[i] + } + fn() + }) + expect(fn).toHaveBeenCalledTimes(1) + arr.shift() + expect(fn).toHaveBeenCalledTimes(2) + }) + + //#6018 + test('edge case: avoid trigger effect in deleteProperty when array length-decrease mutation methods called', () => { + const arr = ref([1]) + const fn1 = vi.fn() + const fn2 = vi.fn() + effect(() => { + fn1() + if (arr.value.length > 0) { + arr.value.slice() + fn2() + } + }) + expect(fn1).toHaveBeenCalledTimes(1) + expect(fn2).toHaveBeenCalledTimes(1) + arr.value.splice(0) + expect(fn1).toHaveBeenCalledTimes(2) + expect(fn2).toHaveBeenCalledTimes(1) + }) + test('add existing index on Array should not trigger length dependency', () => { const array = new Array(3) const observed = reactive(array) diff --git a/packages/reactivity/src/baseHandlers.ts b/packages/reactivity/src/baseHandlers.ts index 259b44a1edc..36e4d311b4b 100644 --- a/packages/reactivity/src/baseHandlers.ts +++ b/packages/reactivity/src/baseHandlers.ts @@ -2,7 +2,6 @@ import { reactive, readonly, toRaw, - ReactiveFlags, Target, readonlyMap, reactiveMap, @@ -11,14 +10,14 @@ import { isReadonly, isShallow } from './reactive' -import { TrackOpTypes, TriggerOpTypes } from './operations' +import { ReactiveFlags, TrackOpTypes, TriggerOpTypes } from './constants' import { - track, - trigger, - ITERATE_KEY, pauseTracking, - resetTracking + resetTracking, + pauseScheduling, + resetScheduling } from './effect' +import { track, trigger, ITERATE_KEY } from './reactiveEffect' import { isObject, hasOwn, @@ -71,7 +70,9 @@ function createArrayInstrumentations() { ;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => { instrumentations[key] = function (this: unknown[], ...args: unknown[]) { pauseTracking() + pauseScheduling() const res = (toRaw(this) as any)[key].apply(this, args) + resetScheduling() resetTracking() return res } diff --git a/packages/reactivity/src/collectionHandlers.ts b/packages/reactivity/src/collectionHandlers.ts index 1d07af3be8c..e8d99840f71 100644 --- a/packages/reactivity/src/collectionHandlers.ts +++ b/packages/reactivity/src/collectionHandlers.ts @@ -1,6 +1,11 @@ -import { toRaw, ReactiveFlags, toReactive, toReadonly } from './reactive' -import { track, trigger, ITERATE_KEY, MAP_KEY_ITERATE_KEY } from './effect' -import { TrackOpTypes, TriggerOpTypes } from './operations' +import { toRaw, toReactive, toReadonly } from './reactive' +import { + track, + trigger, + ITERATE_KEY, + MAP_KEY_ITERATE_KEY +} from './reactiveEffect' +import { ReactiveFlags, TrackOpTypes, TriggerOpTypes } from './constants' import { capitalize, hasOwn, hasChanged, toRawType, isMap } from '@vue/shared' export type CollectionTypes = IterableCollections | WeakCollections diff --git a/packages/reactivity/src/computed.ts b/packages/reactivity/src/computed.ts index b24484c9e62..09247360d06 100644 --- a/packages/reactivity/src/computed.ts +++ b/packages/reactivity/src/computed.ts @@ -1,8 +1,9 @@ import { DebuggerOptions, ReactiveEffect } from './effect' import { Ref, trackRefValue, triggerRefValue } from './ref' -import { isFunction, NOOP } from '@vue/shared' -import { ReactiveFlags, toRaw } from './reactive' +import { hasChanged, isFunction, NOOP } from '@vue/shared' +import { toRaw } from './reactive' import { Dep } from './dep' +import { DirtyLevels, ReactiveFlags } from './constants' declare const ComputedRefSymbol: unique symbol @@ -32,7 +33,6 @@ export class ComputedRefImpl { public readonly __v_isRef = true public readonly [ReactiveFlags.IS_READONLY]: boolean = false - public _dirty = true public _cacheable: boolean constructor( @@ -42,10 +42,7 @@ export class ComputedRefImpl { isSSR: boolean ) { this.effect = new ReactiveEffect(getter, () => { - if (!this._dirty) { - this._dirty = true - triggerRefValue(this) - } + triggerRefValue(this, DirtyLevels.ComputedValueMaybeDirty) }) this.effect.computed = this this.effect.active = this._cacheable = !isSSR @@ -56,9 +53,10 @@ export class ComputedRefImpl { // the computed ref may get wrapped by other proxies e.g. readonly() #3376 const self = toRaw(this) trackRefValue(self) - if (self._dirty || !self._cacheable) { - self._dirty = false - self._value = self.effect.run()! + if (!self._cacheable || self.effect.dirty) { + if (hasChanged(self._value, (self._value = self.effect.run()!))) { + triggerRefValue(self, DirtyLevels.ComputedValueDirty) + } } return self._value } @@ -66,6 +64,16 @@ export class ComputedRefImpl { set value(newValue: T) { this._setter(newValue) } + + // #region polyfill _dirty for backward compatibility third party code for Vue <= 3.3.x + get _dirty() { + return this.effect.dirty + } + + set _dirty(v) { + this.effect.dirty = v + } + // #endregion } /** diff --git a/packages/reactivity/src/constants.ts b/packages/reactivity/src/constants.ts new file mode 100644 index 00000000000..4ad2ec3c7da --- /dev/null +++ b/packages/reactivity/src/constants.ts @@ -0,0 +1,30 @@ +// using literal strings instead of numbers so that it's easier to inspect +// debugger events + +export const enum TrackOpTypes { + GET = 'get', + HAS = 'has', + ITERATE = 'iterate' +} + +export const enum TriggerOpTypes { + SET = 'set', + ADD = 'add', + DELETE = 'delete', + CLEAR = 'clear' +} + +export const enum ReactiveFlags { + SKIP = '__v_skip', + IS_REACTIVE = '__v_isReactive', + IS_READONLY = '__v_isReadonly', + IS_SHALLOW = '__v_isShallow', + RAW = '__v_raw' +} + +export const enum DirtyLevels { + NotDirty = 0, + ComputedValueMaybeDirty = 1, + ComputedValueDirty = 2, + Dirty = 3 +} diff --git a/packages/reactivity/src/deferredComputed.ts b/packages/reactivity/src/deferredComputed.ts index a23122046a4..1dbba1f3f03 100644 --- a/packages/reactivity/src/deferredComputed.ts +++ b/packages/reactivity/src/deferredComputed.ts @@ -1,88 +1,6 @@ -import { Dep } from './dep' -import { ReactiveEffect } from './effect' -import { ComputedGetter, ComputedRef } from './computed' -import { ReactiveFlags, toRaw } from './reactive' -import { trackRefValue, triggerRefValue } from './ref' +import { computed } from './computed' -const tick = /*#__PURE__*/ Promise.resolve() -const queue: any[] = [] -let queued = false - -const scheduler = (fn: any) => { - queue.push(fn) - if (!queued) { - queued = true - tick.then(flush) - } -} - -const flush = () => { - for (let i = 0; i < queue.length; i++) { - queue[i]() - } - queue.length = 0 - queued = false -} - -class DeferredComputedRefImpl { - public dep?: Dep = undefined - - private _value!: T - private _dirty = true - public readonly effect: ReactiveEffect - - public readonly __v_isRef = true - public readonly [ReactiveFlags.IS_READONLY] = true - - constructor(getter: ComputedGetter) { - let compareTarget: any - let hasCompareTarget = false - let scheduled = false - this.effect = new ReactiveEffect(getter, (computedTrigger?: boolean) => { - if (this.dep) { - if (computedTrigger) { - compareTarget = this._value - hasCompareTarget = true - } else if (!scheduled) { - const valueToCompare = hasCompareTarget ? compareTarget : this._value - scheduled = true - hasCompareTarget = false - scheduler(() => { - if (this.effect.active && this._get() !== valueToCompare) { - triggerRefValue(this) - } - scheduled = false - }) - } - // chained upstream computeds are notified synchronously to ensure - // value invalidation in case of sync access; normal effects are - // deferred to be triggered in scheduler. - for (const e of this.dep) { - if (e.computed instanceof DeferredComputedRefImpl) { - e.scheduler!(true /* computedTrigger */) - } - } - } - this._dirty = true - }) - this.effect.computed = this as any - } - - private _get() { - if (this._dirty) { - this._dirty = false - return (this._value = this.effect.run()!) - } - return this._value - } - - get value() { - trackRefValue(this) - // the computed ref may get wrapped by other proxies e.g. readonly() #3376 - return toRaw(this)._get() - } -} - -export function deferredComputed(getter: () => T): ComputedRef { - return new DeferredComputedRefImpl(getter) as any -} +/** + * @deprecated use `computed` instead. See #5912 + */ +export const deferredComputed = computed diff --git a/packages/reactivity/src/dep.ts b/packages/reactivity/src/dep.ts index 8677f575756..eafb2a8af3f 100644 --- a/packages/reactivity/src/dep.ts +++ b/packages/reactivity/src/dep.ts @@ -1,57 +1,17 @@ -import { ReactiveEffect, trackOpBit } from './effect' +import type { ReactiveEffect } from './effect' +import type { ComputedRefImpl } from './computed' -export type Dep = Set & TrackedMarkers - -/** - * wasTracked and newTracked maintain the status for several levels of effect - * tracking recursion. One bit per level is used to define whether the dependency - * was/is tracked. - */ -type TrackedMarkers = { - /** - * wasTracked - */ - w: number - /** - * newTracked - */ - n: number +export type Dep = Map & { + cleanup: () => void + computed?: ComputedRefImpl } -export const createDep = (effects?: ReactiveEffect[]): Dep => { - const dep = new Set(effects) as Dep - dep.w = 0 - dep.n = 0 +export const createDep = ( + cleanup: () => void, + computed?: ComputedRefImpl +): Dep => { + const dep = new Map() as Dep + dep.cleanup = cleanup + dep.computed = computed return dep } - -export const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0 - -export const newTracked = (dep: Dep): boolean => (dep.n & trackOpBit) > 0 - -export const initDepMarkers = ({ deps }: ReactiveEffect) => { - if (deps.length) { - for (let i = 0; i < deps.length; i++) { - deps[i].w |= trackOpBit // set was tracked - } - } -} - -export const finalizeDepMarkers = (effect: ReactiveEffect) => { - const { deps } = effect - if (deps.length) { - let ptr = 0 - for (let i = 0; i < deps.length; i++) { - const dep = deps[i] - if (wasTracked(dep) && !newTracked(dep)) { - dep.delete(effect) - } else { - deps[ptr++] = dep - } - // clear bits - dep.w &= ~trackOpBit - dep.n &= ~trackOpBit - } - deps.length = ptr - } -} diff --git a/packages/reactivity/src/effect.ts b/packages/reactivity/src/effect.ts index c982dbd0b5a..3a25295011c 100644 --- a/packages/reactivity/src/effect.ts +++ b/packages/reactivity/src/effect.ts @@ -1,34 +1,8 @@ -import { TrackOpTypes, TriggerOpTypes } from './operations' -import { extend, isArray, isIntegerKey, isMap, isSymbol } from '@vue/shared' +import { NOOP, extend } from '@vue/shared' +import type { ComputedRefImpl } from './computed' +import { DirtyLevels, TrackOpTypes, TriggerOpTypes } from './constants' +import type { Dep } from './dep' import { EffectScope, recordEffectScope } from './effectScope' -import { - createDep, - Dep, - finalizeDepMarkers, - initDepMarkers, - newTracked, - wasTracked -} from './dep' -import { ComputedRefImpl } from './computed' - -// The main WeakMap that stores {target -> key -> dep} connections. -// Conceptually, it's easier to think of a dependency as a Dep class -// which maintains a Set of subscribers, but we simply store them as -// raw Sets to reduce memory overhead. -type KeyToDepMap = Map -const targetMap = new WeakMap() - -// The number of effects currently being tracked recursively. -let effectTrackDepth = 0 - -export let trackOpBit = 1 - -/** - * The bitwise track markers support at most 30 levels of recursion. - * This value is chosen to enable modern JS engines to use a SMI on all platforms. - * When recursion depth is greater, fall back to using a full cleanup. - */ -const maxMarkerBits = 30 export type EffectScheduler = (...args: any[]) => any @@ -47,13 +21,9 @@ export type DebuggerEventExtraInfo = { export let activeEffect: ReactiveEffect | undefined -export const ITERATE_KEY = Symbol(__DEV__ ? 'iterate' : '') -export const MAP_KEY_ITERATE_KEY = Symbol(__DEV__ ? 'Map key iterate' : '') - export class ReactiveEffect { active = true deps: Dep[] = [] - parent: ReactiveEffect | undefined = undefined /** * Can be attached after creation @@ -64,10 +34,6 @@ export class ReactiveEffect { * @internal */ allowRecurse?: boolean - /** - * @internal - */ - private deferStop?: boolean onStop?: () => void // dev only @@ -75,77 +41,115 @@ export class ReactiveEffect { // dev only onTrigger?: (event: DebuggerEvent) => void + /** + * @internal + */ + _dirtyLevel = DirtyLevels.Dirty + /** + * @internal + */ + _trackId = 0 + /** + * @internal + */ + _runnings = 0 + /** + * @internal + */ + _queryings = 0 + /** + * @internal + */ + _depsLength = 0 + constructor( public fn: () => T, - public scheduler: EffectScheduler | null = null, + public trigger: () => void, + public scheduler?: EffectScheduler, scope?: EffectScope ) { recordEffectScope(this, scope) } + public get dirty() { + if (this._dirtyLevel === DirtyLevels.ComputedValueMaybeDirty) { + this._dirtyLevel = DirtyLevels.NotDirty + this._queryings++ + pauseTracking() + for (const dep of this.deps) { + if (dep.computed) { + triggerComputed(dep.computed) + if (this._dirtyLevel >= DirtyLevels.ComputedValueDirty) { + break + } + } + } + resetTracking() + this._queryings-- + } + return this._dirtyLevel >= DirtyLevels.ComputedValueDirty + } + + public set dirty(v) { + this._dirtyLevel = v ? DirtyLevels.Dirty : DirtyLevels.NotDirty + } + run() { + this._dirtyLevel = DirtyLevels.NotDirty if (!this.active) { return this.fn() } - let parent: ReactiveEffect | undefined = activeEffect let lastShouldTrack = shouldTrack - while (parent) { - if (parent === this) { - return - } - parent = parent.parent - } + let lastEffect = activeEffect try { - this.parent = activeEffect - activeEffect = this shouldTrack = true - - trackOpBit = 1 << ++effectTrackDepth - - if (effectTrackDepth <= maxMarkerBits) { - initDepMarkers(this) - } else { - cleanupEffect(this) - } + activeEffect = this + this._runnings++ + preCleanupEffect(this) return this.fn() } finally { - if (effectTrackDepth <= maxMarkerBits) { - finalizeDepMarkers(this) - } - - trackOpBit = 1 << --effectTrackDepth - - activeEffect = this.parent + postCleanupEffect(this) + this._runnings-- + activeEffect = lastEffect shouldTrack = lastShouldTrack - this.parent = undefined - - if (this.deferStop) { - this.stop() - } } } stop() { - // stopped while running itself - defer the cleanup - if (activeEffect === this) { - this.deferStop = true - } else if (this.active) { - cleanupEffect(this) - if (this.onStop) { - this.onStop() - } + if (this.active) { + preCleanupEffect(this) + postCleanupEffect(this) + this.onStop?.() this.active = false } } } -function cleanupEffect(effect: ReactiveEffect) { - const { deps } = effect - if (deps.length) { - for (let i = 0; i < deps.length; i++) { - deps[i].delete(effect) +function triggerComputed(computed: ComputedRefImpl) { + return computed.value +} + +function preCleanupEffect(effect: ReactiveEffect) { + effect._trackId++ + effect._depsLength = 0 +} + +function postCleanupEffect(effect: ReactiveEffect) { + if (effect.deps && effect.deps.length > effect._depsLength) { + for (let i = effect._depsLength; i < effect.deps.length; i++) { + cleanupDepEffect(effect.deps[i], effect) + } + effect.deps.length = effect._depsLength + } +} + +function cleanupDepEffect(dep: Dep, effect: ReactiveEffect) { + const trackId = dep.get(effect) + if (trackId !== undefined && effect._trackId !== trackId) { + dep.delete(effect) + if (dep.size === 0) { + dep.cleanup() } - deps.length = 0 } } @@ -185,7 +189,11 @@ export function effect( fn = (fn as ReactiveEffectRunner).effect.fn } - const _effect = new ReactiveEffect(fn) + const _effect = new ReactiveEffect(fn, NOOP, () => { + if (_effect.dirty) { + _effect.run() + } + }) if (options) { extend(_effect, options) if (options.scope) recordEffectScope(_effect, options.scope) @@ -208,6 +216,8 @@ export function stop(runner: ReactiveEffectRunner) { } export let shouldTrack = true +export let pauseScheduleStack = 0 + const trackStack: boolean[] = [] /** @@ -234,196 +244,70 @@ export function resetTracking() { shouldTrack = last === undefined ? true : last } -/** - * Tracks access to a reactive property. - * - * This will check which effect is running at the moment and record it as dep - * which records all effects that depend on the reactive property. - * - * @param target - Object holding the reactive property. - * @param type - Defines the type of access to the reactive property. - * @param key - Identifier of the reactive property to track. - */ -export function track(target: object, type: TrackOpTypes, key: unknown) { - if (shouldTrack && activeEffect) { - let depsMap = targetMap.get(target) - if (!depsMap) { - targetMap.set(target, (depsMap = new Map())) - } - let dep = depsMap.get(key) - if (!dep) { - depsMap.set(key, (dep = createDep())) - } - - const eventInfo = __DEV__ - ? { effect: activeEffect, target, type, key } - : undefined +export function pauseScheduling() { + pauseScheduleStack++ +} - trackEffects(dep, eventInfo) +export function resetScheduling() { + pauseScheduleStack-- + while (!pauseScheduleStack && queueEffectSchedulers.length) { + queueEffectSchedulers.shift()!() } } -export function trackEffects( +export function trackEffect( + effect: ReactiveEffect, dep: Dep, debuggerEventExtraInfo?: DebuggerEventExtraInfo ) { - let shouldTrack = false - if (effectTrackDepth <= maxMarkerBits) { - if (!newTracked(dep)) { - dep.n |= trackOpBit // set newly tracked - shouldTrack = !wasTracked(dep) - } - } else { - // Full cleanup mode. - shouldTrack = !dep.has(activeEffect!) - } - - if (shouldTrack) { - dep.add(activeEffect!) - activeEffect!.deps.push(dep) - if (__DEV__ && activeEffect!.onTrack) { - activeEffect!.onTrack( - extend( - { - effect: activeEffect! - }, - debuggerEventExtraInfo! - ) - ) - } - } -} - -/** - * Finds all deps associated with the target (or a specific property) and - * triggers the effects stored within. - * - * @param target - The reactive object. - * @param type - Defines the type of the operation that needs to trigger effects. - * @param key - Can be used to target a specific reactive property in the target object. - */ -export function trigger( - target: object, - type: TriggerOpTypes, - key?: unknown, - newValue?: unknown, - oldValue?: unknown, - oldTarget?: Map | Set -) { - const depsMap = targetMap.get(target) - if (!depsMap) { - // never been tracked - return - } - - let deps: (Dep | undefined)[] = [] - if (type === TriggerOpTypes.CLEAR) { - // collection being cleared - // trigger all effects for target - deps = [...depsMap.values()] - } else if (key === 'length' && isArray(target)) { - const newLength = Number(newValue) - depsMap.forEach((dep, key) => { - if (key === 'length' || (!isSymbol(key) && key >= newLength)) { - deps.push(dep) - } - }) - } else { - // schedule runs for SET | ADD | DELETE - if (key !== void 0) { - deps.push(depsMap.get(key)) - } - - // also run for iteration key on ADD | DELETE | Map.SET - switch (type) { - case TriggerOpTypes.ADD: - if (!isArray(target)) { - deps.push(depsMap.get(ITERATE_KEY)) - if (isMap(target)) { - deps.push(depsMap.get(MAP_KEY_ITERATE_KEY)) - } - } else if (isIntegerKey(key)) { - // new index added to array -> length changes - deps.push(depsMap.get('length')) - } - break - case TriggerOpTypes.DELETE: - if (!isArray(target)) { - deps.push(depsMap.get(ITERATE_KEY)) - if (isMap(target)) { - deps.push(depsMap.get(MAP_KEY_ITERATE_KEY)) - } - } - break - case TriggerOpTypes.SET: - if (isMap(target)) { - deps.push(depsMap.get(ITERATE_KEY)) - } - break - } - } - - const eventInfo = __DEV__ - ? { target, type, key, newValue, oldValue, oldTarget } - : undefined - - if (deps.length === 1) { - if (deps[0]) { - if (__DEV__) { - triggerEffects(deps[0], eventInfo) - } else { - triggerEffects(deps[0]) - } - } - } else { - const effects: ReactiveEffect[] = [] - for (const dep of deps) { - if (dep) { - effects.push(...dep) + if (dep.get(effect) !== effect._trackId) { + dep.set(effect, effect._trackId) + const oldDep = effect.deps[effect._depsLength] + if (oldDep !== dep) { + if (oldDep) { + cleanupDepEffect(oldDep, effect) } + effect.deps[effect._depsLength++] = dep + } else { + effect._depsLength++ } if (__DEV__) { - triggerEffects(createDep(effects), eventInfo) - } else { - triggerEffects(createDep(effects)) + effect.onTrack?.(extend({ effect }, debuggerEventExtraInfo!)) } } } -export function triggerEffects( - dep: Dep | ReactiveEffect[], - debuggerEventExtraInfo?: DebuggerEventExtraInfo -) { - // spread into array for stabilization - const effects = isArray(dep) ? dep : [...dep] - for (const effect of effects) { - if (effect.computed) { - triggerEffect(effect, debuggerEventExtraInfo) - } - } - for (const effect of effects) { - if (!effect.computed) { - triggerEffect(effect, debuggerEventExtraInfo) - } - } -} +const queueEffectSchedulers: (() => void)[] = [] -function triggerEffect( - effect: ReactiveEffect, +export function triggerEffects( + dep: Dep, + dirtyLevel: DirtyLevels, debuggerEventExtraInfo?: DebuggerEventExtraInfo ) { - if (effect !== activeEffect || effect.allowRecurse) { - if (__DEV__ && effect.onTrigger) { - effect.onTrigger(extend({ effect }, debuggerEventExtraInfo)) + pauseScheduling() + for (const effect of dep.keys()) { + if (!effect.allowRecurse && effect._runnings) { + continue } - if (effect.scheduler) { - effect.scheduler() - } else { - effect.run() + if ( + effect._dirtyLevel < dirtyLevel && + (!effect._runnings || dirtyLevel !== DirtyLevels.ComputedValueDirty) + ) { + const lastDirtyLevel = effect._dirtyLevel + effect._dirtyLevel = dirtyLevel + if ( + lastDirtyLevel === DirtyLevels.NotDirty && + (!effect._queryings || dirtyLevel !== DirtyLevels.ComputedValueDirty) + ) { + if (__DEV__) { + effect.onTrigger?.(extend({ effect }, debuggerEventExtraInfo)) + } + effect.trigger() + if (effect.scheduler) { + queueEffectSchedulers.push(effect.scheduler) + } + } } } -} - -export function getDepFromReactive(object: any, key: string | number | symbol) { - return targetMap.get(object)?.get(key) + resetScheduling() } diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index ee4da5b1935..9497527e81e 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -31,7 +31,6 @@ export { shallowReadonly, markRaw, toRaw, - ReactiveFlags /* @remove */, type Raw, type DeepReadonly, type ShallowReactive, @@ -49,12 +48,11 @@ export { deferredComputed } from './deferredComputed' export { effect, stop, - trigger, - track, enableTracking, pauseTracking, resetTracking, - ITERATE_KEY, + pauseScheduling, + resetScheduling, ReactiveEffect, type ReactiveEffectRunner, type ReactiveEffectOptions, @@ -63,6 +61,7 @@ export { type DebuggerEvent, type DebuggerEventExtraInfo } from './effect' +export { trigger, track, ITERATE_KEY } from './reactiveEffect' export { effectScope, EffectScope, @@ -71,5 +70,6 @@ export { } from './effectScope' export { TrackOpTypes /* @remove */, - TriggerOpTypes /* @remove */ -} from './operations' + TriggerOpTypes /* @remove */, + ReactiveFlags /* @remove */ +} from './constants' diff --git a/packages/reactivity/src/operations.ts b/packages/reactivity/src/operations.ts deleted file mode 100644 index 1b96e982571..00000000000 --- a/packages/reactivity/src/operations.ts +++ /dev/null @@ -1,15 +0,0 @@ -// using literal strings instead of numbers so that it's easier to inspect -// debugger events - -export const enum TrackOpTypes { - GET = 'get', - HAS = 'has', - ITERATE = 'iterate' -} - -export const enum TriggerOpTypes { - SET = 'set', - ADD = 'add', - DELETE = 'delete', - CLEAR = 'clear' -} diff --git a/packages/reactivity/src/reactive.ts b/packages/reactivity/src/reactive.ts index 1881955cf1c..2904c69abe2 100644 --- a/packages/reactivity/src/reactive.ts +++ b/packages/reactivity/src/reactive.ts @@ -12,14 +12,7 @@ import { shallowReadonlyCollectionHandlers } from './collectionHandlers' import type { UnwrapRefSimple, Ref, RawSymbol } from './ref' - -export const enum ReactiveFlags { - SKIP = '__v_skip', - IS_REACTIVE = '__v_isReactive', - IS_READONLY = '__v_isReadonly', - IS_SHALLOW = '__v_isShallow', - RAW = '__v_raw' -} +import { ReactiveFlags } from './constants' export interface Target { [ReactiveFlags.SKIP]?: boolean diff --git a/packages/reactivity/src/reactiveEffect.ts b/packages/reactivity/src/reactiveEffect.ts new file mode 100644 index 00000000000..d3474db3da1 --- /dev/null +++ b/packages/reactivity/src/reactiveEffect.ts @@ -0,0 +1,150 @@ +import { isArray, isIntegerKey, isMap, isSymbol } from '@vue/shared' +import { DirtyLevels, TrackOpTypes, TriggerOpTypes } from './constants' +import { createDep, Dep } from './dep' +import { + activeEffect, + pauseScheduling, + resetScheduling, + shouldTrack, + trackEffect, + triggerEffects +} from './effect' + +// The main WeakMap that stores {target -> key -> dep} connections. +// Conceptually, it's easier to think of a dependency as a Dep class +// which maintains a Set of subscribers, but we simply store them as +// raw Sets to reduce memory overhead. +type KeyToDepMap = Map +const targetMap = new WeakMap() + +export const ITERATE_KEY = Symbol(__DEV__ ? 'iterate' : '') +export const MAP_KEY_ITERATE_KEY = Symbol(__DEV__ ? 'Map key iterate' : '') + +/** + * Tracks access to a reactive property. + * + * This will check which effect is running at the moment and record it as dep + * which records all effects that depend on the reactive property. + * + * @param target - Object holding the reactive property. + * @param type - Defines the type of access to the reactive property. + * @param key - Identifier of the reactive property to track. + */ +export function track(target: object, type: TrackOpTypes, key: unknown) { + if (shouldTrack && activeEffect) { + let depsMap = targetMap.get(target) + if (!depsMap) { + targetMap.set(target, (depsMap = new Map())) + } + let dep = depsMap.get(key) + if (!dep) { + depsMap.set(key, (dep = createDep(() => depsMap!.delete(key)))) + } + trackEffect( + activeEffect, + dep, + __DEV__ + ? { + target, + type, + key + } + : void 0 + ) + } +} + +/** + * Finds all deps associated with the target (or a specific property) and + * triggers the effects stored within. + * + * @param target - The reactive object. + * @param type - Defines the type of the operation that needs to trigger effects. + * @param key - Can be used to target a specific reactive property in the target object. + */ +export function trigger( + target: object, + type: TriggerOpTypes, + key?: unknown, + newValue?: unknown, + oldValue?: unknown, + oldTarget?: Map | Set +) { + const depsMap = targetMap.get(target) + if (!depsMap) { + // never been tracked + return + } + + let deps: (Dep | undefined)[] = [] + if (type === TriggerOpTypes.CLEAR) { + // collection being cleared + // trigger all effects for target + deps = [...depsMap.values()] + } else if (key === 'length' && isArray(target)) { + const newLength = Number(newValue) + depsMap.forEach((dep, key) => { + if (key === 'length' || (!isSymbol(key) && key >= newLength)) { + deps.push(dep) + } + }) + } else { + // schedule runs for SET | ADD | DELETE + if (key !== void 0) { + deps.push(depsMap.get(key)) + } + + // also run for iteration key on ADD | DELETE | Map.SET + switch (type) { + case TriggerOpTypes.ADD: + if (!isArray(target)) { + deps.push(depsMap.get(ITERATE_KEY)) + if (isMap(target)) { + deps.push(depsMap.get(MAP_KEY_ITERATE_KEY)) + } + } else if (isIntegerKey(key)) { + // new index added to array -> length changes + deps.push(depsMap.get('length')) + } + break + case TriggerOpTypes.DELETE: + if (!isArray(target)) { + deps.push(depsMap.get(ITERATE_KEY)) + if (isMap(target)) { + deps.push(depsMap.get(MAP_KEY_ITERATE_KEY)) + } + } + break + case TriggerOpTypes.SET: + if (isMap(target)) { + deps.push(depsMap.get(ITERATE_KEY)) + } + break + } + } + + pauseScheduling() + for (const dep of deps) { + if (dep) { + triggerEffects( + dep, + DirtyLevels.Dirty, + __DEV__ + ? { + target, + type, + key, + newValue, + oldValue, + oldTarget + } + : void 0 + ) + } + } + resetScheduling() +} + +export function getDepFromReactive(object: any, key: string | number | symbol) { + return targetMap.get(object)?.get(key) +} diff --git a/packages/reactivity/src/ref.ts b/packages/reactivity/src/ref.ts index 915f5760878..5a4dd710eab 100644 --- a/packages/reactivity/src/ref.ts +++ b/packages/reactivity/src/ref.ts @@ -1,11 +1,10 @@ import { activeEffect, - getDepFromReactive, shouldTrack, - trackEffects, + trackEffect, triggerEffects } from './effect' -import { TrackOpTypes, TriggerOpTypes } from './operations' +import { DirtyLevels, TrackOpTypes, TriggerOpTypes } from './constants' import { isArray, hasChanged, IfAny, isFunction, isObject } from '@vue/shared' import { isProxy, @@ -18,6 +17,8 @@ import { import type { ShallowReactiveMarker } from './reactive' import { CollectionTypes } from './collectionHandlers' import { createDep, Dep } from './dep' +import { ComputedRefImpl } from './computed' +import { getDepFromReactive } from './reactiveEffect' declare const RefSymbol: unique symbol export declare const RawSymbol: unique symbol @@ -40,32 +41,44 @@ type RefBase = { export function trackRefValue(ref: RefBase) { if (shouldTrack && activeEffect) { ref = toRaw(ref) - if (__DEV__) { - trackEffects(ref.dep || (ref.dep = createDep()), { - target: ref, - type: TrackOpTypes.GET, - key: 'value' - }) - } else { - trackEffects(ref.dep || (ref.dep = createDep())) - } + trackEffect( + activeEffect, + ref.dep || + (ref.dep = createDep( + () => (ref.dep = undefined), + ref instanceof ComputedRefImpl ? ref : undefined + )), + __DEV__ + ? { + target: ref, + type: TrackOpTypes.GET, + key: 'value' + } + : void 0 + ) } } -export function triggerRefValue(ref: RefBase, newVal?: any) { +export function triggerRefValue( + ref: RefBase, + dirtyLevel: DirtyLevels = DirtyLevels.Dirty, + newVal?: any +) { ref = toRaw(ref) const dep = ref.dep if (dep) { - if (__DEV__) { - triggerEffects(dep, { - target: ref, - type: TriggerOpTypes.SET, - key: 'value', - newValue: newVal - }) - } else { - triggerEffects(dep) - } + triggerEffects( + dep, + dirtyLevel, + __DEV__ + ? { + target: ref, + type: TriggerOpTypes.SET, + key: 'value', + newValue: newVal + } + : void 0 + ) } } @@ -158,7 +171,7 @@ class RefImpl { if (hasChanged(newVal, this._rawValue)) { this._rawValue = newVal this._value = useDirectValue ? newVal : toReactive(newVal) - triggerRefValue(this, newVal) + triggerRefValue(this, DirtyLevels.Dirty, newVal) } } } @@ -189,7 +202,7 @@ class RefImpl { * @see {@link https://vuejs.org/api/reactivity-advanced.html#triggerref} */ export function triggerRef(ref: Ref) { - triggerRefValue(ref, __DEV__ ? ref.value : void 0) + triggerRefValue(ref, DirtyLevels.Dirty, __DEV__ ? ref.value : void 0) } export type MaybeRef = T | Ref diff --git a/packages/runtime-core/src/apiAsyncComponent.ts b/packages/runtime-core/src/apiAsyncComponent.ts index 342339042ef..535cb83fb5d 100644 --- a/packages/runtime-core/src/apiAsyncComponent.ts +++ b/packages/runtime-core/src/apiAsyncComponent.ts @@ -187,6 +187,7 @@ export function defineAsyncComponent< if (instance.parent && isKeepAlive(instance.parent.vnode)) { // parent is keep-alive, force update so the loaded component's // name is taken into account + instance.parent.effect.dirty = true queueJob(instance.parent.update) } }) diff --git a/packages/runtime-core/src/apiWatch.ts b/packages/runtime-core/src/apiWatch.ts index c307c4198a3..cedebb01af6 100644 --- a/packages/runtime-core/src/apiWatch.ts +++ b/packages/runtime-core/src/apiWatch.ts @@ -322,7 +322,7 @@ function doWatch( ? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE) : INITIAL_WATCHER_VALUE const job: SchedulerJob = () => { - if (!effect.active) { + if (!effect.active || !effect.dirty) { return } if (cb) { @@ -376,7 +376,7 @@ function doWatch( scheduler = () => queueJob(job) } - const effect = new ReactiveEffect(getter, scheduler) + const effect = new ReactiveEffect(getter, NOOP, scheduler) const unwatch = () => { effect.stop() diff --git a/packages/runtime-core/src/componentPublicInstance.ts b/packages/runtime-core/src/componentPublicInstance.ts index b7ef1e07302..7b552c8f92a 100644 --- a/packages/runtime-core/src/componentPublicInstance.ts +++ b/packages/runtime-core/src/componentPublicInstance.ts @@ -267,7 +267,12 @@ export const publicPropertiesMap: PublicPropertiesMap = $root: i => getPublicInstance(i.root), $emit: i => i.emit, $options: i => (__FEATURE_OPTIONS_API__ ? resolveMergedOptions(i) : i.type), - $forceUpdate: i => i.f || (i.f = () => queueJob(i.update)), + $forceUpdate: i => + i.f || + (i.f = () => { + i.effect.dirty = true + queueJob(i.update) + }), $nextTick: i => i.n || (i.n = nextTick.bind(i.proxy!)), $watch: i => (__FEATURE_OPTIONS_API__ ? instanceWatch.bind(i) : NOOP) } as PublicPropertiesMap) diff --git a/packages/runtime-core/src/components/BaseTransition.ts b/packages/runtime-core/src/components/BaseTransition.ts index 9cb80b94ef0..ef0632384d6 100644 --- a/packages/runtime-core/src/components/BaseTransition.ts +++ b/packages/runtime-core/src/components/BaseTransition.ts @@ -246,6 +246,7 @@ const BaseTransitionImpl: ComponentOptions = { // #6835 // it also needs to be updated when active is undefined if (instance.update.active !== false) { + instance.effect.dirty = true instance.update() } } diff --git a/packages/runtime-core/src/hmr.ts b/packages/runtime-core/src/hmr.ts index 1ce66a3da1e..cdf291989bd 100644 --- a/packages/runtime-core/src/hmr.ts +++ b/packages/runtime-core/src/hmr.ts @@ -93,6 +93,7 @@ function rerender(id: string, newRender?: Function) { instance.renderCache = [] // this flag forces child components with slot content to update isHmrUpdating = true + instance.effect.dirty = true instance.update() isHmrUpdating = false }) @@ -137,6 +138,7 @@ function reload(id: string, newComp: HMRComponent) { // 4. Force the parent instance to re-render. This will cause all updated // components to be unmounted and re-mounted. Queue the update so that we // don't end up forcing the same parent to re-render multiple times. + instance.parent.effect.dirty = true queueJob(instance.parent.update) } else if (instance.appContext.reload) { // root instance mounted via createApp() has a reload method diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 8799ecd473c..8dbc1c796d5 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -1280,6 +1280,7 @@ function baseCreateRenderer( // double updating the same child component in the same flush. invalidateJob(instance.update) // instance.update is the reactive effect. + instance.effect.dirty = true instance.update() } } else { @@ -1544,11 +1545,16 @@ function baseCreateRenderer( // create reactive effect for rendering const effect = (instance.effect = new ReactiveEffect( componentUpdateFn, + NOOP, () => queueJob(update), instance.scope // track it in component's effect scope )) - const update: SchedulerJob = (instance.update = () => effect.run()) + const update: SchedulerJob = (instance.update = () => { + if (effect.dirty) { + effect.run() + } + }) update.id = instance.uid // allowRecurse // #1801, #2043 component render effects should allow recursive updates