diff --git a/packages/reactivity/__tests__/effect.spec.ts b/packages/reactivity/__tests__/effect.spec.ts index bb4d32c804d..242fc707153 100644 --- a/packages/reactivity/__tests__/effect.spec.ts +++ b/packages/reactivity/__tests__/effect.spec.ts @@ -1282,4 +1282,48 @@ describe('reactivity/effect', () => { ).not.toHaveBeenWarned() }) }) + + test('should pause/resume effect', () => { + const obj = reactive({ foo: 1 }) + const fnSpy = vi.fn(() => obj.foo) + const runner = effect(fnSpy) + + expect(fnSpy).toHaveBeenCalledTimes(1) + expect(obj.foo).toBe(1) + + runner.effect.pause() + obj.foo++ + expect(fnSpy).toHaveBeenCalledTimes(1) + expect(obj.foo).toBe(2) + + runner.effect.resume() + expect(fnSpy).toHaveBeenCalledTimes(2) + expect(obj.foo).toBe(2) + + obj.foo++ + expect(fnSpy).toHaveBeenCalledTimes(3) + expect(obj.foo).toBe(3) + }) + + test('should be executed once immediately when resume is called', () => { + const obj = reactive({ foo: 1 }) + const fnSpy = vi.fn(() => obj.foo) + const runner = effect(fnSpy) + + expect(fnSpy).toHaveBeenCalledTimes(1) + expect(obj.foo).toBe(1) + + runner.effect.pause() + obj.foo++ + expect(fnSpy).toHaveBeenCalledTimes(1) + expect(obj.foo).toBe(2) + + obj.foo++ + expect(fnSpy).toHaveBeenCalledTimes(1) + expect(obj.foo).toBe(3) + + runner.effect.resume() + expect(fnSpy).toHaveBeenCalledTimes(2) + expect(obj.foo).toBe(3) + }) }) diff --git a/packages/reactivity/__tests__/effectScope.spec.ts b/packages/reactivity/__tests__/effectScope.spec.ts index 4d83d867cf7..8a95f3252ab 100644 --- a/packages/reactivity/__tests__/effectScope.spec.ts +++ b/packages/reactivity/__tests__/effectScope.spec.ts @@ -295,4 +295,31 @@ describe('reactivity/effect/scope', () => { expect(getCurrentScope()).toBe(parentScope) }) }) + + 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) + }) }) diff --git a/packages/reactivity/src/effect.ts b/packages/reactivity/src/effect.ts index e361e854040..ad3a654169d 100644 --- a/packages/reactivity/src/effect.ts +++ b/packages/reactivity/src/effect.ts @@ -46,6 +46,7 @@ export enum EffectFlags { DIRTY = 1 << 4, ALLOW_RECURSE = 1 << 5, NO_BATCH = 1 << 6, + PAUSED = 1 << 7, } /** @@ -107,6 +108,8 @@ export interface Link { prevActiveLink?: Link } +const pausedQueueEffects = new WeakSet() + export class ReactiveEffect implements Subscriber, ReactiveEffectOptions { @@ -142,6 +145,20 @@ export class ReactiveEffect } } + pause() { + this.flags |= EffectFlags.PAUSED + } + + resume() { + if (this.flags & EffectFlags.PAUSED) { + this.flags &= ~EffectFlags.PAUSED + if (pausedQueueEffects.has(this)) { + pausedQueueEffects.delete(this) + this.trigger() + } + } + } + /** * @internal */ @@ -207,7 +224,9 @@ export class ReactiveEffect } trigger() { - if (this.scheduler) { + if (this.flags & EffectFlags.PAUSED) { + pausedQueueEffects.add(this) + } else if (this.scheduler) { this.scheduler() } else { this.runIfDirty() diff --git a/packages/reactivity/src/effectScope.ts b/packages/reactivity/src/effectScope.ts index bc45f8491b8..63236313d27 100644 --- a/packages/reactivity/src/effectScope.ts +++ b/packages/reactivity/src/effectScope.ts @@ -17,6 +17,8 @@ export class EffectScope { */ cleanups: (() => void)[] = [] + private _isPaused = false + /** * only assigned by undetached scope * @internal @@ -48,6 +50,39 @@ export class EffectScope { return this._active } + pause() { + if (this._active) { + this._isPaused = true + if (this.scopes) { + for (let i = 0, l = this.scopes.length; i < l; i++) { + this.scopes[i].pause() + } + } + for (let i = 0, l = this.effects.length; i < l; i++) { + this.effects[i].pause() + } + } + } + + /** + * Resumes the effect scope, including all child scopes and effects. + */ + resume() { + if (this._active) { + if (this._isPaused) { + this._isPaused = false + if (this.scopes) { + for (let i = 0, l = this.scopes.length; i < l; i++) { + this.scopes[i].resume() + } + } + for (let i = 0, l = this.effects.length; i < l; i++) { + this.effects[i].resume() + } + } + } + } + run(fn: () => T): T | undefined { if (this._active) { const currentEffectScope = activeEffectScope diff --git a/packages/runtime-core/__tests__/apiWatch.spec.ts b/packages/runtime-core/__tests__/apiWatch.spec.ts index 5bf156fd77b..85afec24ceb 100644 --- a/packages/runtime-core/__tests__/apiWatch.spec.ts +++ b/packages/runtime-core/__tests__/apiWatch.spec.ts @@ -1621,6 +1621,45 @@ describe('api: watch', () => { expect(cb).toHaveBeenCalledTimes(4) }) + test('pause / resume', async () => { + const count = ref(0) + const cb = vi.fn() + const { pause, resume } = watch(count, cb) + + count.value++ + await nextTick() + expect(cb).toHaveBeenCalledTimes(1) + expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function)) + + pause() + count.value++ + await nextTick() + expect(cb).toHaveBeenCalledTimes(1) + expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function)) + + resume() + count.value++ + await nextTick() + expect(cb).toHaveBeenCalledTimes(2) + expect(cb).toHaveBeenLastCalledWith(3, 1, expect.any(Function)) + + count.value++ + await nextTick() + expect(cb).toHaveBeenCalledTimes(3) + expect(cb).toHaveBeenLastCalledWith(4, 3, expect.any(Function)) + + pause() + count.value++ + await nextTick() + expect(cb).toHaveBeenCalledTimes(3) + expect(cb).toHaveBeenLastCalledWith(4, 3, expect.any(Function)) + + resume() + await nextTick() + expect(cb).toHaveBeenCalledTimes(4) + expect(cb).toHaveBeenLastCalledWith(5, 4, expect.any(Function)) + }) + it('shallowReactive', async () => { const state = shallowReactive({ msg: ref('hello'), diff --git a/packages/runtime-core/src/apiWatch.ts b/packages/runtime-core/src/apiWatch.ts index e25d60083db..60bc78eda31 100644 --- a/packages/runtime-core/src/apiWatch.ts +++ b/packages/runtime-core/src/apiWatch.ts @@ -79,11 +79,17 @@ export interface WatchOptions extends WatchOptionsBase { export type WatchStopHandle = () => void +export interface WatchHandle extends WatchStopHandle { + pause: () => void + resume: () => void + stop: () => void +} + // Simple effect. export function watchEffect( effect: WatchEffect, options?: WatchOptionsBase, -): WatchStopHandle { +): WatchHandle { return doWatch(effect, null, options) } @@ -119,7 +125,7 @@ export function watch = false>( source: WatchSource, cb: WatchCallback>, options?: WatchOptions, -): WatchStopHandle +): WatchHandle // overload: reactive array or tuple of multiple sources + cb export function watch< @@ -131,7 +137,7 @@ export function watch< ? WatchCallback> : WatchCallback, MapSources>, options?: WatchOptions, -): WatchStopHandle +): WatchHandle // overload: array of multiple sources + cb export function watch< @@ -141,7 +147,7 @@ export function watch< sources: [...T], cb: WatchCallback, MapSources>, options?: WatchOptions, -): WatchStopHandle +): WatchHandle // overload: watching reactive object w/ cb export function watch< @@ -151,14 +157,14 @@ export function watch< source: T, cb: WatchCallback>, options?: WatchOptions, -): WatchStopHandle +): WatchHandle // implementation export function watch = false>( source: T | WatchSource, cb: any, options?: WatchOptions, -): WatchStopHandle { +): WatchHandle { if (__DEV__ && !isFunction(cb)) { warn( `\`watch(fn, options?)\` signature has been moved to a separate API. ` + @@ -180,12 +186,12 @@ function doWatch( onTrack, onTrigger, }: WatchOptions = EMPTY_OBJ, -): WatchStopHandle { +): WatchHandle { if (cb && once) { const _cb = cb cb = (...args) => { _cb(...args) - unwatch() + watchHandle() } } @@ -327,7 +333,11 @@ function doWatch( const ctx = useSSRContext()! ssrCleanup = ctx.__watcherHandles || (ctx.__watcherHandles = []) } else { - return NOOP + const watchHandle: WatchHandle = () => {} + watchHandle.stop = NOOP + watchHandle.resume = NOOP + watchHandle.pause = NOOP + return watchHandle } } @@ -397,13 +407,17 @@ function doWatch( effect.scheduler = scheduler const scope = getCurrentScope() - const unwatch = () => { + const watchHandle: WatchHandle = () => { effect.stop() if (scope) { remove(scope.effects, effect) } } + watchHandle.pause = effect.pause.bind(effect) + watchHandle.resume = effect.resume.bind(effect) + watchHandle.stop = watchHandle + if (__DEV__) { effect.onTrack = onTrack effect.onTrigger = onTrigger @@ -425,8 +439,8 @@ function doWatch( effect.run() } - if (__SSR__ && ssrCleanup) ssrCleanup.push(unwatch) - return unwatch + if (__SSR__ && ssrCleanup) ssrCleanup.push(watchHandle) + return watchHandle } // this.$watch @@ -435,7 +449,7 @@ export function instanceWatch( source: string | Function, value: WatchCallback | ObjectWatchOptionItem, options?: WatchOptions, -): WatchStopHandle { +): WatchHandle { const publicThis = this.proxy as any const getter = isString(source) ? source.includes('.') diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 0d8322f8bd1..27372cfc303 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -230,6 +230,7 @@ export type { WatchOptionsBase, WatchCallback, WatchSource, + WatchHandle, WatchStopHandle, } from './apiWatch' export type { InjectionKey } from './apiInject'