Repository: vuejs/composition-api
Pull request #762 (2021-07-15 06:47:48)

feat: implement `effectScope` api
Limitations:

- `scope.effects` is not equivalent with Vue 3, as there is not `effect` in Vue 2. It only stores nested `effectScope`.
- async function is not supported (could possibly introduce [`withAsyncContext`](https://github.com/vuejs/rfcs/blob/3478b0be65bb80e5d0eeb8fc54357e2445a84824/active-rfcs/0038-script-setup.md#top-level-await) from Vue 3.2, but with another PR).


Patch:
diff --git a/src/apis/computed.ts b/src/apis/computed.ts
index 9ac0b28..e386cc4 100644
--- a/src/apis/computed.ts
+++ b/src/apis/computed.ts
@@ -1,4 +1,4 @@
-import { getVueConstructor, getCurrentInstance } from '../runtimeContext'
+import { getVueConstructor } from '../runtimeContext'
 import { createRef, Ref } from '../reactivity'
 import {
   warn,
@@ -6,6 +6,7 @@ import {
   defineComponentInstance,
   getVueInternalClasses,
 } from '../utils'
+import { getCurrentScopeVM } from './effectScope'
 
 export interface ComputedRef<T = any> extends WritableComputedRef<T> {
   readonly value: T
@@ -31,7 +32,7 @@ export function computed<T>(
 export function computed<T>(
   getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>
 ): ComputedRef<T> | WritableComputedRef<T> {
-  const vm = getCurrentInstance()?.proxy
+  const vm = getCurrentScopeVM()
   let getter: ComputedGetter<T>
   let setter: ComputedSetter<T> | undefined
 
diff --git a/src/apis/effectScope.ts b/src/apis/effectScope.ts
new file mode 100644
index 0000000..486a387
--- /dev/null
+++ b/src/apis/effectScope.ts
@@ -0,0 +1,105 @@
+import {
+  getCurrentInstance,
+  getVueConstructor,
+  withCurrentInstanceTrackingDisabled,
+} from '../runtimeContext'
+import { defineComponentInstance } from '../utils'
+import { warn } from './warn'
+
+let activeEffectScope: EffectScope | undefined
+const effectScopeStack: EffectScope[] = []
+
+export class EffectScope {
+  active = true
+  effects: EffectScope[] = []
+  cleanups: (() => void)[] = []
+
+  /**
+   * @internal
+   **/
+  vm: Vue
+
+  constructor(detached = false) {
+    let vm: Vue = undefined!
+    withCurrentInstanceTrackingDisabled(() => {
+      vm = defineComponentInstance(getVueConstructor())
+    })
+    this.vm = vm
+    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.`)
+    }
+    return
+  }
+
+  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.vm.$destroy()
+      this.effects.forEach((e) => e.stop())
+      this.cleanups.forEach((cleanup) => cleanup())
+      this.active = false
+    }
+  }
+}
+
+export function recordEffectScope(
+  effect: EffectScope,
+  scope?: EffectScope | null
+) {
+  scope = scope || activeEffectScope
+  if (scope && scope.active) {
+    scope.effects.push(effect)
+  }
+}
+
+export function effectScope(detached?: boolean) {
+  return new EffectScope(detached)
+}
+
+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.`
+    )
+  }
+}
+
+/**
+ * @internal
+ **/
+export function getCurrentScopeVM() {
+  return getCurrentScope()?.vm || getCurrentInstance()?.proxy
+}
diff --git a/src/apis/index.ts b/src/apis/index.ts
index 19b715d..598dbff 100644
--- a/src/apis/index.ts
+++ b/src/apis/index.ts
@@ -1,5 +1,16 @@
 export * from '../reactivity'
-export * from './lifecycle'
+export {
+  onBeforeMount,
+  onMounted,
+  onBeforeUpdate,
+  onUpdated,
+  onBeforeUnmount,
+  onUnmounted,
+  onErrorCaptured,
+  onActivated,
+  onDeactivated,
+  onServerPrefetch,
+} from './lifecycle'
 export * from './watch'
 export * from './computed'
 export * from './inject'
@@ -8,3 +19,9 @@ export { App, createApp } from './createApp'
 export { nextTick } from './nextTick'
 export { createElement as h } from './createElement'
 export { warn } from './warn'
+export {
+  effectScope,
+  EffectScope,
+  getCurrentScope,
+  onScopeDispose,
+} from './effectScope'
diff --git a/src/apis/watch.ts b/src/apis/watch.ts
index 8f8ce5d..e4c7a74 100644
--- a/src/apis/watch.ts
+++ b/src/apis/watch.ts
@@ -13,12 +13,13 @@ import {
   isMap,
 } from '../utils'
 import { defineComponentInstance } from '../utils/helper'
-import { getCurrentInstance, getVueConstructor } from '../runtimeContext'
+import { getVueConstructor } from '../runtimeContext'
 import {
   WatcherPreFlushQueueKey,
   WatcherPostFlushQueueKey,
 } from '../utils/symbols'
 import { ComputedRef } from './computed'
+import { getCurrentScopeVM } from './effectScope'
 
 export type WatchEffect = (onInvalidate: InvalidateCbRegistrator) => void
 
@@ -110,7 +111,7 @@ function getWatchEffectOption(options?: Partial<WatchOptions>): WatchOptions {
 }
 
 function getWatcherVM() {
-  let vm = getCurrentInstance()?.proxy
+  let vm = getCurrentScopeVM()
   if (!vm) {
     if (!fallbackVM) {
       fallbackVM = defineComponentInstance(getVueConstructor())
diff --git a/src/runtimeContext.ts b/src/runtimeContext.ts
index bfdd68d..e3db538 100644
--- a/src/runtimeContext.ts
+++ b/src/runtimeContext.ts
@@ -21,6 +21,7 @@ try {
 
 let vueConstructor: VueConstructor | null = null
 let currentInstance: ComponentInstance | null = null
+let currentInstanceTracking = true
 
 const PluginInstalledFlag = '__composition_api_installed__'
 
@@ -71,7 +72,22 @@ export function setVueConstructor(Vue: VueConstructor) {
   })
 }
 
+/**
+ * For `effectScope` to create instance without populate the current instance
+ * @internal
+ **/
+export function withCurrentInstanceTrackingDisabled(fn: () => void) {
+  const prev = currentInstanceTracking
+  currentInstanceTracking = false
+  try {
+    fn()
+  } finally {
+    currentInstanceTracking = prev
+  }
+}
+
 export function setCurrentInstance(vm: ComponentInstance | null) {
+  if (!currentInstanceTracking) return
   // currentInstance?.$scopedSlots
   currentInstance = vm
 }


Test patch:
diff --git a/test/v3/reactivity/effectScope.spec.ts b/test/v3/reactivity/effectScope.spec.ts
new file mode 100644
index 0000000..79cc8c8
--- /dev/null
+++ b/test/v3/reactivity/effectScope.spec.ts
@@ -0,0 +1,254 @@
+import {
+  nextTick,
+  watch,
+  watchEffect,
+  reactive,
+  EffectScope,
+  onScopeDispose,
+  computed,
+  ref,
+  ComputedRef,
+} from '../../../src'
+import { mockWarn } from '../../helpers'
+
+describe('reactivity/effect/scope', () => {
+  mockWarn(true)
+
+  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', async () => {
+    const scope = new EffectScope()
+    let dummy = 0
+    scope.run(() => {
+      const counter = reactive({ num: 0 })
+      watchEffect(() => (dummy = counter.num))
+
+      expect(dummy).toBe(0)
+      counter.num = 7
+    })
+
+    await nextTick()
+
+    expect(dummy).toBe(7)
+    // expect(scope.effects.length).toBe(1)
+  })
+
+  it('stop', async () => {
+    let dummy, doubled
+    const counter = reactive({ num: 0 })
+
+    const scope = new EffectScope()
+    scope.run(() => {
+      watchEffect(() => (dummy = counter.num))
+      watchEffect(() => (doubled = counter.num * 2))
+    })
+
+    // expect(scope.effects.length).toBe(2)
+
+    expect(dummy).toBe(0)
+    counter.num = 7
+    await nextTick()
+    expect(dummy).toBe(7)
+    expect(doubled).toBe(14)
+
+    scope.stop()
+
+    counter.num = 6
+    await nextTick()
+    expect(dummy).toBe(7)
+    expect(doubled).toBe(14)
+  })
+
+  it('should collect nested scope', async () => {
+    let dummy, doubled
+    const counter = reactive({ num: 0 })
+
+    const scope = new EffectScope()
+    scope.run(() => {
+      // nested scope
+      new EffectScope().run(() => {
+        watchEffect(() => (doubled = counter.num * 2))
+      })
+      watchEffect(() => (dummy = counter.num))
+    })
+
+    // expect(scope.effects.length).toBe(2)
+    expect(scope.effects[0]).toBeInstanceOf(EffectScope)
+
+    expect(dummy).toBe(0)
+    counter.num = 7
+    await nextTick()
+    expect(dummy).toBe(7)
+    expect(doubled).toBe(14)
+
+    // stop the nested scope as well
+    scope.stop()
+
+    counter.num = 6
+    await nextTick()
+    expect(dummy).toBe(7)
+    expect(doubled).toBe(14)
+  })
+
+  it('nested scope can be escaped', async () => {
+    let dummy, doubled
+    const counter = reactive({ num: 0 })
+
+    const scope = new EffectScope()
+    scope.run(() => {
+      watchEffect(() => (dummy = counter.num))
+      // nested scope
+      new EffectScope(true).run(() => {
+        watchEffect(() => (doubled = counter.num * 2))
+      })
+    })
+
+    expect(scope.effects.length).toBe(0)
+
+    expect(dummy).toBe(0)
+    counter.num = 7
+    await nextTick()
+    expect(dummy).toBe(7)
+    expect(doubled).toBe(14)
+
+    scope.stop()
+
+    counter.num = 6
+    await nextTick()
+    expect(dummy).toBe(7)
+
+    // nested scope should not be stoped
+    expect(doubled).toBe(12)
+  })
+
+  it('able to run the scope', async () => {
+    let dummy, doubled
+    const counter = reactive({ num: 0 })
+
+    const scope = new EffectScope()
+    scope.run(() => {
+      watchEffect(() => (dummy = counter.num))
+    })
+
+    // expect(scope.effects.length).toBe(1)
+
+    scope.run(() => {
+      watchEffect(() => (doubled = counter.num * 2))
+    })
+
+    // expect(scope.effects.length).toBe(2)
+
+    counter.num = 7
+    await nextTick()
+    expect(dummy).toBe(7)
+    expect(doubled).toBe(14)
+
+    scope.stop()
+  })
+
+  it('can not run an inactive scope', async () => {
+    let dummy, doubled
+    const counter = reactive({ num: 0 })
+
+    const scope = new EffectScope()
+    scope.run(() => {
+      watchEffect(() => (dummy = counter.num))
+    })
+
+    // expect(scope.effects.length).toBe(1)
+
+    scope.stop()
+
+    scope.run(() => {
+      watchEffect(() => (doubled = counter.num * 2))
+    })
+
+    expect(
+      '[Vue warn]: cannot run an inactive effect scope.'
+    ).toHaveBeenWarned()
+
+    // expect(scope.effects.length).toBe(1)
+
+    counter.num = 7
+    await nextTick()
+    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)
+  })
+})