diff --git a/packages/reactivity/__tests__/effect.spec.ts b/packages/reactivity/__tests__/effect.spec.ts index d458a17d840..6a05038554b 100644 --- a/packages/reactivity/__tests__/effect.spec.ts +++ b/packages/reactivity/__tests__/effect.spec.ts @@ -8,7 +8,8 @@ import { DebuggerEvent, markRaw, shallowReactive, - readonly + readonly, + ReactiveEffectRunner } from '../src/index' import { ITERATE_KEY } from '../src/effect' @@ -490,6 +491,96 @@ describe('reactivity/effect', () => { expect(conditionalSpy).toHaveBeenCalledTimes(2) }) + it('should handle deep effect recursion using cleanup fallback', () => { + const results = reactive([0]) + const effects: { fx: ReactiveEffectRunner; index: number }[] = [] + for (let i = 1; i < 40; i++) { + ;(index => { + const fx = effect(() => { + results[index] = results[index - 1] * 2 + }) + effects.push({ fx, index }) + })(i) + } + + expect(results[39]).toBe(0) + results[0] = 1 + expect(results[39]).toBe(Math.pow(2, 39)) + }) + + it('should register deps independently during effect recursion', () => { + const input = reactive({ a: 1, b: 2, c: 0 }) + const output = reactive({ fx1: 0, fx2: 0 }) + + const fx1Spy = jest.fn(() => { + let result = 0 + if (input.c < 2) result += input.a + if (input.c > 1) result += input.b + output.fx1 = result + }) + + const fx1 = effect(fx1Spy) + + const fx2Spy = jest.fn(() => { + let result = 0 + if (input.c > 1) result += input.a + if (input.c < 3) result += input.b + output.fx2 = result + output.fx1 + }) + + const fx2 = effect(fx2Spy) + + expect(fx1).not.toBeNull() + expect(fx2).not.toBeNull() + + expect(output.fx1).toBe(1) + expect(output.fx2).toBe(2 + 1) + expect(fx1Spy).toHaveBeenCalledTimes(1) + expect(fx2Spy).toHaveBeenCalledTimes(1) + + fx1Spy.mockClear() + fx2Spy.mockClear() + input.b = 3 + expect(output.fx1).toBe(1) + expect(output.fx2).toBe(3 + 1) + expect(fx1Spy).toHaveBeenCalledTimes(0) + expect(fx2Spy).toHaveBeenCalledTimes(1) + + fx1Spy.mockClear() + fx2Spy.mockClear() + input.c = 1 + expect(output.fx1).toBe(1) + expect(output.fx2).toBe(3 + 1) + expect(fx1Spy).toHaveBeenCalledTimes(1) + expect(fx2Spy).toHaveBeenCalledTimes(1) + + fx1Spy.mockClear() + fx2Spy.mockClear() + input.c = 2 + expect(output.fx1).toBe(3) + expect(output.fx2).toBe(1 + 3 + 3) + expect(fx1Spy).toHaveBeenCalledTimes(1) + + // Invoked twice due to change of fx1. + expect(fx2Spy).toHaveBeenCalledTimes(2) + + fx1Spy.mockClear() + fx2Spy.mockClear() + input.c = 3 + expect(output.fx1).toBe(3) + expect(output.fx2).toBe(1 + 3) + expect(fx1Spy).toHaveBeenCalledTimes(1) + expect(fx2Spy).toHaveBeenCalledTimes(1) + + fx1Spy.mockClear() + fx2Spy.mockClear() + input.a = 10 + expect(output.fx1).toBe(3) + expect(output.fx2).toBe(10 + 3) + expect(fx1Spy).toHaveBeenCalledTimes(0) + expect(fx2Spy).toHaveBeenCalledTimes(1) + }) + it('should not double wrap if the passed function is a effect', () => { const runner = effect(() => {}) const otherRunner = effect(runner) diff --git a/packages/reactivity/src/Dep.ts b/packages/reactivity/src/Dep.ts new file mode 100644 index 00000000000..1d735466fc4 --- /dev/null +++ b/packages/reactivity/src/Dep.ts @@ -0,0 +1,51 @@ +import { ReactiveEffect, getTrackOpBit } from './effect' + +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 wheter the dependency + * was/is tracked. + */ +type TrackedMarkers = { wasTracked: number; newTracked: number } + +export function createDep(effects?: ReactiveEffect[]): Dep { + const dep = new Set(effects) as Dep + dep.wasTracked = 0 + dep.newTracked = 0 + return dep +} + +export function wasTracked(dep: Dep): boolean { + return hasBit(dep.wasTracked, getTrackOpBit()) +} + +export function newTracked(dep: Dep): boolean { + return hasBit(dep.newTracked, getTrackOpBit()) +} + +export function setWasTracked(dep: Dep) { + dep.wasTracked = setBit(dep.wasTracked, getTrackOpBit()) +} + +export function setNewTracked(dep: Dep) { + dep.newTracked = setBit(dep.newTracked, getTrackOpBit()) +} + +export function resetTracked(dep: Dep) { + const trackOpBit = getTrackOpBit() + dep.wasTracked = clearBit(dep.wasTracked, trackOpBit) + dep.newTracked = clearBit(dep.newTracked, trackOpBit) +} + +function hasBit(value: number, bit: number): boolean { + return (value & bit) > 0 +} + +function setBit(value: number, bit: number): number { + return value | bit +} + +function clearBit(value: number, bit: number): number { + return value & ~bit +} diff --git a/packages/reactivity/src/computed.ts b/packages/reactivity/src/computed.ts index 0fe19191eb7..e1bdfd7c9e9 100644 --- a/packages/reactivity/src/computed.ts +++ b/packages/reactivity/src/computed.ts @@ -2,6 +2,7 @@ import { ReactiveEffect } from './effect' import { Ref, trackRefValue, triggerRefValue } from './ref' import { isFunction, NOOP } from '@vue/shared' import { ReactiveFlags, toRaw } from './reactive' +import { Dep } from './Dep' export interface ComputedRef extends WritableComputedRef { readonly value: T @@ -31,7 +32,7 @@ export const setComputedScheduler = (s: ComputedScheduler | undefined) => { } class ComputedRefImpl { - public dep?: Set = undefined + public dep?: Dep = undefined private _value!: T private _dirty = true diff --git a/packages/reactivity/src/effect.ts b/packages/reactivity/src/effect.ts index 37c80d84896..670b6b340c5 100644 --- a/packages/reactivity/src/effect.ts +++ b/packages/reactivity/src/effect.ts @@ -1,12 +1,20 @@ import { TrackOpTypes, TriggerOpTypes } from './operations' import { extend, isArray, isIntegerKey, isMap } from '@vue/shared' import { EffectScope, recordEffectScope } from './effectScope' +import { + createDep, + Dep, + newTracked, + resetTracked, + setNewTracked, + setWasTracked, + wasTracked +} from './Dep' // 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 Dep = Set type KeyToDepMap = Map const targetMap = new WeakMap() @@ -56,19 +64,57 @@ export class ReactiveEffect { return this.fn() } if (!effectStack.includes(this)) { - this.cleanup() try { - enableTracking() effectStack.push((activeEffect = this)) + enableTracking() + + effectTrackDepth++ + + if (effectTrackDepth <= maxMarkerBits) { + this.initDepMarkers() + } else { + this.cleanup() + } return this.fn() } finally { - effectStack.pop() + if (effectTrackDepth <= maxMarkerBits) { + this.finalizeDepMarkers() + } + effectTrackDepth-- resetTracking() - activeEffect = effectStack[effectStack.length - 1] + effectStack.pop() + const n = effectStack.length + activeEffect = n > 0 ? effectStack[n - 1] : undefined } } } + initDepMarkers() { + const { deps } = this + if (deps.length) { + for (let i = 0; i < deps.length; i++) { + setWasTracked(deps[i]) + } + } + } + + finalizeDepMarkers() { + const { deps } = this + 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(this) + } else { + deps[ptr++] = dep + } + resetTracked(dep) + } + deps.length = ptr + } + } + cleanup() { const { deps } = this if (deps.length) { @@ -90,6 +136,20 @@ export class ReactiveEffect { } } +// The number of effects currently being tracked recursively. +let effectTrackDepth = 0 + +/** + * The bitwise track markers support at most 30 levels op 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 function getTrackOpBit(): number { + return 1 << effectTrackDepth +} + export interface ReactiveEffectOptions { lazy?: boolean scheduler?: EffectScheduler @@ -158,7 +218,8 @@ export function track(target: object, type: TrackOpTypes, key: unknown) { } let dep = depsMap.get(key) if (!dep) { - depsMap.set(key, (dep = new Set())) + dep = createDep() + depsMap.set(key, dep) } const eventInfo = __DEV__ @@ -173,10 +234,21 @@ export function isTracking() { } export function trackEffects( - dep: Set, + dep: Dep, debuggerEventExtraInfo?: DebuggerEventExtraInfo ) { - if (!dep.has(activeEffect!)) { + let shouldTrack = false + if (effectTrackDepth <= maxMarkerBits) { + if (!newTracked(dep)) { + setNewTracked(dep) + shouldTrack = !wasTracked(dep) + } + } else { + // Full cleanup mode. + shouldTrack = !dep.has(activeEffect!) + } + + if (shouldTrack) { dep.add(activeEffect!) activeEffect!.deps.push(dep) if (__DEV__ && activeEffect!.onTrack) { @@ -267,7 +339,7 @@ export function trigger( effects.push(...dep) } } - triggerEffects(new Set(effects), eventInfo) + triggerEffects(createDep(effects), eventInfo) } } diff --git a/packages/reactivity/src/ref.ts b/packages/reactivity/src/ref.ts index 2cb6f1ef187..ec40e67b39e 100644 --- a/packages/reactivity/src/ref.ts +++ b/packages/reactivity/src/ref.ts @@ -1,13 +1,9 @@ -import { - isTracking, - ReactiveEffect, - trackEffects, - triggerEffects -} from './effect' +import { isTracking, trackEffects, triggerEffects } from './effect' import { TrackOpTypes, TriggerOpTypes } from './operations' import { isArray, isObject, hasChanged } from '@vue/shared' import { reactive, isProxy, toRaw, isReactive } from './reactive' import { CollectionTypes } from './collectionHandlers' +import { createDep, Dep } from './Dep' export declare const RefSymbol: unique symbol @@ -27,11 +23,11 @@ export interface Ref { /** * Deps are maintained locally rather than in depsMap for performance reasons. */ - dep?: Set + dep?: Dep } type RefBase = { - dep?: Set + dep?: Dep value: T } @@ -39,7 +35,7 @@ export function trackRefValue(ref: RefBase) { if (isTracking()) { ref = toRaw(ref) if (!ref.dep) { - ref.dep = new Set() + ref.dep = createDep() } if (__DEV__) { trackEffects(ref.dep, { @@ -101,7 +97,7 @@ export function shallowRef(value?: unknown) { } class RefImpl { - public dep?: Set = undefined + public dep?: Dep = undefined private _value: T @@ -170,7 +166,7 @@ export type CustomRefFactory = ( } class CustomRefImpl { - public dep?: Set = undefined + public dep?: Dep = undefined private readonly _get: ReturnType>['get'] private readonly _set: ReturnType>['set'] diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index f6d47b81575..b0feb2c275a 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -1406,25 +1406,32 @@ function baseCreateRenderer( isSVG, optimized ) => { - const componentUpdateFn = () => { + const componentUpdateFn = function(this: ReactiveEffect) { if (!instance.isMounted) { let vnodeHook: VNodeHook | null | undefined const { el, props } = initialVNode const { bm, m, parent } = instance - // beforeMount hook - if (bm) { - invokeArrayFns(bm) - } - // onVnodeBeforeMount - if ((vnodeHook = props && props.onVnodeBeforeMount)) { - invokeVNodeHook(vnodeHook, parent, initialVNode) - } - if ( - __COMPAT__ && - isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance) - ) { - instance.emit('hook:beforeMount') + try { + // Disallow component effect recursion during pre-lifecycle hooks. + this.allowRecurse = false + + // beforeMount hook + if (bm) { + invokeArrayFns(bm) + } + // onVnodeBeforeMount + if ((vnodeHook = props && props.onVnodeBeforeMount)) { + invokeVNodeHook(vnodeHook, parent, initialVNode) + } + if ( + __COMPAT__ && + isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance) + ) { + instance.emit('hook:beforeMount') + } + } finally { + this.allowRecurse = true } if (el && hydrateNode) { @@ -1551,19 +1558,26 @@ function baseCreateRenderer( next = vnode } - // beforeUpdate hook - if (bu) { - invokeArrayFns(bu) - } - // onVnodeBeforeUpdate - if ((vnodeHook = next.props && next.props.onVnodeBeforeUpdate)) { - invokeVNodeHook(vnodeHook, parent, next, vnode) - } - if ( - __COMPAT__ && - isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance) - ) { - instance.emit('hook:beforeUpdate') + try { + // Disallow component effect recursion during pre-lifecycle hooks. + this.allowRecurse = false + + // beforeUpdate hook + if (bu) { + invokeArrayFns(bu) + } + // onVnodeBeforeUpdate + if ((vnodeHook = next.props && next.props.onVnodeBeforeUpdate)) { + invokeVNodeHook(vnodeHook, parent, next, vnode) + } + if ( + __COMPAT__ && + isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance) + ) { + instance.emit('hook:beforeUpdate') + } + } finally { + this.allowRecurse = true } // render