Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(watchTriggerable): extending
watch
with a manual trigger (#1736)
- Loading branch information
1 parent
d67d903
commit cf663ff
Showing
5 changed files
with
294 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |