--- /dev/null
+import { nextTick, watch, watchEffect } from '@vue/runtime-core'
+import {
+ reactive,
+ effect,
+ EffectScope,
+ onScopeDispose,
+ computed,
+ ref,
+ ComputedRef
+} from '../src'
+
+describe('reactivity/effect/scope', () => {
+ it('should run', () => {
+ const fnSpy = jest.fn(() => {})
+ new EffectScope().run(fnSpy)
+ expect(fnSpy).toHaveBeenCalledTimes(1)
+ })
+
+ it('should accept zero argument', () => {
+ const scope = new EffectScope()
+ expect(scope.effects.length).toBe(0)
+ })
+
+ it('should return run value', () => {
+ expect(new EffectScope().run(() => 1)).toBe(1)
+ })
+
+ it('should collect the effects', () => {
+ const scope = new EffectScope()
+ scope.run(() => {
+ let dummy
+ const counter = reactive({ num: 0 })
+ effect(() => (dummy = counter.num))
+
+ expect(dummy).toBe(0)
+ counter.num = 7
+ expect(dummy).toBe(7)
+ })
+
+ expect(scope.effects.length).toBe(1)
+ })
+
+ it('stop', () => {
+ let dummy, doubled
+ const counter = reactive({ num: 0 })
+
+ const scope = new EffectScope()
+ scope.run(() => {
+ effect(() => (dummy = counter.num))
+ effect(() => (doubled = counter.num * 2))
+ })
+
+ expect(scope.effects.length).toBe(2)
+
+ expect(dummy).toBe(0)
+ counter.num = 7
+ expect(dummy).toBe(7)
+ expect(doubled).toBe(14)
+
+ scope.stop()
+
+ counter.num = 6
+ expect(dummy).toBe(7)
+ expect(doubled).toBe(14)
+ })
+
+ it('should collect nested scope', () => {
+ let dummy, doubled
+ const counter = reactive({ num: 0 })
+
+ const scope = new EffectScope()
+ scope.run(() => {
+ effect(() => (dummy = counter.num))
+ // nested scope
+ new EffectScope().run(() => {
+ effect(() => (doubled = counter.num * 2))
+ })
+ })
+
+ expect(scope.effects.length).toBe(2)
+ expect(scope.effects[1]).toBeInstanceOf(EffectScope)
+
+ expect(dummy).toBe(0)
+ counter.num = 7
+ expect(dummy).toBe(7)
+ expect(doubled).toBe(14)
+
+ // stop the nested scope as well
+ scope.stop()
+
+ counter.num = 6
+ expect(dummy).toBe(7)
+ expect(doubled).toBe(14)
+ })
+
+ it('nested scope can be escaped', () => {
+ let dummy, doubled
+ const counter = reactive({ num: 0 })
+
+ const scope = new EffectScope()
+ scope.run(() => {
+ effect(() => (dummy = counter.num))
+ // nested scope
+ new EffectScope(true).run(() => {
+ effect(() => (doubled = counter.num * 2))
+ })
+ })
+
+ expect(scope.effects.length).toBe(1)
+
+ expect(dummy).toBe(0)
+ counter.num = 7
+ expect(dummy).toBe(7)
+ expect(doubled).toBe(14)
+
+ scope.stop()
+
+ counter.num = 6
+ expect(dummy).toBe(7)
+
+ // nested scope should not be stoped
+ expect(doubled).toBe(12)
+ })
+
+ it('able to run the scope', () => {
+ let dummy, doubled
+ const counter = reactive({ num: 0 })
+
+ const scope = new EffectScope()
+ scope.run(() => {
+ effect(() => (dummy = counter.num))
+ })
+
+ expect(scope.effects.length).toBe(1)
+
+ scope.run(() => {
+ effect(() => (doubled = counter.num * 2))
+ })
+
+ expect(scope.effects.length).toBe(2)
+
+ counter.num = 7
+ expect(dummy).toBe(7)
+ expect(doubled).toBe(14)
+
+ scope.stop()
+ })
+
+ it('can not run an inactive scope', () => {
+ let dummy, doubled
+ const counter = reactive({ num: 0 })
+
+ const scope = new EffectScope()
+ scope.run(() => {
+ effect(() => (dummy = counter.num))
+ })
+
+ expect(scope.effects.length).toBe(1)
+
+ scope.stop()
+
+ scope.run(() => {
+ effect(() => (doubled = counter.num * 2))
+ })
+
+ expect('[Vue warn] cannot run an inactive effect scope.').toHaveBeenWarned()
+
+ expect(scope.effects.length).toBe(1)
+
+ counter.num = 7
+ expect(dummy).toBe(0)
+ expect(doubled).toBe(undefined)
+ })
+
+ it('should fire onDispose hook', () => {
+ let dummy = 0
+
+ const scope = new EffectScope()
+ scope.run(() => {
+ onScopeDispose(() => (dummy += 1))
+ onScopeDispose(() => (dummy += 2))
+ })
+
+ scope.run(() => {
+ onScopeDispose(() => (dummy += 4))
+ })
+
+ expect(dummy).toBe(0)
+
+ scope.stop()
+ expect(dummy).toBe(7)
+ })
+
+ it('test with higher level APIs', async () => {
+ const r = ref(1)
+
+ const computedSpy = jest.fn()
+ const watchSpy = jest.fn()
+ const watchEffectSpy = jest.fn()
+
+ let c: ComputedRef
+ const scope = new EffectScope()
+ scope.run(() => {
+ c = computed(() => {
+ computedSpy()
+ return r.value + 1
+ })
+
+ watch(r, watchSpy)
+ watchEffect(() => {
+ watchEffectSpy()
+ r.value
+ })
+ })
+
+ c!.value // computed is lazy so trigger collection
+ expect(computedSpy).toHaveBeenCalledTimes(1)
+ expect(watchSpy).toHaveBeenCalledTimes(0)
+ expect(watchEffectSpy).toHaveBeenCalledTimes(1)
+
+ r.value++
+ c!.value
+ await nextTick()
+ expect(computedSpy).toHaveBeenCalledTimes(2)
+ expect(watchSpy).toHaveBeenCalledTimes(1)
+ expect(watchEffectSpy).toHaveBeenCalledTimes(2)
+
+ scope.stop()
+
+ r.value++
+ c!.value
+ await nextTick()
+ // should not trigger anymore
+ expect(computedSpy).toHaveBeenCalledTimes(2)
+ expect(watchSpy).toHaveBeenCalledTimes(1)
+ expect(watchEffectSpy).toHaveBeenCalledTimes(2)
+ })
+})
import { TrackOpTypes, TriggerOpTypes } from './operations'
import { extend, isArray, isIntegerKey, isMap } from '@vue/shared'
+import { EffectScope, recordEffectScope } from './effectScope'
// The main WeakMap that stores {target -> key -> dep} connections.
// Conceptually, it's easier to think of a dependency as a Dep class
constructor(
public fn: () => T,
public scheduler: EffectScheduler | null = null,
+ scope?: EffectScope | null,
// allow recursive self-invocation
public allowRecurse = false
- ) {}
+ ) {
+ recordEffectScope(this, scope)
+ }
run() {
if (!this.active) {
} finally {
effectStack.pop()
resetTracking()
- const n = effectStack.length
- activeEffect = n > 0 ? effectStack[n - 1] : undefined
+ activeEffect = effectStack[effectStack.length - 1]
}
}
}
export interface ReactiveEffectOptions {
lazy?: boolean
scheduler?: EffectScheduler
+ scope?: EffectScope
allowRecurse?: boolean
onStop?: () => void
onTrack?: (event: DebuggerEvent) => void
const _effect = new ReactiveEffect(fn)
if (options) {
extend(_effect, options)
+ if (options.scope) recordEffectScope(_effect, options.scope)
}
if (!options || !options.lazy) {
_effect.run()
--- /dev/null
+import { ReactiveEffect } from './effect'
+import { warn } from './warning'
+
+let activeEffectScope: EffectScope | undefined
+const effectScopeStack: EffectScope[] = []
+
+export class EffectScope {
+ active = true
+ effects: (ReactiveEffect | EffectScope)[] = []
+ cleanups: (() => void)[] = []
+
+ constructor(detached = false) {
+ if (!detached) {
+ recordEffectScope(this)
+ }
+ }
+
+ run<T>(fn: () => T): T | undefined {
+ if (this.active) {
+ try {
+ this.on()
+ return fn()
+ } finally {
+ this.off()
+ }
+ } else if (__DEV__) {
+ warn(`cannot run an inactive effect scope.`)
+ }
+ }
+
+ on() {
+ if (this.active) {
+ effectScopeStack.push(this)
+ activeEffectScope = this
+ }
+ }
+
+ off() {
+ if (this.active) {
+ effectScopeStack.pop()
+ activeEffectScope = effectScopeStack[effectScopeStack.length - 1]
+ }
+ }
+
+ stop() {
+ if (this.active) {
+ this.effects.forEach(e => e.stop())
+ this.cleanups.forEach(cleanup => cleanup())
+ this.active = false
+ }
+ }
+}
+
+export function effectScope(detached?: boolean) {
+ return new EffectScope(detached)
+}
+
+export function recordEffectScope(
+ effect: ReactiveEffect | EffectScope,
+ scope?: EffectScope | null
+) {
+ scope = scope || activeEffectScope
+ if (scope && scope.active) {
+ scope.effects.push(effect)
+ }
+}
+
+export function getCurrentScope() {
+ return activeEffectScope
+}
+
+export function onScopeDispose(fn: () => void) {
+ if (activeEffectScope) {
+ activeEffectScope.cleanups.push(fn)
+ } else if (__DEV__) {
+ warn(
+ `onDispose() is called when there is no active effect scope ` +
+ ` to be associated with.`
+ )
+ }
+}
EffectScheduler,
DebuggerEvent
} from './effect'
+export {
+ effectScope,
+ EffectScope,
+ getCurrentScope,
+ onScopeDispose
+} from './effectScope'
export { TrackOpTypes, TriggerOpTypes } from './operations'
--- /dev/null
+export function warn(msg: string, ...args: any[]) {
+ console.warn(`[Vue warn] ${msg}`, ...args)
+}
render(h(Comp), nodeOps.createElement('div'))
expect(instance!).toBeDefined()
- expect(instance!.effects).toBeInstanceOf(Array)
- expect(instance!.effects!.length).toBe(1)
+ expect(instance!.scope.effects).toBeInstanceOf(Array)
+ // includes the component's own render effect AND the watcher effect
+ expect(instance!.scope.effects!.length).toBe(2)
_show!.value = false
await nextTick()
await nextTick()
- expect(instance!.effects![0].active).toBe(false)
+ expect(instance!.scope.effects![0].active).toBe(false)
})
test('this.$watch should pass `this.proxy` to watch source as the first argument ', () => {
+++ /dev/null
-import {
- computed as _computed,
- ComputedRef,
- WritableComputedOptions,
- WritableComputedRef,
- ComputedGetter
-} from '@vue/reactivity'
-import { recordInstanceBoundEffect } from './component'
-
-export function computed<T>(getter: ComputedGetter<T>): ComputedRef<T>
-export function computed<T>(
- options: WritableComputedOptions<T>
-): WritableComputedRef<T>
-export function computed<T>(
- getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>
-) {
- const c = _computed(getterOrOptions as any)
- recordInstanceBoundEffect(c.effect)
- return c
-}
currentInstance,
isInSSRComponentSetup,
LifecycleHooks,
- setCurrentInstance
+ setCurrentInstance,
+ unsetCurrentInstance
} from './component'
import { ComponentPublicInstance } from './componentPublicInstance'
import { callWithAsyncErrorHandling, ErrorTypeStrings } from './errorHandling'
// can only be false when the user does something really funky.
setCurrentInstance(target)
const res = callWithAsyncErrorHandling(hook, target, type, args)
- setCurrentInstance(null)
+ unsetCurrentInstance()
resetTracking()
return res
})
getCurrentInstance,
setCurrentInstance,
SetupContext,
- createSetupContext
+ createSetupContext,
+ unsetCurrentInstance
} from './component'
import { EmitFn, EmitsOptions } from './componentEmits'
import {
* @internal
*/
export function withAsyncContext(getAwaitable: () => any) {
- const ctx = getCurrentInstance()
+ const ctx = getCurrentInstance()!
+ if (__DEV__ && !ctx) {
+ warn(
+ `withAsyncContext called without active current instance. ` +
+ `This is likely a bug.`
+ )
+ }
let awaitable = getAwaitable()
- setCurrentInstance(null)
+ unsetCurrentInstance()
if (isPromise(awaitable)) {
awaitable = awaitable.catch(e => {
setCurrentInstance(ctx)
import {
currentInstance,
ComponentInternalInstance,
- isInSSRComponentSetup,
- recordInstanceBoundEffect
+ isInSSRComponentSetup
} from './component'
import {
ErrorCodes,
}
}
- const effect = new ReactiveEffect(getter, scheduler)
+ const scope = instance && instance.scope
+ const effect = new ReactiveEffect(getter, scheduler, scope)
if (__DEV__) {
effect.onTrack = onTrack
effect.onTrigger = onTrigger
}
- recordInstanceBoundEffect(effect, instance)
-
// initial run
if (cb) {
if (immediate) {
return () => {
effect.stop()
- if (instance) {
- remove(instance.effects!, effect)
+ if (scope) {
+ remove(scope.effects!, effect)
}
}
}
}
delete app._container.__vue_app__
} else {
- const { bum, effects, um } = instance
+ const { bum, scope, um } = instance
// beforeDestroy hooks
if (bum) {
invokeArrayFns(bum)
instance.emit('hook:beforeDestroy')
}
// stop effects
- if (effects) {
- for (let i = 0; i < effects.length; i++) {
- effects[i].stop()
- }
+ if (scope) {
+ scope.stop()
}
// unmounted hook
if (um) {
import { VNode, VNodeChild, isVNode } from './vnode'
import {
- ReactiveEffect,
pauseTracking,
resetTracking,
shallowReadonly,
proxyRefs,
+ EffectScope,
markRaw
} from '@vue/reactivity'
import {
* Root vnode of this component's own vdom tree
*/
subTree: VNode
- /**
- * Main update effect
- * @internal
- */
- effect: ReactiveEffect
/**
* Bound effect runner to be passed to schedulers
*/
* so that they can be automatically stopped on component unmount
* @internal
*/
- effects: ReactiveEffect[] | null
+ scope: EffectScope
/**
* cache for proxy access type to avoid hasOwnProperty calls
* @internal
root: null!, // to be immediately set
next: null,
subTree: null!, // will be set synchronously right after creation
- effect: null!, // will be set synchronously right after creation
update: null!, // will be set synchronously right after creation
+ scope: new EffectScope(),
render: null,
proxy: null,
exposed: null,
exposeProxy: null,
withProxy: null,
- effects: null,
provides: parent ? parent.provides : Object.create(appContext.provides),
accessCache: null!,
renderCache: [],
export const getCurrentInstance: () => ComponentInternalInstance | null = () =>
currentInstance || currentRenderingInstance
-export const setCurrentInstance = (
- instance: ComponentInternalInstance | null
-) => {
+export const setCurrentInstance = (instance: ComponentInternalInstance) => {
currentInstance = instance
+ instance.scope.on()
+}
+
+export const unsetCurrentInstance = () => {
+ currentInstance && currentInstance.scope.off()
+ currentInstance = null
}
const isBuiltInTag = /*#__PURE__*/ makeMap('slot,component')
const setupContext = (instance.setupContext =
setup.length > 1 ? createSetupContext(instance) : null)
- currentInstance = instance
+ setCurrentInstance(instance)
pauseTracking()
const setupResult = callWithErrorHandling(
setup,
[__DEV__ ? shallowReadonly(instance.props) : instance.props, setupContext]
)
resetTracking()
- currentInstance = null
+ unsetCurrentInstance()
if (isPromise(setupResult)) {
- const unsetInstance = () => {
- currentInstance = null
- }
- setupResult.then(unsetInstance, unsetInstance)
+ setupResult.then(unsetCurrentInstance, unsetCurrentInstance)
if (isSSR) {
// return the promise so server-renderer can wait on it
// support for 2.x options
if (__FEATURE_OPTIONS_API__ && !(__COMPAT__ && skipOptions)) {
- currentInstance = instance
+ setCurrentInstance(instance)
pauseTracking()
applyOptions(instance)
resetTracking()
- currentInstance = null
+ unsetCurrentInstance()
}
// warn missing template/render
}
}
-// record effects created during a component's setup() so that they can be
-// stopped when the component unmounts
-export function recordInstanceBoundEffect(
- effect: ReactiveEffect,
- instance = currentInstance
-) {
- if (instance) {
- ;(instance.effects || (instance.effects = [])).push(effect)
- }
-}
-
const classifyRE = /(?:^|[-_])(\w)/g
const classify = (str: string): string =>
str.replace(classifyRE, c => c.toUpperCase()).replace(/[-_]/g, '')
NOOP,
isPromise
} from '@vue/shared'
-import { computed } from './apiComputed'
+import { computed } from '@vue/reactivity'
import {
watch,
WatchOptions,
ComponentInternalInstance,
ComponentOptions,
ConcreteComponent,
- setCurrentInstance
+ setCurrentInstance,
+ unsetCurrentInstance
} from './component'
import { isEmitListener } from './componentEmits'
import { InternalObjectKey } from './vnode'
: null,
props
)
- setCurrentInstance(null)
+ unsetCurrentInstance()
}
} else {
value = defaultValue
export const version = __VERSION__
export {
// core
+ computed,
reactive,
ref,
readonly,
shallowReactive,
shallowReadonly,
markRaw,
- toRaw
+ toRaw,
+ // effect
+ effect,
+ stop,
+ ReactiveEffect,
+ // effect scope
+ effectScope,
+ EffectScope,
+ getCurrentScope,
+ onScopeDispose
} from '@vue/reactivity'
-export { computed } from './apiComputed'
export { watch, watchEffect } from './apiWatch'
export {
onBeforeMount,
}
export {
- ReactiveEffect,
ReactiveEffectOptions,
DebuggerEvent,
TrackOpTypes,
}
// create reactive effect for rendering
- const effect = (instance.effect = new ReactiveEffect(
+ const effect = new ReactiveEffect(
componentUpdateFn,
() => queueJob(instance.update),
+ instance.scope, // track it in component's effect scope
true /* allowRecurse */
- ))
+ )
const update = (instance.update = effect.run.bind(effect) as SchedulerJob)
update.id = instance.uid
unregisterHMR(instance)
}
- const { bum, effect, effects, update, subTree, um } = instance
+ const { bum, scope, update, subTree, um } = instance
// beforeUnmount hook
if (bum) {
invokeArrayFns(bum)
}
+
if (
__COMPAT__ &&
isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)
instance.emit('hook:beforeDestroy')
}
- if (effects) {
- for (let i = 0; i < effects.length; i++) {
- effects[i].stop()
- }
+ if (scope) {
+ scope.stop()
}
+
// update may be null if a component is unmounted before its async
// setup has resolved.
- if (effect) {
- effect.stop()
+ if (update) {
// so that scheduler will no longer invoke it
update.active = false
unmount(subTree, instance, parentSuspense, doRemove)