Przeglądaj źródła

wip: effectScope

Evan You 3 lat temu
rodzic
commit
9fb4f7d070

+ 1 - 1
src/core/instance/events.ts

@@ -56,7 +56,7 @@ export function updateComponentListeners(
   target = undefined
 }
 
-export function eventsMixin(Vue: Component) {
+export function eventsMixin(Vue: typeof Component) {
   const hookRE = /^hook:/
   Vue.prototype.$on = function (
     event: string | Array<string>,

+ 8 - 3
src/core/instance/init.ts

@@ -9,10 +9,11 @@ import { initProvide, initInjections } from './inject'
 import { extend, mergeOptions, formatComponentName } from '../util/index'
 import type { Component } from 'typescript/component'
 import type { InternalComponentOptions } from 'typescript/options'
+import { EffectScope } from 'v3'
 
 let uid = 0
 
-export function initMixin(Vue: Component) {
+export function initMixin(Vue: typeof Component) {
   Vue.prototype._init = function (options?: Record<string, any>) {
     const vm: Component = this
     // a uid
@@ -31,6 +32,8 @@ export function initMixin(Vue: Component) {
     vm._isVue = true
     // avoid instances from being observed
     vm.__v_skip = true
+    // effect scope
+    vm._scope = new EffectScope(true /* detached */)
     // merge options
     if (options && options._isComponent) {
       // optimize internal component instantiation
@@ -96,7 +99,7 @@ export function initInternalComponent(
   }
 }
 
-export function resolveConstructorOptions(Ctor: Component) {
+export function resolveConstructorOptions(Ctor: typeof Component) {
   let options = Ctor.options
   if (Ctor.super) {
     const superOptions = resolveConstructorOptions(Ctor.super)
@@ -120,7 +123,9 @@ export function resolveConstructorOptions(Ctor: Component) {
   return options
 }
 
-function resolveModifiedOptions(Ctor: Component): Record<string, any> | null {
+function resolveModifiedOptions(
+  Ctor: typeof Component
+): Record<string, any> | null {
   let modified
   const latest = Ctor.options
   const sealed = Ctor.sealedOptions

+ 4 - 9
src/core/instance/lifecycle.ts

@@ -57,7 +57,7 @@ export function initLifecycle(vm: Component) {
   vm._isBeingDestroyed = false
 }
 
-export function lifecycleMixin(Vue: Component) {
+export function lifecycleMixin(Vue: typeof Component) {
   Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
     const vm: Component = this
     const prevEl = vm.$el
@@ -108,14 +108,9 @@ export function lifecycleMixin(Vue: Component) {
     if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) {
       remove(parent.$children, vm)
     }
-    // teardown watchers
-    if (vm._watcher) {
-      vm._watcher.teardown()
-    }
-    let i = vm._watchers.length
-    while (i--) {
-      vm._watchers[i].teardown()
-    }
+    // teardown scope. this includes both the render watcher and other
+    // watchers created
+    vm._scope.stop()
     // remove reference from data ob
     // frozen object may not have observer.
     if (vm._data.__ob__) {

+ 2 - 2
src/core/instance/render.ts

@@ -86,11 +86,11 @@ export function setCurrentRenderingInstance(vm: Component) {
   currentRenderingInstance = vm
 }
 
-export function renderMixin(Vue: Component) {
+export function renderMixin(Vue: typeof Component) {
   // install runtime convenience helpers
   installRenderHelpers(Vue.prototype)
 
-  Vue.prototype.$nextTick = function (fn: Function) {
+  Vue.prototype.$nextTick = function (fn: (...args: any[]) => any) {
     return nextTick(fn, this)
   }
 

+ 3 - 4
src/core/instance/state.ts

@@ -29,7 +29,7 @@ import {
   invokeWithErrorHandling,
   isFunction
 } from '../util/index'
-import type { Component } from '../../../typescript/component'
+import type { Component } from 'typescript/component'
 
 const sharedPropertyDefinition = {
   enumerable: true,
@@ -49,7 +49,6 @@ export function proxy(target: Object, sourceKey: string, key: string) {
 }
 
 export function initState(vm: Component) {
-  vm._watchers = []
   const opts = vm.$options
   if (opts.props) initProps(vm, opts.props)
 
@@ -310,7 +309,7 @@ function initWatch(vm: Component, watch: Object) {
 
 function createWatcher(
   vm: Component,
-  expOrFn: string | Function,
+  expOrFn: string | (() => any),
   handler: any,
   options?: Object
 ) {
@@ -324,7 +323,7 @@ function createWatcher(
   return vm.$watch(expOrFn, handler, options)
 }
 
-export function stateMixin(Vue: Component) {
+export function stateMixin(Vue: typeof Component) {
   // flow somehow has problems with directly declared definition object
   // when using Object.defineProperty, so we have to procedurally build up
   // the object here.

+ 8 - 7
src/core/observer/watcher.ts

@@ -16,6 +16,10 @@ import Dep, { pushTarget, popTarget, DepTarget } from './dep'
 
 import type { SimpleSet } from '../util/index'
 import type { Component } from 'typescript/component'
+import {
+  activeEffectScope,
+  recordEffectScope
+} from '../../v3/reactivity/effectScope'
 
 let uid = 0
 
@@ -59,11 +63,11 @@ export default class Watcher implements DepTarget {
     } | null,
     isRenderWatcher?: boolean
   ) {
+    recordEffectScope(this, activeEffectScope || (vm ? vm._scope : undefined))
     if ((this.vm = vm)) {
       if (isRenderWatcher) {
         vm._watcher = this
       }
-      vm._watchers.push(this)
     }
     // options
     if (options) {
@@ -237,13 +241,10 @@ export default class Watcher implements DepTarget {
    * Remove self from all dependencies' subscriber list.
    */
   teardown() {
+    if (this.vm && !this.vm._isBeingDestroyed) {
+      remove(this.vm._scope.effects, this)
+    }
     if (this.active) {
-      // remove self from vm's watcher list
-      // this is a somewhat expensive operation so we skip it
-      // if the vm is being destroyed.
-      if (this.vm && !this.vm._isBeingDestroyed) {
-        remove(this.vm._watchers, this)
-      }
       let i = this.deps.length
       while (i--) {
         this.deps[i].removeSub(this)

+ 3 - 1
src/core/util/next-tick.ts

@@ -85,7 +85,9 @@ if (typeof Promise !== 'undefined' && isNative(Promise)) {
   }
 }
 
-export function nextTick(cb?: Function, ctx?: Object) {
+export function nextTick(): Promise<void>
+export function nextTick(cb: (...args: any[]) => any, ctx?: object): void
+export function nextTick(cb?: (...args: any[]) => any, ctx?: object) {
   let _resolve
   callbacks.push(() => {
     if (cb) {

+ 8 - 5
src/core/vdom/create-component.ts

@@ -26,7 +26,10 @@ import type {
   VNodeWithData
 } from 'typescript/vnode'
 import type { Component } from 'typescript/component'
-import type { InternalComponentOptions } from 'typescript/options'
+import type {
+  ComponentOptions,
+  InternalComponentOptions
+} from 'typescript/options'
 
 // inline hooks to be invoked on component VNodes during patch
 const componentVNodeHooks = {
@@ -95,7 +98,7 @@ const componentVNodeHooks = {
 const hooksToMerge = Object.keys(componentVNodeHooks)
 
 export function createComponent(
-  Ctor: Component | Function | Object | void,
+  Ctor: typeof Component | Function | ComponentOptions | void,
   data: VNodeData | undefined,
   context: Component,
   children?: Array<VNode>,
@@ -109,7 +112,7 @@ export function createComponent(
 
   // plain options object: turn it into a constructor
   if (isObject(Ctor)) {
-    Ctor = baseCtor.extend(Ctor)
+    Ctor = baseCtor.extend(Ctor as typeof Component)
   }
 
   // if at this stage it's not a constructor or an async component factory,
@@ -139,7 +142,7 @@ export function createComponent(
 
   // resolve constructor options in case global mixins are applied after
   // component constructor creation
-  resolveConstructorOptions(Ctor as Component)
+  resolveConstructorOptions(Ctor as typeof Component)
 
   // transform component v-model data into props & events
   if (isDef(data.model)) {
@@ -155,7 +158,7 @@ export function createComponent(
   // @ts-expect-error
   if (isTrue(Ctor.options.functional)) {
     return createFunctionalComponent(
-      Ctor as Component,
+      Ctor as typeof Component,
       propsData,
       data,
       context,

+ 1 - 1
src/core/vdom/create-element.ts

@@ -126,7 +126,7 @@ export function _createElement(
     }
   } else {
     // direct component options / constructor
-    vnode = createComponent(tag, data, context, children)
+    vnode = createComponent(tag as any, data, context, children)
   }
   if (isArray(vnode)) {
     return vnode

+ 3 - 4
src/core/vdom/create-functional-component.ts

@@ -23,7 +23,7 @@ export function FunctionalRenderContext(
   props: Object,
   children: Array<VNode> | undefined,
   parent: Component,
-  Ctor: Component
+  Ctor: typeof Component
 ) {
   const options = Ctor.options
   // ensure the createElement function in functional components
@@ -31,14 +31,13 @@ export function FunctionalRenderContext(
   let contextVm
   if (hasOwn(parent, '_uid')) {
     contextVm = Object.create(parent)
-    // $flow-disable-line
     contextVm._original = parent
   } else {
     // the context vm passed in is a functional context as well.
     // in this case we want to make sure we are able to get a hold to the
     // real context instance.
     contextVm = parent
-    // $flow-disable-line
+    // @ts-ignore
     parent = parent._original
   }
   const isCompiled = isTrue(options._compiled)
@@ -94,7 +93,7 @@ export function FunctionalRenderContext(
 installRenderHelpers(FunctionalRenderContext.prototype)
 
 export function createFunctionalComponent(
-  Ctor: Component,
+  Ctor: typeof Component,
   propsData: Object | undefined,
   data: VNodeData,
   contextVm: Component,

+ 1 - 1
src/core/vdom/helpers/extract-props.ts

@@ -11,7 +11,7 @@ import type { VNodeData } from 'typescript/vnode'
 
 export function extractPropsFromVNodeData(
   data: VNodeData,
-  Ctor: Component,
+  Ctor: typeof Component,
   tag?: string
 ): object | undefined {
   // we are only extracting raw values here.

+ 2 - 2
src/core/vdom/helpers/resolve-async-component.ts

@@ -37,8 +37,8 @@ export function createAsyncPlaceholder(
 
 export function resolveAsyncComponent(
   factory: { (...args: any[]): any; [keye: string]: any },
-  baseCtor: Component
-): Component | void {
+  baseCtor: typeof Component
+): typeof Component | void {
   if (isTrue(factory.error) && isDef(factory.errorComp)) {
     return factory.errorComp
   }

+ 15 - 10
src/v3/apiSetup.ts

@@ -10,15 +10,7 @@ export function initSetup(vm: Component) {
   const options = vm.$options
   const setup = options.setup
   if (setup) {
-    const ctx = {
-      get attrs() {
-        return initAttrsProxy(vm)
-      },
-      get slots() {
-        return initSlotsProxy(vm)
-      },
-      emit: bind(vm.$emit, vm) as any
-    }
+    const ctx = (vm._setupContext = createSetupContext(vm))
 
     setCurrentInstance(vm)
     const setupResult = invokeWithErrorHandling(
@@ -73,6 +65,18 @@ function proxySetupProperty(
   })
 }
 
+function createSetupContext(vm: Component) {
+  return {
+    get attrs() {
+      return initAttrsProxy(vm)
+    },
+    get slots() {
+      return initSlotsProxy(vm)
+    },
+    emit: bind(vm.$emit, vm) as any
+  }
+}
+
 function initAttrsProxy(vm: Component) {
   if (!vm._attrsProxy) {
     const proxy = (vm._attrsProxy = {})
@@ -146,5 +150,6 @@ function getContext(): SetupContext {
   if (__DEV__ && !currentInstance) {
     warn(`useContext() called without active instance.`)
   }
-  return currentInstance!.setupContext
+  const vm = currentInstance!
+  return vm._setupContext || (vm._setupContext = createSetupContext(vm))
 }

+ 1 - 1
src/v3/apiWatch.ts

@@ -245,7 +245,7 @@ function doWatch(
     } else {
       // no cb -> simple effect
       getter = () => {
-        if (instance && instance.isUnmounted) {
+        if (instance && instance._isDestroyed) {
           return
         }
         if (cleanup) {

+ 2 - 0
src/v3/currentInstance.ts

@@ -17,5 +17,7 @@ export function getCurrentInstance(): { proxy: Component } | null {
  * @private
  */
 export function setCurrentInstance(vm: Component | null = null) {
+  if (!vm) currentInstance && currentInstance._scope.off()
   currentInstance = vm
+  vm && vm._scope.on()
 }

+ 8 - 0
src/v3/index.ts

@@ -57,10 +57,18 @@ export {
   DebuggerEvent
 } from './apiWatch'
 
+export {
+  EffectScope,
+  effectScope,
+  onScopeDispose,
+  getCurrentScope
+} from './reactivity/effectScope'
+
 export { TrackOpTypes, TriggerOpTypes } from './reactivity/operations'
 
 export { h } from './h'
 export { getCurrentInstance } from './currentInstance'
 export { useSlots, useAttrs } from './apiSetup'
+export { nextTick } from 'core/util'
 
 export * from './apiLifecycle'

+ 131 - 0
src/v3/reactivity/effectScope.ts

@@ -0,0 +1,131 @@
+import Watcher from 'core/observer/watcher'
+import { warn } from 'core/util'
+
+export let activeEffectScope: EffectScope | undefined
+
+export class EffectScope {
+  /**
+   * @internal
+   */
+  active = true
+  /**
+   * @internal
+   */
+  effects: Watcher[] = []
+  /**
+   * @internal
+   */
+  cleanups: (() => void)[] = []
+
+  /**
+   * only assigned by undetached scope
+   * @internal
+   */
+  parent: EffectScope | undefined
+  /**
+   * record undetached scopes
+   * @internal
+   */
+  scopes: EffectScope[] | undefined
+  /**
+   * track a child scope's index in its parent's scopes array for optimized
+   * removal
+   * @internal
+   */
+  private index: number | undefined
+
+  constructor(detached = false) {
+    if (!detached && activeEffectScope) {
+      this.parent = activeEffectScope
+      this.index =
+        (activeEffectScope.scopes || (activeEffectScope.scopes = [])).push(
+          this
+        ) - 1
+    }
+  }
+
+  run<T>(fn: () => T): T | undefined {
+    if (this.active) {
+      const currentEffectScope = activeEffectScope
+      try {
+        activeEffectScope = this
+        return fn()
+      } finally {
+        activeEffectScope = currentEffectScope
+      }
+    } else if (__DEV__) {
+      warn(`cannot run an inactive effect scope.`)
+    }
+  }
+
+  /**
+   * This should only be called on non-detached scopes
+   * @internal
+   */
+  on() {
+    activeEffectScope = this
+  }
+
+  /**
+   * This should only be called on non-detached scopes
+   * @internal
+   */
+  off() {
+    activeEffectScope = this.parent
+  }
+
+  stop(fromParent?: boolean) {
+    if (this.active) {
+      let i, l
+      for (i = 0, l = this.effects.length; i < l; i++) {
+        this.effects[i].teardown()
+      }
+      for (i = 0, l = this.cleanups.length; i < l; i++) {
+        this.cleanups[i]()
+      }
+      if (this.scopes) {
+        for (i = 0, l = this.scopes.length; i < l; i++) {
+          this.scopes[i].stop(true)
+        }
+      }
+      // nested scope, dereference from parent to avoid memory leaks
+      if (this.parent && !fromParent) {
+        // optimized O(1) removal
+        const last = this.parent.scopes!.pop()
+        if (last && last !== this) {
+          this.parent.scopes![this.index!] = last
+          last.index = this.index!
+        }
+      }
+      this.active = false
+    }
+  }
+}
+
+export function effectScope(detached?: boolean) {
+  return new EffectScope(detached)
+}
+
+export function recordEffectScope(
+  effect: Watcher,
+  scope: EffectScope | undefined = 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(
+      `onScopeDispose() is called when there is no active effect scope` +
+        ` to be associated with.`
+    )
+  }
+}

+ 1 - 1
test/unit/features/instance/methods-lifecycle.spec.ts

@@ -107,7 +107,7 @@ describe('Instance methods lifecycle', () => {
       vm.$watch('a', () => {})
       vm.$destroy()
       expect(vm._watcher.active).toBe(false)
-      expect(vm._watchers.every(w => !w.active)).toBe(true)
+      expect(vm._scope.effects.every(w => !w.active)).toBe(true)
     })
 
     it('remove self from data observer', () => {

+ 24 - 51
test/unit/features/v3/apiWatch.spec.ts

@@ -10,10 +10,13 @@ import {
   triggerRef,
   shallowRef,
   h,
-  onMounted
+  onMounted,
+  getCurrentInstance,
+  effectScope
 } from 'v3'
 import { nextTick } from 'core/util'
 import { set } from 'core/observer'
+import { Component } from 'typescript/component'
 
 // reference: https://vue-composition-api-rfc.netlify.com/api.html#watch
 
@@ -1008,35 +1011,6 @@ describe('api: watch', () => {
     expect(cb).toHaveBeenCalledTimes(1)
   })
 
-  test('watching keypath', async () => {
-    const spy = vi.fn()
-    const Comp = {
-      render() {},
-      data() {
-        return {
-          a: {
-            b: 1
-          }
-        }
-      },
-      watch: {
-        'a.b': spy
-      },
-      created(this: any) {
-        this.$watch('a.b', spy)
-      },
-      mounted(this: any) {
-        this.a.b++
-      }
-    }
-
-    const root = document.createElement('div')
-    new Vue(Comp).$mount(root)
-
-    await nextTick()
-    expect(spy).toHaveBeenCalledTimes(2)
-  })
-
   it('watching sources: ref<any[]>', async () => {
     const foo = ref([1])
     const spy = vi.fn()
@@ -1062,25 +1036,24 @@ describe('api: watch', () => {
   })
 
   // vuejs/core#4158
-  // TODO
-  // test.skip('watch should not register in owner component if created inside detached scope', () => {
-  //   let instance: Component
-  //   const Comp = {
-  //     setup() {
-  //       instance = getCurrentInstance()!.proxy
-  //       effectScope(true).run(() => {
-  //         watch(
-  //           () => 1,
-  //           () => {}
-  //         )
-  //       })
-  //       return () => ''
-  //     }
-  //   }
-  //   const root = document.createElement('div')
-  //   new Vue(Comp).$mount(root)
-  //   // should not record watcher in detached scope and only the instance's
-  //   // own update effect
-  //   expect(instance!.scope.effects.length).toBe(1)
-  // })
+  test('watch should not register in owner component if created inside detached scope', () => {
+    let instance: Component
+    const Comp = {
+      setup() {
+        instance = getCurrentInstance()!.proxy
+        effectScope(true).run(() => {
+          watch(
+            () => 1,
+            () => {}
+          )
+        })
+        return () => ''
+      }
+    }
+    const root = document.createElement('div')
+    new Vue(Comp).$mount(root)
+    // should not record watcher in detached scope and only the instance's
+    // own update effect
+    expect(instance!._scope.effects.length).toBe(1)
+  })
 })

+ 282 - 0
test/unit/features/v3/reactivity/effectScope.spec.ts

@@ -0,0 +1,282 @@
+import { nextTick } from 'core/util'
+import {
+  watch,
+  watchEffect,
+  reactive,
+  computed,
+  ref,
+  ComputedRef,
+  EffectScope,
+  onScopeDispose,
+  getCurrentScope
+} from 'v3/index'
+import { effect } from 'v3/reactivity/effect'
+
+describe('reactivity/effectScope', () => {
+  it('should run', () => {
+    const fnSpy = vi.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(1)
+    expect(scope.scopes!.length).toBe(1)
+    expect(scope.scopes![0]).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 stopped
+    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('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 onScopeDispose 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('should warn onScopeDispose() is called when there is no active effect scope', () => {
+    const spy = vi.fn()
+    const scope = new EffectScope()
+    scope.run(() => {
+      onScopeDispose(spy)
+    })
+
+    expect(spy).toHaveBeenCalledTimes(0)
+
+    onScopeDispose(spy)
+
+    expect(
+      'onScopeDispose() is called when there is no active effect scope to be associated with.'
+    ).toHaveBeenWarned()
+
+    scope.stop()
+    expect(spy).toHaveBeenCalledTimes(1)
+  })
+
+  it('should dereference child scope from parent scope after stopping child scope (no memleaks)', () => {
+    const parent = new EffectScope()
+    const child = parent.run(() => new EffectScope())!
+    expect(parent.scopes!.includes(child)).toBe(true)
+    child.stop()
+    expect(parent.scopes!.includes(child)).toBe(false)
+  })
+
+  it('test with higher level APIs', async () => {
+    const r = ref(1)
+
+    const computedSpy = vi.fn()
+    const watchSpy = vi.fn()
+    const watchEffectSpy = vi.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('getCurrentScope() stays valid when running a detached nested EffectScope', () => {
+    const parentScope = new EffectScope()
+
+    parentScope.run(() => {
+      const currentScope = getCurrentScope()
+      expect(currentScope).toBeDefined()
+      const detachedScope = new EffectScope(true)
+      detachedScope.run(() => {})
+
+      expect(getCurrentScope()).toBe(currentScope)
+    })
+  })
+})

+ 10 - 7
typescript/component.d.ts

@@ -1,8 +1,9 @@
 import type VNode from '../src/core/vdom/vnode'
 import type Watcher from '../src/core/observer/watcher'
-import { ComponentOptions } from './options'
+import { ComponentOptions, SetupContext } from './options'
 import { ScopedSlotsData, VNodeChildren, VNodeData } from './vnode'
 import { GlobalAPI } from './global-api'
+import { EffectScope } from 'v3'
 
 // TODO this should be using the same as /component/
 
@@ -16,7 +17,7 @@ export declare class Component {
   static superOptions: Record<string, any>
   static extendOptions: Record<string, any>
   static sealedOptions: Record<string, any>
-  static super: Component
+  static super: typeof Component
   // assets
   static directive: GlobalAPI['directive']
   static component: GlobalAPI['component']
@@ -58,7 +59,7 @@ export declare class Component {
     key: string | number
   ) => void
   $watch: (
-    expOrFn: string | Function,
+    expOrFn: string | (() => any),
     cb: Function,
     options?: Record<string, any>
   ) => Function
@@ -66,7 +67,7 @@ export declare class Component {
   $once: (event: string, fn: Function) => Component
   $off: (event?: string | Array<string>, fn?: Function) => Component
   $emit: (event: string, ...args: Array<any>) => Component
-  $nextTick: (fn: Function) => void | Promise<any>
+  $nextTick: (fn: (...args: any[]) => any) => void | Promise<any>
   $createElement: (
     tag?: string | Component,
     data?: Record<string, any>,
@@ -77,11 +78,12 @@ export declare class Component {
   _uid: number | string
   _name: string // this only exists in dev mode
   _isVue: true
+  __v_skip: true
   _self: Component
   _renderProxy: Component
   _renderContext?: Component
   _watcher: Watcher | null
-  _watchers: Array<Watcher>
+  _scope: EffectScope
   _computedWatchers: { [key: string]: Watcher }
   _data: Record<string, any>
   _props: Record<string, any>
@@ -99,6 +101,7 @@ export declare class Component {
 
   // @v3
   _setupState?: Record<string, any>
+  _setupContext?: SetupContext
   _attrsProxy?: Record<string, any>
   _slotsProxy?: Record<string, () => VNode[]>
   _preWatchers?: Watcher[]
@@ -193,8 +196,8 @@ export declare class Component {
   _ssrAttrs: Function
   _ssrDOMProps: Function
   _ssrClass: Function
-  _ssrStyle: Function;
+  _ssrStyle: Function
 
   // allow dynamic method registration
-  [key: string]: any
+  // [key: string]: any
 }

+ 2 - 1
typescript/global-api.d.ts

@@ -1,5 +1,6 @@
 import { Config } from '../src/core/config'
 import { Component } from './component'
+import { ComponentOptions } from './options'
 
 declare interface GlobalAPI {
   // new(options?: any): Component
@@ -9,7 +10,7 @@ declare interface GlobalAPI {
   config: Config
   util: Object
 
-  extend: (options: Object) => typeof Component
+  extend: (options: typeof Component | ComponentOptions) => typeof Component
   set: <T>(target: Object | Array<T>, key: string | number, value: T) => T
   delete: <T>(target: Object | Array<T>, key: string | number) => void
   nextTick: (fn: Function, context?: Object) => void | Promise<any>

+ 1 - 1
typescript/options.d.ts

@@ -100,7 +100,7 @@ declare type ComponentOptions = {
   _renderChildren?: Array<VNode> | null
   _componentTag: string | null
   _scopeId: string | null
-  _base: Component
+  _base: typeof Component
 }
 
 declare type PropOptions = {

+ 1 - 1
typescript/vnode.d.ts

@@ -6,7 +6,7 @@ declare type VNodeChildren =
   | string
 
 declare type VNodeComponentOptions = {
-  Ctor: Component
+  Ctor: typeof Component
   propsData?: Object
   listeners?: Record<string, Function | Function[]>
   children?: Array<VNode>