Skip to content

Commit

Permalink
feat(watchTriggerable): extending watch with a manual trigger (#1736)
Browse files Browse the repository at this point in the history
  • Loading branch information
LittleSound committed Jul 6, 2022
1 parent d67d903 commit cf663ff
Show file tree
Hide file tree
Showing 5 changed files with 294 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/shared/index.ts
Expand Up @@ -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'
49 changes: 49 additions & 0 deletions packages/shared/watchTriggerable/demo.vue
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref } from 'vue'
import { watchTriggerable } from '@vueuse/core'
const log = ref('')
const source = ref(0)
const { trigger, ignoreUpdates } = watchTriggerable(
source,
async (v, _, onCleanup) => {
let canceled = false
onCleanup(() => canceled = true)
await new Promise(resolve => setTimeout(resolve, 500))
if (canceled)
return
log.value += `The value is "${v}"\n`
},
)
const clear = () => {
ignoreUpdates(() => {
source.value = 0
log.value = ''
})
}
const update = () => {
source.value++
}
</script>

<template>
<div>Value: {{ source }}</div>
<button @click="update">
Update
</button>
<button class="orange" @click="trigger">
Manual Trigger
</button>
<button @click="clear">
Reset
</button>

<br>

<note>Log (500 ms delay)</note>

<pre>{{ log }}</pre>
</template>
57 changes: 57 additions & 0 deletions 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"
```
97 changes: 97 additions & 0 deletions 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)
})
})
90 changes: 90 additions & 0 deletions 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<FnReturnT = void> extends WatchIgnorableReturn {
/** Execute `WatchCallback` immediately */
trigger: () => FnReturnT
}

type OnCleanup = (cleanupFn: () => void) => void

export type WatchTriggerableCallback<V = any, OV = any, R = void> = (value: V, oldValue: OV, onCleanup: OnCleanup) => R

export function watchTriggerable<T extends Readonly<WatchSource<unknown>[]>, FnReturnT>(sources: [...T], cb: WatchTriggerableCallback<MapSources<T>, MapOldSources<T, true>, FnReturnT>, options?: WatchWithFilterOptions<boolean>): WatchTriggerableReturn<FnReturnT>
export function watchTriggerable<T, FnReturnT>(source: WatchSource<T>, cb: WatchTriggerableCallback<T, T | undefined, FnReturnT>, options?: WatchWithFilterOptions<boolean>): WatchTriggerableReturn<FnReturnT>
export function watchTriggerable<T extends object, FnReturnT>(source: T, cb: WatchTriggerableCallback<T, T | undefined, FnReturnT>, options?: WatchWithFilterOptions<boolean>): WatchTriggerableReturn<FnReturnT>

export function watchTriggerable<Immediate extends Readonly<boolean> = false>(
source: any,
cb: any,
options: WatchWithFilterOptions<Immediate> = {},
): 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<WatchSource<unknown>>) {
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
}

0 comments on commit cf663ff

Please sign in to comment.