From cf663ff20ecc68884009612b2781198984e66e14 Mon Sep 17 00:00:00 2001
From: Ayaka Rizumu <464388324@qq.com>
Date: Wed, 6 Jul 2022 11:13:33 +0800
Subject: [PATCH] feat(watchTriggerable): extending `watch` with a manual
trigger (#1736)
---
packages/shared/index.ts | 1 +
packages/shared/watchTriggerable/demo.vue | 49 ++++++++++
packages/shared/watchTriggerable/index.md | 57 +++++++++++
.../shared/watchTriggerable/index.test.ts | 97 +++++++++++++++++++
packages/shared/watchTriggerable/index.ts | 90 +++++++++++++++++
5 files changed, 294 insertions(+)
create mode 100644 packages/shared/watchTriggerable/demo.vue
create mode 100644 packages/shared/watchTriggerable/index.md
create mode 100644 packages/shared/watchTriggerable/index.test.ts
create mode 100644 packages/shared/watchTriggerable/index.ts
diff --git a/packages/shared/index.ts b/packages/shared/index.ts
index 913b24f2e69..c0e5795d66c 100644
--- a/packages/shared/index.ts
+++ b/packages/shared/index.ts
@@ -51,5 +51,6 @@ export * from './watchIgnorable'
export * from './watchOnce'
export * from './watchPausable'
export * from './watchThrottled'
+export * from './watchTriggerable'
export * from './watchWithFilter'
export * from './whenever'
diff --git a/packages/shared/watchTriggerable/demo.vue b/packages/shared/watchTriggerable/demo.vue
new file mode 100644
index 00000000000..3cf81deef21
--- /dev/null
+++ b/packages/shared/watchTriggerable/demo.vue
@@ -0,0 +1,49 @@
+
+
+
+ Value: {{ source }}
+
+
+
+
+
+
+ Log (500 ms delay)
+
+ {{ log }}
+
diff --git a/packages/shared/watchTriggerable/index.md b/packages/shared/watchTriggerable/index.md
new file mode 100644
index 00000000000..b8d401af476
--- /dev/null
+++ b/packages/shared/watchTriggerable/index.md
@@ -0,0 +1,57 @@
+---
+category: Watch
+---
+
+# watchTriggerable
+
+Watch that can be triggered manually
+
+## Usage
+
+A `watch` wrapper that supports manual triggering of `WatchCallback`, which returns an additional `trigger` to execute a `WatchCallback` immediately.
+
+```ts
+import { watchTriggerable } from '@vueuse/core'
+import { nextTick, ref } from 'vue'
+
+const source = ref(0)
+
+const { trigger, ignoreUpdates } = watchTriggerable(
+ source,
+ v => console.log(`Changed to ${v}!`),
+)
+
+source.value = 'bar'
+await nextTick() // logs: Changed to bar!
+
+// Execution of WatchCallback via `trigger` does not require waiting
+trigger() // logs: Changed to bar!
+```
+
+### `onCleanup`
+When you want to manually call a `watch` that uses the onCleanup parameter; simply taking the `WatchCallback` out and calling it doesn't make it easy to implement the `onCleanup` parameter.
+
+Using `watchTriggerable` will solve this problem.
+```ts
+import { watchTriggerable } from '@vueuse/core'
+import { ref } from 'vue'
+
+const source = ref(0)
+
+const { trigger } = watchTriggerable(
+ source,
+ async (v, _, onCleanup) => {
+ let canceled = false
+ onCleanup(() => canceled = true)
+
+ await new Promise(resolve => setTimeout(resolve, 500))
+ if (canceled)
+ return
+
+ console.log(`The value is "${v}"\n`)
+ },
+)
+
+source.value = 1 // no log
+await trigger() // logs (after 500 ms): The value is "1"
+```
diff --git a/packages/shared/watchTriggerable/index.test.ts b/packages/shared/watchTriggerable/index.test.ts
new file mode 100644
index 00000000000..449e7011656
--- /dev/null
+++ b/packages/shared/watchTriggerable/index.test.ts
@@ -0,0 +1,97 @@
+import { nextTick, reactive, ref } from 'vue-demi'
+import { watchTriggerable } from '.'
+describe('watchTriggerable', () => {
+ test('this should work', async () => {
+ const source = ref(0)
+ const effect = ref(0)
+ let cleanupCount = -1
+ const { trigger } = watchTriggerable(source, (value, oldValue, onCleanup) => {
+ onCleanup(() => {
+ cleanupCount = value
+ })
+ expect(value).toBe(source.value)
+ effect.value = value
+ })
+
+ // By default watch will be executed on the next tick
+ source.value = 1
+ expect(effect.value).toBe(0)
+ await nextTick()
+ expect(effect.value).toBe(source.value)
+ expect(cleanupCount).toBe(-1)
+
+ source.value = 2
+ expect(cleanupCount).toBe(-1)
+ await nextTick()
+ expect(effect.value).toBe(source.value)
+ expect(cleanupCount).toBe(1)
+
+ // trigger is executed immediately
+ effect.value = 0
+ trigger()
+ expect(effect.value).toBe(source.value)
+ expect(cleanupCount).toBe(2)
+ })
+
+ test('source array', async () => {
+ const source1 = ref(0)
+ const source2 = reactive({ a: 'a' })
+ const effect1 = ref(-1)
+ const effect2 = ref('z')
+ let cleanupCount = -1
+ const { trigger } = watchTriggerable([source1, () => source2.a], ([value1, value2], [old1, old2], onCleanup) => {
+ onCleanup(() => {
+ cleanupCount = value1
+ })
+ expect(value1).toBe(source1.value)
+ effect1.value = value1
+ effect2.value = value2
+ })
+
+ trigger()
+ expect(effect1.value).toBe(source1.value)
+ expect(effect2.value).toBe(source2.a)
+ expect(cleanupCount).toBe(-1)
+
+ source1.value = 1
+ source2.a = 'b'
+ await nextTick()
+ expect(effect1.value).toBe(source1.value)
+ expect(effect2.value).toBe(source2.a)
+ expect(cleanupCount).toBe(0)
+ })
+
+ test('source reactive object', async () => {
+ const source = reactive({ a: 'a' })
+ const effect = ref('')
+ let cleanupCount = 0
+ const { trigger } = watchTriggerable(source, (value, old, onCleanup) => {
+ onCleanup(() => {
+ cleanupCount += 1
+ })
+ expect(value).toBe(source)
+ effect.value = value.a
+ })
+
+ trigger()
+ expect(effect.value).toBe(source.a)
+ expect(cleanupCount).toBe(0)
+
+ source.a = 'b'
+ await nextTick()
+ expect(effect.value).toBe(source.a)
+ expect(cleanupCount).toBe(1)
+ })
+
+ test('trigger should await', async () => {
+ const source = ref(1)
+ const effect = ref(0)
+ const { trigger } = watchTriggerable(source, async (value) => {
+ await new Promise(resolve => setTimeout(resolve, 10))
+ effect.value = value
+ })
+
+ await trigger()
+ expect(effect.value).toBe(source.value)
+ })
+})
diff --git a/packages/shared/watchTriggerable/index.ts b/packages/shared/watchTriggerable/index.ts
new file mode 100644
index 00000000000..6caa9a58476
--- /dev/null
+++ b/packages/shared/watchTriggerable/index.ts
@@ -0,0 +1,90 @@
+import type { WatchSource } from 'vue-demi'
+import { isReactive, unref } from 'vue-demi'
+import type { MapOldSources, MapSources } from '../utils'
+import type { WatchIgnorableReturn } from '../watchIgnorable'
+import { watchIgnorable } from '../watchIgnorable'
+import type { WatchWithFilterOptions } from '../watchWithFilter'
+
+// Watch that can be triggered manually
+// A `watch` wrapper that supports manual triggering of `WatchCallback`, which returns an additional `trigger` to execute a `WatchCallback` immediately.
+
+export interface WatchTriggerableReturn extends WatchIgnorableReturn {
+ /** Execute `WatchCallback` immediately */
+ trigger: () => FnReturnT
+}
+
+type OnCleanup = (cleanupFn: () => void) => void
+
+export type WatchTriggerableCallback = (value: V, oldValue: OV, onCleanup: OnCleanup) => R
+
+export function watchTriggerable[]>, FnReturnT>(sources: [...T], cb: WatchTriggerableCallback, MapOldSources, FnReturnT>, options?: WatchWithFilterOptions): WatchTriggerableReturn
+export function watchTriggerable(source: WatchSource, cb: WatchTriggerableCallback, options?: WatchWithFilterOptions): WatchTriggerableReturn
+export function watchTriggerable(source: T, cb: WatchTriggerableCallback, options?: WatchWithFilterOptions): WatchTriggerableReturn
+
+export function watchTriggerable = false>(
+ source: any,
+ cb: any,
+ options: WatchWithFilterOptions = {},
+): WatchTriggerableReturn {
+ let cleanupFn: (() => void) | undefined
+
+ function onEffect() {
+ if (!cleanupFn)
+ return
+
+ const fn = cleanupFn
+ cleanupFn = undefined
+ fn()
+ }
+
+ /** Register the function `cleanupFn` */
+ function onCleanup(callback: () => void) {
+ cleanupFn = callback
+ }
+
+ const _cb = (
+ value: any,
+ oldValue: any,
+ ) => {
+ // When a new side effect occurs, clean up the previous side effect
+ onEffect()
+
+ return cb(value, oldValue, onCleanup)
+ }
+ const res = watchIgnorable(source, _cb, options)
+ const { ignoreUpdates } = res
+
+ const trigger = () => {
+ let res: any
+ ignoreUpdates(() => {
+ res = _cb(getWatchSources(source), getOldValue(source))
+ })
+ return res
+ }
+
+ return {
+ ...res,
+ trigger,
+ }
+}
+
+function getWatchSources(sources: any) {
+ if (isReactive(sources))
+ return sources
+ if (Array.isArray(sources))
+ return sources.map(item => getOneWatchSource(item))
+ return getOneWatchSource(sources)
+}
+
+function getOneWatchSource(source: Readonly>) {
+ return typeof source === 'function'
+ ? source()
+ : unref(source)
+}
+
+// For calls triggered by trigger, the old value is unknown, so it cannot be returned
+function getOldValue(source: any) {
+ return Array.isArray(source)
+ ? source.map(() => undefined)
+ : undefined
+}