Ver Fonte

feat(reactivity/watch): add pause/resume for ReactiveEffect, EffectScope, and WatchHandle (#9651)

远方os há 1 ano atrás
pai
commit
267093c314

+ 44 - 0
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)
+  })
 })

+ 27 - 0
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)
+  })
 })

+ 20 - 1
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<ReactiveEffect>()
+
 export class ReactiveEffect<T = any>
   implements Subscriber, ReactiveEffectOptions
 {
@@ -142,6 +145,20 @@ export class ReactiveEffect<T = any>
     }
   }
 
+  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<T = any>
   }
 
   trigger() {
-    if (this.scheduler) {
+    if (this.flags & EffectFlags.PAUSED) {
+      pausedQueueEffects.add(this)
+    } else if (this.scheduler) {
       this.scheduler()
     } else {
       this.runIfDirty()

+ 35 - 0
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<T>(fn: () => T): T | undefined {
     if (this._active) {
       const currentEffectScope = activeEffectScope

+ 39 - 0
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'),

+ 27 - 13
packages/runtime-core/src/apiWatch.ts

@@ -79,11 +79,17 @@ export interface WatchOptions<Immediate = boolean> 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<T, Immediate extends Readonly<boolean> = false>(
   source: WatchSource<T>,
   cb: WatchCallback<T, MaybeUndefined<T, Immediate>>,
   options?: WatchOptions<Immediate>,
-): WatchStopHandle
+): WatchHandle
 
 // overload: reactive array or tuple of multiple sources + cb
 export function watch<
@@ -131,7 +137,7 @@ export function watch<
     ? WatchCallback<T, MaybeUndefined<T, Immediate>>
     : WatchCallback<MapSources<T, false>, MapSources<T, Immediate>>,
   options?: WatchOptions<Immediate>,
-): WatchStopHandle
+): WatchHandle
 
 // overload: array of multiple sources + cb
 export function watch<
@@ -141,7 +147,7 @@ export function watch<
   sources: [...T],
   cb: WatchCallback<MapSources<T, false>, MapSources<T, Immediate>>,
   options?: WatchOptions<Immediate>,
-): WatchStopHandle
+): WatchHandle
 
 // overload: watching reactive object w/ cb
 export function watch<
@@ -151,14 +157,14 @@ export function watch<
   source: T,
   cb: WatchCallback<T, MaybeUndefined<T, Immediate>>,
   options?: WatchOptions<Immediate>,
-): WatchStopHandle
+): WatchHandle
 
 // implementation
 export function watch<T = any, Immediate extends Readonly<boolean> = false>(
   source: T | WatchSource<T>,
   cb: any,
   options?: WatchOptions<Immediate>,
-): 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('.')

+ 1 - 0
packages/runtime-core/src/index.ts

@@ -230,6 +230,7 @@ export type {
   WatchOptionsBase,
   WatchCallback,
   WatchSource,
+  WatchHandle,
   WatchStopHandle,
 } from './apiWatch'
 export type { InjectionKey } from './apiInject'