]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(reactivity): new effectScope API (#2195)
authorAnthony Fu <anthonyfu117@hotmail.com>
Wed, 7 Jul 2021 13:07:19 +0000 (21:07 +0800)
committerEvan You <yyx990803@gmail.com>
Fri, 16 Jul 2021 18:30:49 +0000 (14:30 -0400)
16 files changed:
packages/reactivity/__tests__/effectScope.spec.ts [new file with mode: 0644]
packages/reactivity/src/effect.ts
packages/reactivity/src/effectScope.ts [new file with mode: 0644]
packages/reactivity/src/index.ts
packages/reactivity/src/warning.ts [new file with mode: 0644]
packages/runtime-core/__tests__/apiWatch.spec.ts
packages/runtime-core/src/apiComputed.ts [deleted file]
packages/runtime-core/src/apiLifecycle.ts
packages/runtime-core/src/apiSetupHelpers.ts
packages/runtime-core/src/apiWatch.ts
packages/runtime-core/src/compat/global.ts
packages/runtime-core/src/component.ts
packages/runtime-core/src/componentOptions.ts
packages/runtime-core/src/componentProps.ts
packages/runtime-core/src/index.ts
packages/runtime-core/src/renderer.ts

diff --git a/packages/reactivity/__tests__/effectScope.spec.ts b/packages/reactivity/__tests__/effectScope.spec.ts
new file mode 100644 (file)
index 0000000..b5bc970
--- /dev/null
@@ -0,0 +1,238 @@
+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)
+  })
+})
index d2733e93cd6ddabe5d09e84f88090d7243998d68..37c80d8489601bedd8bd9e557e0b3fa3bae5ee0f 100644 (file)
@@ -1,5 +1,6 @@
 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
@@ -43,9 +44,12 @@ export class ReactiveEffect<T = any> {
   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) {
@@ -60,8 +64,7 @@ export class ReactiveEffect<T = any> {
       } finally {
         effectStack.pop()
         resetTracking()
-        const n = effectStack.length
-        activeEffect = n > 0 ? effectStack[n - 1] : undefined
+        activeEffect = effectStack[effectStack.length - 1]
       }
     }
   }
