import { nextTick, watch, watchEffect } from '@vue/runtime-core' import { type ComputedRef, EffectScope, computed, effect, effectScope, getCurrentScope, onScopeDispose, reactive, ref, } from '../src' describe('reactivity/effect/scope', () => { it('should run', () => { const fnSpy = vi.fn(() => {}) effectScope().run(fnSpy) expect(fnSpy).toHaveBeenCalledTimes(1) }) it('should accept zero argument', () => { const scope = effectScope() expect(scope.effects.length).toBe(0) }) it('should return run value', () => { expect(effectScope().run(() => 1)).toBe(1) }) it('should work w/ active property', () => { const scope = effectScope() scope.run(() => 1) expect(scope.active).toBe(true) scope.stop() expect(scope.active).toBe(false) }) it('should collect the effects', () => { const scope = 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 = 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 = effectScope() scope.run(() => { effect(() => (dummy = counter.num)) // nested scope 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 = effectScope() scope.run(() => { effect(() => (dummy = counter.num)) // nested scope 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 = 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 = 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(0) counter.num = 7 expect(dummy).toBe(0) expect(doubled).toBe(undefined) }) it('should fire onScopeDispose hook', () => { let dummy = 0 const scope = 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 = effectScope() scope.run(() => { onScopeDispose(spy) }) expect(spy).toHaveBeenCalledTimes(0) onScopeDispose(spy) expect( '[Vue warn] 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 = effectScope() const child = parent.run(() => 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 = effectScope() scope.run(() => { c = computed(() => { computedSpy() return r.value + 1 }) watch(r, watchSpy) watchEffect(() => { watchEffectSpy() r.value c.value }) }) expect(computedSpy).toHaveBeenCalledTimes(1) expect(watchSpy).toHaveBeenCalledTimes(0) expect(watchEffectSpy).toHaveBeenCalledTimes(1) r.value++ await nextTick() expect(computedSpy).toHaveBeenCalledTimes(2) expect(watchSpy).toHaveBeenCalledTimes(1) expect(watchEffectSpy).toHaveBeenCalledTimes(2) scope.stop() r.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 = effectScope() parentScope.run(() => { const currentScope = getCurrentScope() expect(currentScope).toBeDefined() const detachedScope = effectScope(true) detachedScope.run(() => {}) expect(getCurrentScope()).toBe(currentScope) }) }) it('calling .off() of a detached scope inside an active scope should not break currentScope', () => { const parentScope = effectScope() parentScope.run(() => { const childScope = effectScope(true) childScope.on() childScope.off() expect(getCurrentScope()).toBe(parentScope) }) }) it('calling .off() out of order should unlink the scope from the active chain', () => { const parentScope = effectScope(true) const firstScope = effectScope(true) const secondScope = effectScope(true) parentScope.on() firstScope.on() secondScope.on() firstScope.off() expect(getCurrentScope()).toBe(secondScope) secondScope.off() expect(getCurrentScope()).toBe(parentScope) parentScope.off() expect(getCurrentScope()).toBeUndefined() }) it('should pause/resume EffectScope', async () => { const counter = reactive({ num: 0 }) const fnSpy = vi.fn(() => counter.num) const scope = new EffectScope() scope.run(() => { effect(fnSpy) }) expect(fnSpy).toHaveBeenCalledTimes(1) counter.num++ await nextTick() expect(fnSpy).toHaveBeenCalledTimes(2) scope.pause() counter.num++ await nextTick() expect(fnSpy).toHaveBeenCalledTimes(2) counter.num++ await nextTick() expect(fnSpy).toHaveBeenCalledTimes(2) scope.resume() expect(fnSpy).toHaveBeenCalledTimes(3) }) test('removing a watcher while stopping its effectScope', async () => { const count = ref(0) const scope = effectScope() let watcherCalls = 0 let cleanupCalls = 0 scope.run(() => { const stop1 = watch(count, () => { watcherCalls++ }) watch(count, (val, old, onCleanup) => { watcherCalls++ onCleanup(() => { cleanupCalls++ stop1() }) }) watch(count, () => { watcherCalls++ }) }) expect(watcherCalls).toBe(0) expect(cleanupCalls).toBe(0) count.value++ await nextTick() expect(watcherCalls).toBe(3) expect(cleanupCalls).toBe(0) scope.stop() count.value++ await nextTick() expect(watcherCalls).toBe(3) expect(cleanupCalls).toBe(1) expect(scope.effects.length).toBe(0) expect(scope.cleanups.length).toBe(0) }) it('should still trigger updates after stopping scope stored in reactive object', () => { const rs = ref({ stage: 0, scope: null as any, }) let renderCount = 0 effect(() => { renderCount++ return rs.value.stage }) const handleBegin = () => { const status = rs.value status.stage = 1 status.scope = effectScope() status.scope.run(() => { watch([() => status.stage], () => {}) }) } const handleExit = () => { const status = rs.value status.stage = 0 const watchScope = status.scope status.scope = null if (watchScope) { watchScope.stop() } } expect(rs.value.stage).toBe(0) expect(renderCount).toBe(1) // 1. Click begin handleBegin() expect(rs.value.stage).toBe(1) expect(renderCount).toBe(2) // 2. Click add rs.value.stage++ expect(rs.value.stage).toBe(2) expect(renderCount).toBe(3) // 3. Click end handleExit() expect(rs.value.stage).toBe(0) expect(renderCount).toBe(4) handleBegin() expect(rs.value.stage).toBe(1) expect(renderCount).toBe(5) }) })