Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(watchTriggerable): extending watch with a manual trigger #1736

Merged
merged 8 commits into from Jul 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/contributors.json
Expand Up @@ -166,6 +166,7 @@
"phaust",
"marktnoonan",
"dm4t2",
"melishev",
"mauriciabad",
"mxmvshnvsk",
"AldeonMoriak",
Expand All @@ -185,6 +186,7 @@
"a1xon",
"octref",
"praburangki",
"preeteshjain",
"QiroNT",
"ramonakira",
"Redemption198",
Expand Down Expand Up @@ -223,10 +225,12 @@
"monkeywithacupcake",
"katsuyaU",
"koheing",
"kongmoumou",
"laozei6401",
"leovoon",
"likeswinds",
"lxhyl",
"lxnelyclxud",
"lzdFeiFei",
"meteorlxy",
"odex21",
Expand Down
1 change: 1 addition & 0 deletions packages/shared/index.ts
Expand Up @@ -49,5 +49,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
}