Repository: vuejs/composition-api
Pull request #780 (2021-08-09 20:34:49)

feat: v1.1.0


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0492059..254c200 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,12 +1,68 @@
-<a name="1.0.6"></a>
-## [1.0.6](https://github.com/vuejs/composition-api/compare/v1.0.5...v1.0.6) (2021-08-09)
+<a name="1.1.0-beta.7"></a>
+# [1.1.0-beta.7](https://github.com/vuejs/composition-api/compare/v1.1.0-beta.6...v1.1.0-beta.7) (2021-08-09)
+
+### Bug Fixes
+
+* **effectScope:** should have a vaild scope with component ([da21873](https://github.com/vuejs/composition-api/commit/da21873))
+
+
+<a name="1.1.0-beta.6"></a>
+# [1.1.0-beta.6](https://github.com/vuejs/composition-api/compare/v1.1.0-beta.5...v1.1.0-beta.6) (2021-08-09)
+
+* new watchPostEffect api ([92fe90c](https://github.com/vuejs/composition-api/commit/92fe90c))
+* support second target argument for lifecycle functions ([0133c1e](https://github.com/vuejs/composition-api/commit/0133c1e))
+
+
+<a name="1.1.0-beta.4"></a>
+# [1.1.0-beta.4](https://github.com/vuejs/composition-api/compare/v1.0.4...v1.1.0-beta.4) (2021-07-22)
+
+
+### Bug Fixes
+
+* revert module field to `esm.js` version, close [#769](https://github.com/vuejs/composition-api/issues/769) ([92afa6f](https://github.com/vuejs/composition-api/commit/92afa6f))
+
+
+
+<a name="1.1.0-beta.3"></a>
+# [1.1.0-beta.3](https://github.com/vuejs/composition-api/compare/v1.0.3...v1.1.0-beta.3) (2021-07-18)
+
+
+### Bug Fixes
+
+* build for mjs and exports all submodules ([c116714](https://github.com/vuejs/composition-api/commit/c116714))
+
+
+
+<a name="1.1.0-beta.2"></a>
+# [1.1.0-beta.2](https://github.com/vuejs/composition-api/compare/v1.1.0-beta.1...v1.1.0-beta.2) (2021-07-16)
+
+
+### Bug Fixes
+
+* **effectScope:** should stop along with parent component ([784d96c](https://github.com/vuejs/composition-api/commit/784d96c))
+
+
+
+<a name="1.1.0-beta.1"></a>
+# [1.1.0-beta.1](https://github.com/vuejs/composition-api/compare/v1.0.2...v1.1.0-beta.1) (2021-07-16)
+
+
+### Features
+
+* implement `effectScope` api ([#762](https://github.com/vuejs/composition-api/issues/762)) ([fcadec2](https://github.com/vuejs/composition-api/commit/fcadec2))
 
 
 ### Features
 
+* implement `effectScope` api ([#762](https://github.com/vuejs/composition-api/issues/762)) ([fcadec2](https://github.com/vuejs/composition-api/commit/fcadec2))
 * support second target argument for lifecycle functions ([3f3b9c6](https://github.com/vuejs/composition-api/commit/3f3b9c6))
 
 
+<a name="1.0.6"></a>
+## [1.0.6](https://github.com/vuejs/composition-api/compare/v1.0.5...v1.0.6) (2021-08-09)
+
+
+### Features
 
 <a name="1.0.5"></a>
 ## [1.0.5](https://github.com/vuejs/composition-api/compare/v1.0.4...v1.0.5) (2021-08-01)
diff --git a/README.md b/README.md
index 2be072a..0470772 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ Include `@vue/composition-api` after Vue and it will install itself automaticall
 <!--cdn-links-start-->
 ```html
 <script src="https://cdn.jsdelivr.net/npm/vue@2.6"></script>
-<script src="https://cdn.jsdelivr.net/npm/@vue/composition-api@1.0.6"></script>
+<script src="https://cdn.jsdelivr.net/npm/@vue/composition-api@1.1.0-beta.7"></script>
 ```
 <!--cdn-links-end-->
 
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 94cab46..b9a36df 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -40,7 +40,7 @@ import { ref, reactive } from '@vue/composition-api'
 <!--cdn-links-start-->
 ```html
 <script src="https://cdn.jsdelivr.net/npm/vue@2.6"></script>
-<script src="https://cdn.jsdelivr.net/npm/@vue/composition-api@1.0.6"></script>
+<script src="https://cdn.jsdelivr.net/npm/@vue/composition-api@1.1.0-beta.7"></script>
 ```
 <!--cdn-links-end-->
 
diff --git a/package.json b/package.json
index 58c18a0..d3220b3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@vue/composition-api",
-  "version": "1.0.6",
+  "version": "1.1.0-beta.7",
   "description": "Provide logic composition capabilities for Vue.",
   "keywords": [
     "vue",
@@ -42,7 +42,7 @@
     "update-readme": "node ./scripts/update-readme.js",
     "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
     "version": "yarn changelog && yarn update-readme && git add CHANGELOG.md README.*",
-    "release": "yarn version && yarn test && yarn publish --non-interactive && git push --follow-tags",
+    "release": "yarn version && yarn test && yarn publish --non-interactive --tag next && git push --follow-tags",
     "release-gh": "conventional-github-releaser -p angular"
   },
   "bugs": {
diff --git a/src/apis/computed.ts b/src/apis/computed.ts
index c1b65ec..2cac40a 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,
@@ -7,6 +7,7 @@ import {
   getVueInternalClasses,
   isFunction,
 } from '../utils'
+import { getCurrentScopeVM } from './effectScope'
 
 export interface ComputedRef<T = any> extends WritableComputedRef<T> {
   readonly value: T
@@ -32,7 +33,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..e2aac9d
--- /dev/null
+++ b/src/apis/effectScope.ts
@@ -0,0 +1,130 @@
+import {
+  ComponentInternalInstance,
+  getCurrentInstance,
+  getVueConstructor,
+  withCurrentInstanceTrackingDisabled,
+} from '../runtimeContext'
+import { defineComponentInstance } from '../utils'
+import { warn } from './warn'
+
+let activeEffectScope: EffectScope | undefined
+const effectScopeStack: EffectScope[] = []
+
+class EffectScopeImpl {
+  active = true
+  effects: EffectScope[] = []
+  cleanups: (() => void)[] = []
+
+  /**
+   * @internal
+   **/
+  vm: Vue
+
+  constructor(vm: Vue) {
+    this.vm = vm
+  }
+
+  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 class EffectScope extends EffectScopeImpl {
+  constructor(detached = false) {
+    let vm: Vue = undefined!
+    withCurrentInstanceTrackingDisabled(() => {
+      vm = defineComponentInstance(getVueConstructor())
+    })
+    super(vm)
+    if (!detached) {
+      recordEffectScope(this)
+    }
+  }
+}
+
+export function recordEffectScope(
+  effect: EffectScope,
+  scope?: EffectScope | null
+) {
+  scope = scope || activeEffectScope
+  if (scope && scope.active) {
+    scope.effects.push(effect)
+    return
+  }
+  // destory on parent component unmounted
+  const vm = getCurrentInstance()?.proxy
+  vm && vm.$on('hook:destroyed', () => effect.stop())
+}
+
+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
+}
+
+/**
+ * @internal
+ **/
+export function bindCurrentScopeToVM(
+  vm: ComponentInternalInstance
+): EffectScope {
+  if (!vm.scope) {
+    const scope = new EffectScopeImpl(vm.proxy) as EffectScope
+    vm.scope = scope
+    vm.proxy.$on('hook:destroyed', () => scope.stop())
+  }
+  return vm.scope
+}
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/inject.ts b/src/apis/inject.ts
index 75b5301..a8feaf2 100644
--- a/src/apis/inject.ts
+++ b/src/apis/inject.ts
@@ -1,5 +1,11 @@
 import { ComponentInstance } from '../component'
-import { hasOwn, warn, currentVMInFn, isFunction, proxy } from '../utils'
+import {
+  hasOwn,
+  warn,
+  getCurrentInstanceForFn,
+  isFunction,
+  proxy,
+} from '../utils'
 import { getCurrentInstance } from '../runtimeContext'
 
 const NOT_FOUND = {}
@@ -23,7 +29,7 @@ function resolveInject(
 }
 
 export function provide<T>(key: InjectionKey<T> | string, value: T): void {
-  const vm: any = currentVMInFn('provide')
+  const vm: any = getCurrentInstanceForFn('provide')?.proxy
   if (!vm) return
 
   if (!vm._provided) {
diff --git a/src/apis/lifecycle.ts b/src/apis/lifecycle.ts
index a8ffc54..3bb49e3 100644
--- a/src/apis/lifecycle.ts
+++ b/src/apis/lifecycle.ts
@@ -1,49 +1,51 @@
 import { VueConstructor } from 'vue'
-import { ComponentInstance } from '../component'
 import {
   getVueConstructor,
   setCurrentInstance,
   getCurrentInstance,
   ComponentInternalInstance,
 } from '../runtimeContext'
-import { currentVMInFn } from '../utils/helper'
+import { getCurrentInstanceForFn } from '../utils/helper'
 
 const genName = (name: string) => `on${name[0].toUpperCase() + name.slice(1)}`
 function createLifeCycle(lifeCyclehook: string) {
   return (callback: Function, target?: ComponentInternalInstance | null) => {
-    const vm = currentVMInFn(genName(lifeCyclehook), target)
+    const instance = getCurrentInstanceForFn(genName(lifeCyclehook), target)
     return (
-      vm && injectHookOption(getVueConstructor(), vm, lifeCyclehook, callback)
+      instance &&
+      injectHookOption(getVueConstructor(), instance, lifeCyclehook, callback)
     )
   }
 }
 
 function injectHookOption(
   Vue: VueConstructor,
-  vm: ComponentInstance,
+  instance: ComponentInternalInstance,
   hook: string,
   val: Function
 ) {
-  const options = vm.$options as Record<string, unknown>
+  const options = instance.proxy.$options as Record<string, unknown>
   const mergeFn = Vue.config.optionMergeStrategies[hook]
-  const wrappedHook = wrapHookCall(vm, val)
+  const wrappedHook = wrapHookCall(instance, val)
   options[hook] = mergeFn(options[hook], wrappedHook)
   return wrappedHook
 }
 
-function wrapHookCall(vm: ComponentInstance, fn: Function): Function {
+function wrapHookCall(
+  instance: ComponentInternalInstance,
+  fn: Function
+): Function {
   return (...args: any) => {
-    let preVm = getCurrentInstance()?.proxy
-    setCurrentInstance(vm)
+    let prev = getCurrentInstance()
+    setCurrentInstance(instance)
     try {
       return fn(...args)
     } finally {
-      setCurrentInstance(preVm!)
+      setCurrentInstance(prev)
     }
   }
 }
 
-// export const onCreated = createLifeCycle('created');
 export const onBeforeMount = createLifeCycle('beforeMount')
 export const onMounted = createLifeCycle('mounted')
 export const onBeforeUpdate = createLifeCycle('beforeUpdate')
diff --git a/src/apis/watch.ts b/src/apis/watch.ts
index 503c8c7..9c165f5 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())
@@ -395,6 +396,14 @@ export function watchEffect(
   return createWatcher(vm, effect, null, opts)
 }
 
+export function watchPostEffect(effect: WatchEffect) {
+  return watchEffect(effect, { flush: 'post' })
+}
+
+export function watchSyncEffect(effect: WatchEffect) {
+  return watchEffect(effect, { flush: 'sync' })
+}
+
 // overload #1: array of multiple sources + cb
 // Readonly constraint helps the callback to correctly infer value types based
 // on position in the source array. Otherwise the values will get a union type
diff --git a/src/runtimeContext.ts b/src/runtimeContext.ts
index f64b264..02afded 100644
--- a/src/runtimeContext.ts
+++ b/src/runtimeContext.ts
@@ -1,4 +1,5 @@
 import type { VueConstructor, VNode } from 'vue'
+import { bindCurrentScopeToVM, EffectScope } from './apis/effectScope'
 import { ComponentInstance, Data } from './component'
 import {
   assert,
@@ -27,7 +28,8 @@ try {
 }
 
 let vueConstructor: VueConstructor | null = null
-let currentInstance: ComponentInstance | null = null
+let currentInstance: ComponentInternalInstance | null = null
+let currentInstanceTracking = true
 
 const PluginInstalledFlag = '__composition_api_installed__'
 
@@ -78,9 +80,31 @@ export function setVueConstructor(Vue: VueConstructor) {
   })
 }
 
-export function setCurrentInstance(vm: ComponentInstance | null) {
-  // currentInstance?.$scopedSlots
-  currentInstance = vm
+/**
+ * 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 setCurrentVue2Instance(vm: ComponentInstance | null) {
+  if (!currentInstanceTracking) return
+  setCurrentInstance(vm ? toVue3ComponentInstance(vm) : vm)
+}
+
+export function setCurrentInstance(instance: ComponentInternalInstance | null) {
+  if (!currentInstanceTracking) return
+  const prev = currentInstance
+  prev?.scope.off()
+  currentInstance = instance
+  currentInstance?.scope.on()
 }
 
 export type Slot = (...args: any[]) => VNode[]
@@ -150,17 +174,15 @@ export declare interface ComponentInternalInstance {
   isMounted: boolean
   isUnmounted: boolean
   isDeactivated: boolean
-}
 
-export function getCurrentVue2Instance() {
-  return currentInstance
+  /**
+   * @internal
+   */
+  scope: EffectScope
 }
 
 export function getCurrentInstance() {
-  if (currentInstance) {
-    return toVue3ComponentInstance(currentInstance)
-  }
-  return null
+  return currentInstance
 }
 
 const instanceMapCache = new WeakMap<
@@ -169,24 +191,26 @@ const instanceMapCache = new WeakMap<
 >()
 
 function toVue3ComponentInstance(
-  vue2Instance: ComponentInstance
+  vm: ComponentInstance
 ): ComponentInternalInstance {
-  if (instanceMapCache.has(vue2Instance)) {
-    return instanceMapCache.get(vue2Instance)!
+  if (instanceMapCache.has(vm)) {
+    return instanceMapCache.get(vm)!
   }
 
   const instance: ComponentInternalInstance = {
-    proxy: vue2Instance,
-    update: vue2Instance.$forceUpdate,
-    uid: vue2Instance._uid,
+    proxy: vm,
+    update: vm.$forceUpdate,
+    uid: vm._uid,
 
     // $emit is defined on prototype and it expected to be bound
-    emit: vue2Instance.$emit.bind(vue2Instance),
+    emit: vm.$emit.bind(vm),
 
     parent: null,
     root: null!, // to be immediately set
   } as unknown as ComponentInternalInstance
 
+  bindCurrentScopeToVM(instance)
+
   // map vm.$props =
   const instanceProps = [
     'data',
@@ -200,7 +224,7 @@ function toVue3ComponentInstance(
   instanceProps.forEach((prop) => {
     proxy(instance, prop, {
       get() {
-        return (vue2Instance as any)[`$${prop}`]
+        return (vm as any)[`$${prop}`]
       },
     })
   })
@@ -208,39 +232,39 @@ function toVue3ComponentInstance(
   proxy(instance, 'isMounted', {
     get() {
       // @ts-expect-error private api
-      return vue2Instance._isMounted
+      return vm._isMounted
     },
   })
 
   proxy(instance, 'isUnmounted', {
     get() {
       // @ts-expect-error private api
-      return vue2Instance._isDestroyed
+      return vm._isDestroyed
     },
   })
 
   proxy(instance, 'isDeactivated', {
     get() {
       // @ts-expect-error private api
-      return vue2Instance._inactive
+      return vm._inactive
     },
   })
 
   proxy(instance, 'emitted', {
     get() {
       // @ts-expect-error private api
-      return vue2Instance._events
+      return vm._events
     },
   })
 
-  instanceMapCache.set(vue2Instance, instance)
+  instanceMapCache.set(vm, instance)
 
-  if (vue2Instance.$parent) {
-    instance.parent = toVue3ComponentInstance(vue2Instance.$parent)
+  if (vm.$parent) {
+    instance.parent = toVue3ComponentInstance(vm.$parent)
   }
 
-  if (vue2Instance.$root) {
-    instance.root = toVue3ComponentInstance(vue2Instance.$root)
+  if (vm.$root) {
+    instance.root = toVue3ComponentInstance(vm.$root)
   }
 
   return instance
diff --git a/src/utils/helper.ts b/src/utils/helper.ts
index c83dc7c..e60c475 100644
--- a/src/utils/helper.ts
+++ b/src/utils/helper.ts
@@ -7,10 +7,10 @@ import {
 } from '../runtimeContext'
 import { warn } from './utils'
 
-export function currentVMInFn(
+export function getCurrentInstanceForFn(
   hook: string,
   target?: ComponentInternalInstance | null
-): ComponentInstance | undefined {
+): ComponentInternalInstance | null {
   target = target || getCurrentInstance()
   if (__DEV__ && !target) {
     warn(
@@ -19,7 +19,7 @@ export function currentVMInFn(
         `Lifecycle injection APIs can only be used during execution of setup().`
     )
   }
-  return target?.proxy
+  return target
 }
 
 export function defineComponentInstance<V extends Vue = Vue>(
diff --git a/src/utils/instance.ts b/src/utils/instance.ts
index a4f4448..7626e0d 100644
--- a/src/utils/instance.ts
+++ b/src/utils/instance.ts
@@ -1,6 +1,10 @@
 import { ComponentInstance } from '../component'
 import vmStateManager from './vmStateManager'
-import { setCurrentInstance, getCurrentVue2Instance } from '../runtimeContext'
+import {
+  setCurrentInstance,
+  getCurrentInstance,
+  setCurrentVue2Instance,
+} from '../runtimeContext'
 import { Ref, isRef, isReactive } from '../apis'
 import { hasOwn, proxy, warn } from './utils'
 import { createSlotProxy, resolveSlots } from './helper'
@@ -129,8 +133,8 @@ export function activateCurrentInstance(
   fn: (vm_: ComponentInstance) => any,
   onError?: (err: Error) => void
 ) {
-  let preVm = getCurrentVue2Instance()
-  setCurrentInstance(vm)
+  let preVm = getCurrentInstance()
+  setCurrentVue2Instance(vm)
   try {
     return fn(vm)
   } catch (err) {


Test patch:
diff --git a/test/v3/reactivity/effectScope.spec.ts b/test/v3/reactivity/effectScope.spec.ts
new file mode 100644
index 0000000..77d0582
--- /dev/null
+++ b/test/v3/reactivity/effectScope.spec.ts
@@ -0,0 +1,308 @@
+import {
+  nextTick,
+  watch,
+  watchEffect,
+  reactive,
+  EffectScope,
+  onScopeDispose,
+  computed,
+  ref,
+  ComputedRef,
+  createApp,
+  getCurrentScope,
+} 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)
+  })
+
+  it('should stop along with parent component', async () => {
+    let dummy, doubled
+    const counter = reactive({ num: 0 })
+
+    const root = document.createElement('div')
+    const vm = createApp({
+      setup() {
+        const scope = new EffectScope()
+        scope.run(() => {
+          watchEffect(() => (dummy = counter.num))
+          watchEffect(() => (doubled = counter.num * 2))
+        })
+      },
+    })
+    vm.mount(root)
+
+    expect(dummy).toBe(0)
+    counter.num = 7
+    await nextTick()
+    expect(dummy).toBe(7)
+    expect(doubled).toBe(14)
+
+    vm.unmount()
+
+    counter.num = 6
+    await nextTick()
+    expect(dummy).toBe(7)
+    expect(doubled).toBe(14)
+  })
+
+  it('component should be a valid scope', async () => {
+    let dummy = 0
+    let scope
+
+    const root = document.createElement('div')
+    const vm = createApp({
+      setup() {
+        scope = getCurrentScope()
+        onScopeDispose(() => (dummy += 1))
+        scope?.cleanups.push(() => (dummy += 1))
+      },
+    })
+
+    vm.mount(root)
+    expect(dummy).toBe(0)
+    expect(scope).not.toBeFalsy()
+
+    vm.unmount()
+    await nextTick()
+    expect(dummy).toBe(2)
+  })
+})