From 9fb4f7d0702d34ccfabe59fa4f9713ee9755ab58 Mon Sep 17 00:00:00 2001 From: Evan You Date: Sun, 29 May 2022 14:17:08 +0800 Subject: [PATCH] wip: effectScope --- src/core/instance/events.ts | 2 +- src/core/instance/init.ts | 11 +- src/core/instance/lifecycle.ts | 13 +- src/core/instance/render.ts | 4 +- src/core/instance/state.ts | 7 +- src/core/observer/watcher.ts | 15 +- src/core/util/next-tick.ts | 4 +- src/core/vdom/create-component.ts | 13 +- src/core/vdom/create-element.ts | 2 +- src/core/vdom/create-functional-component.ts | 7 +- src/core/vdom/helpers/extract-props.ts | 2 +- .../vdom/helpers/resolve-async-component.ts | 4 +- src/v3/apiSetup.ts | 25 +- src/v3/apiWatch.ts | 2 +- src/v3/currentInstance.ts | 2 + src/v3/index.ts | 8 + src/v3/reactivity/effectScope.ts | 131 ++++++++ .../instance/methods-lifecycle.spec.ts | 2 +- test/unit/features/v3/apiWatch.spec.ts | 75 ++--- .../v3/reactivity/effectScope.spec.ts | 282 ++++++++++++++++++ typescript/component.d.ts | 17 +- typescript/global-api.d.ts | 3 +- typescript/options.d.ts | 2 +- typescript/vnode.d.ts | 2 +- 24 files changed, 522 insertions(+), 113 deletions(-) create mode 100644 src/v3/reactivity/effectScope.ts create mode 100644 test/unit/features/v3/reactivity/effectScope.spec.ts diff --git a/src/core/instance/events.ts b/src/core/instance/events.ts index 9affa565..d62d5656 100644 --- a/src/core/instance/events.ts +++ b/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, diff --git a/src/core/instance/init.ts b/src/core/instance/init.ts index aaf55a35..99241f8f 100644 --- a/src/core/instance/init.ts +++ b/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) { 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 | null { +function resolveModifiedOptions( + Ctor: typeof Component +): Record | null { let modified const latest = Ctor.options const sealed = Ctor.sealedOptions diff --git a/src/core/instance/lifecycle.ts b/src/core/instance/lifecycle.ts index 19dab252..0b9b445b 100644 --- a/src/core/instance/lifecycle.ts +++ b/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__) { diff --git a/src/core/instance/render.ts b/src/core/instance/render.ts index 1f4208b6..fc0407b8 100644 --- a/src/core/instance/render.ts +++ b/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) } diff --git a/src/core/instance/state.ts b/src/core/instance/state.ts index 9af2a5c4..f9da112d 100644 --- a/src/core/instance/state.ts +++ b/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. diff --git a/src/core/observer/watcher.ts b/src/core/observer/watcher.ts index 1704d921..22252495 100644 --- a/src/core/observer/watcher.ts +++ b/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) diff --git a/src/core/util/next-tick.ts b/src/core/util/next-tick.ts index 5d4670d2..ac3eceaf 100644 --- a/src/core/util/next-tick.ts +++ b/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 +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) { diff --git a/src/core/vdom/create-component.ts b/src/core/vdom/create-component.ts index 1f738b7c..6cca9655 100644 --- a/src/core/vdom/create-component.ts +++ b/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, @@ -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, diff --git a/src/core/vdom/create-element.ts b/src/core/vdom/create-element.ts index e41b81a9..9207b8fa 100644 --- a/src/core/vdom/create-element.ts +++ b/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 diff --git a/src/core/vdom/create-functional-component.ts b/src/core/vdom/create-functional-component.ts index de576e97..eee5811f 100644 --- a/src/core/vdom/create-functional-component.ts +++ b/src/core/vdom/create-functional-component.ts @@ -23,7 +23,7 @@ export function FunctionalRenderContext( props: Object, children: Array | 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, diff --git a/src/core/vdom/helpers/extract-props.ts b/src/core/vdom/helpers/extract-props.ts index b97637ad..87abc595 100644 --- a/src/core/vdom/helpers/extract-props.ts +++ b/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. diff --git a/src/core/vdom/helpers/resolve-async-component.ts b/src/core/vdom/helpers/resolve-async-component.ts index ea89092e..69794170 100644 --- a/src/core/vdom/helpers/resolve-async-component.ts +++ b/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 } diff --git a/src/v3/apiSetup.ts b/src/v3/apiSetup.ts index fee173c1..c2520311 100644 --- a/src/v3/apiSetup.ts +++ b/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)) } diff --git a/src/v3/apiWatch.ts b/src/v3/apiWatch.ts index 17d62eeb..0e83cf3f 100644 --- a/src/v3/apiWatch.ts +++ b/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) { diff --git a/src/v3/currentInstance.ts b/src/v3/currentInstance.ts index 9beb8b68..43a89d85 100644 --- a/src/v3/currentInstance.ts +++ b/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() } diff --git a/src/v3/index.ts b/src/v3/index.ts index 610527a5..40097d35 100644 --- a/src/v3/index.ts +++ b/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' diff --git a/src/v3/reactivity/effectScope.ts b/src/v3/reactivity/effectScope.ts new file mode 100644 index 00000000..7324d757 --- /dev/null +++ b/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(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.` + ) + } +} diff --git a/test/unit/features/instance/methods-lifecycle.spec.ts b/test/unit/features/instance/methods-lifecycle.spec.ts index e89b86ea..826f1b7a 100644 --- a/test/unit/features/instance/methods-lifecycle.spec.ts +++ b/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', () => { diff --git a/test/unit/features/v3/apiWatch.spec.ts b/test/unit/features/v3/apiWatch.spec.ts index 84740cb8..9018a88d 100644 --- a/test/unit/features/v3/apiWatch.spec.ts +++ b/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', 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) + }) }) diff --git a/test/unit/features/v3/reactivity/effectScope.spec.ts b/test/unit/features/v3/reactivity/effectScope.spec.ts new file mode 100644 index 00000000..b82127cd --- /dev/null +++ b/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) + }) + }) +}) diff --git a/typescript/component.d.ts b/typescript/component.d.ts index a95719dc..c1e2d3b1 100644 --- a/typescript/component.d.ts +++ b/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 static extendOptions: Record static sealedOptions: Record - 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 ) => Function @@ -66,7 +67,7 @@ export declare class Component { $once: (event: string, fn: Function) => Component $off: (event?: string | Array, fn?: Function) => Component $emit: (event: string, ...args: Array) => Component - $nextTick: (fn: Function) => void | Promise + $nextTick: (fn: (...args: any[]) => any) => void | Promise $createElement: ( tag?: string | Component, data?: Record, @@ -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 + _scope: EffectScope _computedWatchers: { [key: string]: Watcher } _data: Record _props: Record @@ -99,6 +101,7 @@ export declare class Component { // @v3 _setupState?: Record + _setupContext?: SetupContext _attrsProxy?: Record _slotsProxy?: Record 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 } diff --git a/typescript/global-api.d.ts b/typescript/global-api.d.ts index eab61b82..8afef2ae 100644 --- a/typescript/global-api.d.ts +++ b/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: (target: Object | Array, key: string | number, value: T) => T delete: (target: Object | Array, key: string | number) => void nextTick: (fn: Function, context?: Object) => void | Promise diff --git a/typescript/options.d.ts b/typescript/options.d.ts index bd59f808..bd2af3a3 100644 --- a/typescript/options.d.ts +++ b/typescript/options.d.ts @@ -100,7 +100,7 @@ declare type ComponentOptions = { _renderChildren?: Array | null _componentTag: string | null _scopeId: string | null - _base: Component + _base: typeof Component } declare type PropOptions = { diff --git a/typescript/vnode.d.ts b/typescript/vnode.d.ts index 36dc1c4f..5fd49bab 100644 --- a/typescript/vnode.d.ts +++ b/typescript/vnode.d.ts @@ -6,7 +6,7 @@ declare type VNodeChildren = | string declare type VNodeComponentOptions = { - Ctor: Component + Ctor: typeof Component propsData?: Object listeners?: Record children?: Array