]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(reactivity/watch): add pause/resume for ReactiveEffect, EffectScope, and WatchHa...
author远方os <yangpanteng@gmail.com>
Fri, 2 Aug 2024 06:41:27 +0000 (14:41 +0800)
committerGitHub <noreply@github.com>
Fri, 2 Aug 2024 06:41:27 +0000 (14:41 +0800)
packages/reactivity/__tests__/effect.spec.ts
packages/reactivity/__tests__/effectScope.spec.ts
packages/reactivity/src/effect.ts
packages/reactivity/src/effectScope.ts
packages/runtime-core/__tests__/apiWatch.spec.ts
packages/runtime-core/src/apiWatch.ts
packages/runtime-core/src/index.ts

index bb4d32c804d06f788e5e1f20bcadad3a73b28f55..242fc7071536ed6577670546b19f7f6e0793ad75 100644 (file)
@@ -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)
+  })
 })
index 4d83d867cf7dc602d1cea652fb4c4b985f17e703..8a95f3252ab1bb33fe39734d43a543f8f69ef413 100644 (file)
@@ -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)
+  })
 })
index e361e85404021ae56b7e9234c9023c13f888131e..ad3a654169d9aae60df7a872032a904865e0f746 100644 (file)
@@ -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()
index bc45f8491b83a8d7e7af4cbbeef2d5eb8fdf605c..63236313d274fe8c6afa91db48b749154b090743 100644 (file)
@@ -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
index 5bf156fd77b6024db93f8102349578d320244130..85afec24ceb2f279007547f0bab324346c94bc65 100644 (file)
@@ -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'),
index e25d60083db38c51db49e4bf184a2b9d23bedc7c..60bc78eda31958da41a8e2bfd0e7e281603595ea 100644 (file)
@@ -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('.')
index 0d8322f8bd199c464e3ecbb53eb2bdfbb0e3ceae..27372cfc30382ecc99c58e9db0ad4d8c9b4291b0 100644 (file)
@@ -230,6 +230,7 @@ export type {
   WatchOptionsBase,
   WatchCallback,
   WatchSource,
+  WatchHandle,
   WatchStopHandle,
 } from './apiWatch'
 export type { InjectionKey } from './apiInject'