@@ -90,6 +93,7 @@ export class ReactiveEffect<T = any> {
 export interface ReactiveEffectOptions {
   lazy?: boolean
   scheduler?: EffectScheduler
+  scope?: EffectScope
   allowRecurse?: boolean
   onStop?: () => void
   onTrack?: (event: DebuggerEvent) => void
@@ -112,6 +116,7 @@ export function effect<T = any>(
   const _effect = new ReactiveEffect(fn)
   if (options) {
     extend(_effect, options)
+    if (options.scope) recordEffectScope(_effect, options.scope)
   }
   if (!options || !options.lazy) {
     _effect.run()
diff --git a/packages/reactivity/src/effectScope.ts b/packages/reactivity/src/effectScope.ts
new file mode 100644 (file)
index 0000000..fdacffc
--- /dev/null
@@ -0,0 +1,81 @@
+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.`
+    )
+  }
+}
index e392f182439d08b4d639c538c7907e5cdd28ebcb..d86f0bde882cfefe2ecf1f7afebd6d3249b2e78d 100644 (file)
@@ -51,4 +51,10 @@ export {
   EffectScheduler,
   DebuggerEvent
 } from './effect'
+export {
+  effectScope,
+  EffectScope,
+  getCurrentScope,
+  onScopeDispose
+} from './effectScope'
 export { TrackOpTypes, TriggerOpTypes } from './operations'
diff --git a/packages/reactivity/src/warning.ts b/packages/reactivity/src/warning.ts
new file mode 100644 (file)
index 0000000..c6cbdfe
--- /dev/null
@@ -0,0 +1,3 @@
+export function warn(msg: string, ...args: any[]) {
+  console.warn(`[Vue warn] ${msg}`, ...args)
+}
index 28f06057bee6e96aee89784d529d82375f2a15e9..9611b761f03befe84ce84891e60ba14ff65d1ceb 100644 (file)
@@ -848,15 +848,16 @@ describe('api: watch', () => {
     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 ', () => {
diff --git a/packages/runtime-core/src/apiComputed.ts b/packages/runtime-core/src/apiComputed.ts
deleted file mode 100644 (file)
index 02b0ab8..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-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
-}
index 7decc103a1626da847d7ca9adc725ad6efd98b95..61db5e3409fb54022c74aae41ffdf39bc9afd139 100644 (file)
@@ -3,7 +3,8 @@ import {
   currentInstance,
   isInSSRComponentSetup,
   LifecycleHooks,
-  setCurrentInstance
+  setCurrentInstance,
+  unsetCurrentInstance
 } from './component'
 import { ComponentPublicInstance } from './componentPublicInstance'
 import { callWithAsyncErrorHandling, ErrorTypeStrings } from './errorHandling'
@@ -38,7 +39,7 @@ export function injectHook(
         // 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
       })
index 4dcadbabdde9de9f7eac0607c29b4aaa85ca8358..08a94f7bc310479b8d2e3871e18194d81c89f768 100644 (file)
@@ -3,7 +3,8 @@ import {
   getCurrentInstance,
   setCurrentInstance,
   SetupContext,
-  createSetupContext
+  createSetupContext,
+  unsetCurrentInstance
 } from './component'
 import { EmitFn, EmitsOptions } from './componentEmits'
 import {
@@ -248,9 +249,15 @@ export function mergeDefaults(
  * @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)
index f589ab62951e8aacff6f9c0821bce5717a72975f..eb3a4503cebc9cedc9aa799b23c38e1857c31150 100644 (file)
@@ -25,8 +25,7 @@ import {
 import {
   currentInstance,
   ComponentInternalInstance,
-  isInSSRComponentSetup,
-  recordInstanceBoundEffect
+  isInSSRComponentSetup
 } from './component'
 import {
   ErrorCodes,
@@ -326,15 +325,14 @@ function doWatch(
     }
   }
 
-  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) {
@@ -353,8 +351,8 @@ function doWatch(
 
   return () => {
     effect.stop()
-    if (instance) {
-      remove(instance.effects!, effect)
+    if (scope) {
+      remove(scope.effects!, effect)
     }
   }
 }
index f843b5ead140a1252f24a7037cf0f249df015399..a130514726a66a5197629da03f6bc08793c3f6fa 100644 (file)
@@ -563,7 +563,7 @@ function installCompatMount(
         }
         delete app._container.__vue_app__
       } else {
-        const { bum, effects, um } = instance
+        const { bum, scope, um } = instance
         // beforeDestroy hooks
         if (bum) {
           invokeArrayFns(bum)
@@ -572,10 +572,8 @@ function installCompatMount(
           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) {
index 4da57d4436842127278c08d96fa2d2f3fa96fce8..3caf0df5864825d348d6d384ec7bb77b0861063e 100644 (file)
@@ -1,10 +1,10 @@
 import { VNode, VNodeChild, isVNode } from './vnode'
 import {
-  ReactiveEffect,
   pauseTracking,
   resetTracking,
   shallowReadonly,
   proxyRefs,
+  EffectScope,
   markRaw
 } from '@vue/reactivity'
 import {
@@ -217,11 +217,6 @@ export interface ComponentInternalInstance {
    * 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
    */
@@ -246,7 +241,7 @@ export interface ComponentInternalInstance {
    * 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
@@ -451,14 +446,13 @@ export function createComponentInstance(
     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: [],
@@ -533,10 +527,14 @@ export let currentInstance: ComponentInternalInstance | null = null
 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')
@@ -618,7 +616,7 @@ function setupStatefulComponent(
     const setupContext = (instance.setupContext =
       setup.length > 1 ? createSetupContext(instance) : null)
 
-    currentInstance = instance
+    setCurrentInstance(instance)
     pauseTracking()
     const setupResult = callWithErrorHandling(
       setup,
@@ -627,13 +625,10 @@ function setupStatefulComponent(
       [__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
@@ -801,11 +796,11 @@ export function finishComponentSetup(
 
   // 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
@@ -900,17 +895,6 @@ export function getExposeProxy(instance: ComponentInternalInstance) {
   }
 }
 
-// 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, '')
index 5c7960d341c15a0009a1e43a7f1039dd57bf37cb..e8cc5f75d67bb21718e77b160f921a696508cebe 100644 (file)
@@ -17,7 +17,7 @@ import {
   NOOP,
   isPromise
 } from '@vue/shared'
-import { computed } from './apiComputed'
+import { computed } from '@vue/reactivity'
 import {
   watch,
   WatchOptions,
index cacd72102badac9fdbc80cf394520e2e93043bee..c4ab05dd021be0ef6bed514480356f42b2be21ea 100644 (file)
@@ -29,7 +29,8 @@ import {
   ComponentInternalInstance,
   ComponentOptions,
   ConcreteComponent,
-  setCurrentInstance
+  setCurrentInstance,
+  unsetCurrentInstance
 } from './component'
 import { isEmitListener } from './componentEmits'
 import { InternalObjectKey } from './vnode'
@@ -411,7 +412,7 @@ function resolvePropValue(
               : null,
             props
           )
-          setCurrentInstance(null)
+          unsetCurrentInstance()
         }
       } else {
         value = defaultValue
index 296ac574f70214926b236a1678148c6450f01223..3b745735542ede660291d45efdb9c73407900f47 100644 (file)
@@ -3,6 +3,7 @@
 export const version = __VERSION__
 export {
   // core
+  computed,
   reactive,
   ref,
   readonly,
@@ -22,9 +23,17 @@ export {
   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,
@@ -137,7 +146,6 @@ declare module '@vue/reactivity' {
 }
 
 export {
-  ReactiveEffect,
   ReactiveEffectOptions,
   DebuggerEvent,
   TrackOpTypes,
index 5ecee3edd4c07f448a8c3fa34cb86fd2a77f7fa0..9415eabb2a2d63b14b56319c4b01ab2b4ba3c396 100644 (file)
@@ -1622,11 +1622,12 @@ function baseCreateRenderer(
     }
 
     // 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
@@ -2285,12 +2286,13 @@ function baseCreateRenderer(
       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)
@@ -2298,15 +2300,13 @@ function baseCreateRenderer(
       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)