fix(vue): eslint/tsconfig migration + resolve type errors
@robonen/vue (toolkit): migrate to eslint flat config + composite tsconfig; fix composable + test type errors (writable computed returns, null guards, overload-compatible signatures, typed test helpers) — all type-level.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, effectScope, nextTick, watch } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { broadcastedRef } from '.';
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
export * from './broadcastedRef';
|
||||
export * from './refAutoReset';
|
||||
export * from './refDebounced';
|
||||
export * from './refThrottled';
|
||||
export * from './until';
|
||||
export * from './useArrayFilter';
|
||||
export * from './useArrayFind';
|
||||
export * from './useArrayMap';
|
||||
export * from './useCached';
|
||||
export * from './useCloned';
|
||||
export * from './useCycleList';
|
||||
export * from './useLastChanged';
|
||||
export * from './usePrevious';
|
||||
export * from './useSyncRefs';
|
||||
export * from './useToNumber';
|
||||
export * from './useToString';
|
||||
export * from './watchDebounced';
|
||||
export * from './watchIgnorable';
|
||||
export * from './watchOnce';
|
||||
export * from './watchPausable';
|
||||
export * from './watchThrottled';
|
||||
export * from './whenever';
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref, watch } from 'vue';
|
||||
import { refAutoReset } from '.';
|
||||
|
||||
describe(refAutoReset, () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('initializes with the default value', () => {
|
||||
const value = refAutoReset('default', 1000);
|
||||
expect(value.value).toBe('default');
|
||||
});
|
||||
|
||||
it('resets to the default value after the delay', () => {
|
||||
const value = refAutoReset('default', 1000);
|
||||
|
||||
value.value = 'changed';
|
||||
expect(value.value).toBe('changed');
|
||||
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(value.value).toBe('changed');
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(value.value).toBe('default');
|
||||
});
|
||||
|
||||
it('uses a default delay of 10000ms', () => {
|
||||
const value = refAutoReset('default');
|
||||
|
||||
value.value = 'changed';
|
||||
vi.advanceTimersByTime(9999);
|
||||
expect(value.value).toBe('changed');
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(value.value).toBe('default');
|
||||
});
|
||||
|
||||
it('restarts the timer on each set', () => {
|
||||
const value = refAutoReset('default', 1000);
|
||||
|
||||
value.value = 'first';
|
||||
vi.advanceTimersByTime(800);
|
||||
|
||||
value.value = 'second';
|
||||
vi.advanceTimersByTime(800);
|
||||
// 1600ms total elapsed but only 800ms since last set
|
||||
expect(value.value).toBe('second');
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(value.value).toBe('default');
|
||||
});
|
||||
|
||||
it('does not reset before any write', () => {
|
||||
const value = refAutoReset('default', 1000);
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(value.value).toBe('default');
|
||||
});
|
||||
|
||||
it('resolves a reactive default value at reset time', () => {
|
||||
const fallback = ref('a');
|
||||
const value = refAutoReset(fallback, 1000);
|
||||
|
||||
expect(value.value).toBe('a');
|
||||
|
||||
value.value = 'changed';
|
||||
fallback.value = 'b';
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(value.value).toBe('b');
|
||||
});
|
||||
|
||||
it('resolves a reactive delay on each set', () => {
|
||||
const delay = ref(1000);
|
||||
const value = refAutoReset('default', delay);
|
||||
|
||||
value.value = 'first';
|
||||
delay.value = 500;
|
||||
|
||||
// delay is resolved at set time -> still 1000 for the first write
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(value.value).toBe('default');
|
||||
|
||||
// next set picks up the new delay
|
||||
value.value = 'second';
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(value.value).toBe('default');
|
||||
});
|
||||
|
||||
it('resolves a getter as the default value', () => {
|
||||
let base = 1;
|
||||
const value = refAutoReset(() => base, 1000);
|
||||
|
||||
expect(value.value).toBe(1);
|
||||
value.value = 99;
|
||||
base = 5;
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(value.value).toBe(5);
|
||||
});
|
||||
|
||||
it('is reactive and triggers watchers on set and on reset', async () => {
|
||||
const scope = effectScope();
|
||||
const spy = vi.fn();
|
||||
|
||||
scope.run(() => {
|
||||
const value = refAutoReset('default', 1000);
|
||||
watch(value, spy, { flush: 'sync' });
|
||||
value.value = 'changed';
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy).toHaveBeenLastCalledWith('changed', 'default', expect.anything());
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
await nextTick();
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
expect(spy).toHaveBeenLastCalledWith('default', 'changed', expect.anything());
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('cancels the pending reset when the owning scope is disposed', () => {
|
||||
const scope = effectScope();
|
||||
let value!: ReturnType<typeof refAutoReset<string>>;
|
||||
|
||||
scope.run(() => {
|
||||
value = refAutoReset('default', 1000);
|
||||
value.value = 'changed';
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(value.value).toBe('changed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { customRef, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { useTimeoutFn } from '@/composables/utilities/useTimeoutFn';
|
||||
|
||||
export type RefAutoResetReturn<T> = Ref<T>;
|
||||
|
||||
/**
|
||||
* @name refAutoReset
|
||||
* @category Reactivity
|
||||
* @description Create a ref that resets to its default value after a delay
|
||||
* since the last write. Each set restarts the timer; reading is reactive.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<T>} defaultValue The value the ref resets to (resolved each reset, can be reactive)
|
||||
* @param {MaybeRefOrGetter<number>} [afterMs=10000] Delay in milliseconds before resetting (resolved on each set, can be reactive)
|
||||
* @returns {RefAutoResetReturn<T>} A ref that auto-resets to `defaultValue`
|
||||
*
|
||||
* @example
|
||||
* const message = refAutoReset('', 1000);
|
||||
* message.value = 'Saved!'; // reverts to '' after 1000ms
|
||||
*
|
||||
* @example
|
||||
* // Reactive delay and default
|
||||
* const delay = ref(2000);
|
||||
* const fallback = ref('idle');
|
||||
* const status = refAutoReset(fallback, delay);
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function refAutoReset<T>(
|
||||
defaultValue: MaybeRefOrGetter<T>,
|
||||
afterMs: MaybeRefOrGetter<number> = 10000,
|
||||
): RefAutoResetReturn<T> {
|
||||
return customRef<T>((track, trigger) => {
|
||||
let value: T = toValue(defaultValue);
|
||||
|
||||
const { start, stop } = useTimeoutFn(
|
||||
() => {
|
||||
value = toValue(defaultValue);
|
||||
trigger();
|
||||
},
|
||||
afterMs,
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
return {
|
||||
get() {
|
||||
track();
|
||||
return value;
|
||||
},
|
||||
set(newValue) {
|
||||
value = newValue;
|
||||
trigger();
|
||||
|
||||
stop();
|
||||
start();
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, isReadonly, nextTick, reactive, ref } from 'vue';
|
||||
import { refDebounced } from '.';
|
||||
|
||||
describe(refDebounced, () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns a readonly ref', () => {
|
||||
const source = ref('a');
|
||||
const debounced = refDebounced(source);
|
||||
expect(isReadonly(debounced)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('mirrors the initial value synchronously', () => {
|
||||
const source = ref('initial');
|
||||
const debounced = refDebounced(source, 100);
|
||||
expect(debounced.value).toBe('initial');
|
||||
});
|
||||
|
||||
it('delays updates by the given ms', async () => {
|
||||
const source = ref('a');
|
||||
const debounced = refDebounced(source, 100);
|
||||
|
||||
source.value = 'b';
|
||||
await nextTick();
|
||||
expect(debounced.value).toBe('a');
|
||||
|
||||
vi.advanceTimersByTime(99);
|
||||
expect(debounced.value).toBe('a');
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(debounced.value).toBe('b');
|
||||
});
|
||||
|
||||
it('uses a default delay of 200ms', async () => {
|
||||
const source = ref(0);
|
||||
const debounced = refDebounced(source);
|
||||
|
||||
source.value = 1;
|
||||
await nextTick();
|
||||
|
||||
vi.advanceTimersByTime(199);
|
||||
expect(debounced.value).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(debounced.value).toBe(1);
|
||||
});
|
||||
|
||||
it('coalesces rapid bursts into a single trailing update', async () => {
|
||||
const source = ref(0);
|
||||
const debounced = refDebounced(source, 100);
|
||||
|
||||
source.value = 1;
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(50);
|
||||
|
||||
source.value = 2;
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(50);
|
||||
|
||||
source.value = 3;
|
||||
await nextTick();
|
||||
|
||||
// Still within the debounce window — no update yet.
|
||||
expect(debounced.value).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(debounced.value).toBe(3);
|
||||
});
|
||||
|
||||
it('respects maxWait to force progress under sustained input', async () => {
|
||||
const source = ref(0);
|
||||
const debounced = refDebounced(source, 100, { maxWait: 250 });
|
||||
|
||||
// Keep pushing updates just before each debounce timer fires.
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
source.value = i;
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(60);
|
||||
}
|
||||
|
||||
// 5 * 60 = 300ms elapsed; maxWait (250ms) must have forced a flush.
|
||||
expect(debounced.value).not.toBe(0);
|
||||
});
|
||||
|
||||
it('supports a reactive ms', async () => {
|
||||
const source = ref('a');
|
||||
const ms = ref(100);
|
||||
const debounced = refDebounced(source, ms);
|
||||
|
||||
ms.value = 50;
|
||||
source.value = 'b';
|
||||
await nextTick();
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(debounced.value).toBe('b');
|
||||
});
|
||||
|
||||
it('runs synchronously when ms is zero or negative', async () => {
|
||||
const source = ref('a');
|
||||
const debounced = refDebounced(source, 0);
|
||||
|
||||
source.value = 'b';
|
||||
await nextTick();
|
||||
expect(debounced.value).toBe('b');
|
||||
});
|
||||
|
||||
it('works with a getter source', async () => {
|
||||
const state = reactive({ n: 1 });
|
||||
const debounced = refDebounced(() => state.n, 100);
|
||||
|
||||
expect(debounced.value).toBe(1);
|
||||
|
||||
state.n = 2;
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(debounced.value).toBe(2);
|
||||
});
|
||||
|
||||
it('disposes pending timers when the owning scope stops', async () => {
|
||||
const source = ref('a');
|
||||
let debounced: ReturnType<typeof refDebounced<string>>;
|
||||
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
debounced = refDebounced(source, 100);
|
||||
});
|
||||
|
||||
source.value = 'b';
|
||||
await nextTick();
|
||||
|
||||
scope.stop();
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
// The pending update was cancelled with the scope.
|
||||
expect(debounced!.value).toBe('a');
|
||||
});
|
||||
|
||||
it('does not update when the source never changes (SSR-safe, no timers)', () => {
|
||||
const source = ref('stable');
|
||||
const debounced = refDebounced(source, 100);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(debounced.value).toBe('stable');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { shallowReadonly, shallowRef, toRef, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { useDebounceFn } from '@/composables/utilities/useDebounceFn';
|
||||
import type { UseDebounceFnOptions } from '@/composables/utilities/useDebounceFn';
|
||||
|
||||
export type RefDebouncedOptions = UseDebounceFnOptions;
|
||||
|
||||
export type RefDebouncedReturn<T> = Readonly<Ref<T>>;
|
||||
|
||||
/**
|
||||
* @name refDebounced
|
||||
* @category Reactivity
|
||||
* @description A readonly ref whose value mirrors a source but only after
|
||||
* updates stop arriving for `ms`. Wraps the source change in our debounce
|
||||
* primitive (built on `debounceFilter`), so rapid bursts collapse into a single
|
||||
* delayed write. Supports a `maxWait` ceiling so the value still progresses
|
||||
* under sustained input, and tears its timer down with the owning scope.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<T>} source The ref, getter, or reactive source to debounce
|
||||
* @param {MaybeRefOrGetter<number>} [ms=200] Debounce delay in milliseconds (can be reactive)
|
||||
* @param {RefDebouncedOptions} [options={}] Debounce options (`maxWait`, `rejectOnCancel`)
|
||||
* @returns {RefDebouncedReturn<T>} A readonly ref tracking the source with debounced updates
|
||||
*
|
||||
* @example
|
||||
* const input = ref('');
|
||||
* const debounced = refDebounced(input, 300);
|
||||
* // debounced.value lags `input` by 300ms of quiet
|
||||
*
|
||||
* @example
|
||||
* // Guarantee the debounced value advances at least every 1000ms
|
||||
* const debounced = refDebounced(input, 300, { maxWait: 1000 });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function refDebounced<T>(
|
||||
source: MaybeRefOrGetter<T>,
|
||||
ms: MaybeRefOrGetter<number> = 200,
|
||||
options: RefDebouncedOptions = {},
|
||||
): RefDebouncedReturn<T> {
|
||||
const reference = toRef(source);
|
||||
const debounced = shallowRef(toValue(source)) as Ref<T>;
|
||||
|
||||
const update = useDebounceFn(() => {
|
||||
debounced.value = reference.value;
|
||||
}, ms, options);
|
||||
|
||||
watch(reference, () => {
|
||||
void update();
|
||||
});
|
||||
|
||||
return shallowReadonly(debounced) as RefDebouncedReturn<T>;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { refThrottled } from '.';
|
||||
|
||||
describe(refThrottled, () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('initializes with the source value', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const source = ref('init');
|
||||
const throttled = refThrottled(source, 100);
|
||||
expect(throttled.value).toBe('init');
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('updates immediately on the leading edge', async () => {
|
||||
const scope = effectScope();
|
||||
await scope.run(async () => {
|
||||
const source = ref(0);
|
||||
const throttled = refThrottled(source, 100);
|
||||
|
||||
source.value = 1;
|
||||
await nextTick();
|
||||
expect(throttled.value).toBe(1);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('throttles intermediate updates within the window', async () => {
|
||||
const scope = effectScope();
|
||||
await scope.run(async () => {
|
||||
const source = ref(0);
|
||||
const throttled = refThrottled(source, 100);
|
||||
|
||||
source.value = 1;
|
||||
await nextTick();
|
||||
expect(throttled.value).toBe(1);
|
||||
|
||||
source.value = 2;
|
||||
await nextTick();
|
||||
// Still within the window: not yet propagated as a fresh leading update.
|
||||
expect(throttled.value).toBe(1);
|
||||
|
||||
source.value = 3;
|
||||
await nextTick();
|
||||
expect(throttled.value).toBe(1);
|
||||
|
||||
// Trailing edge fires with the most recent value.
|
||||
vi.advanceTimersByTime(100);
|
||||
await nextTick();
|
||||
expect(throttled.value).toBe(3);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not emit a trailing update when trailing is false', async () => {
|
||||
const scope = effectScope();
|
||||
await scope.run(async () => {
|
||||
const source = ref(0);
|
||||
const throttled = refThrottled(source, 100, false);
|
||||
|
||||
source.value = 1;
|
||||
await nextTick();
|
||||
expect(throttled.value).toBe(1);
|
||||
|
||||
source.value = 2;
|
||||
await nextTick();
|
||||
expect(throttled.value).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
await nextTick();
|
||||
// No trailing edge: value stays at the leading update.
|
||||
expect(throttled.value).toBe(1);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('skips the leading update when leading is false', async () => {
|
||||
const scope = effectScope();
|
||||
await scope.run(async () => {
|
||||
const source = ref(0);
|
||||
const throttled = refThrottled(source, 100, true, false);
|
||||
|
||||
source.value = 1;
|
||||
await nextTick();
|
||||
// Leading suppressed: initial value retained until the trailing edge.
|
||||
expect(throttled.value).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
await nextTick();
|
||||
expect(throttled.value).toBe(1);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('mirrors the source synchronously when delay <= 0', async () => {
|
||||
const scope = effectScope();
|
||||
await scope.run(async () => {
|
||||
const source = ref(0);
|
||||
const throttled = refThrottled(source, 0);
|
||||
|
||||
source.value = 1;
|
||||
await nextTick();
|
||||
expect(throttled.value).toBe(1);
|
||||
|
||||
source.value = 2;
|
||||
await nextTick();
|
||||
expect(throttled.value).toBe(2);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('mirrors the source synchronously when delay is negative', async () => {
|
||||
const scope = effectScope();
|
||||
await scope.run(async () => {
|
||||
const source = ref('a');
|
||||
const throttled = refThrottled(source, -50);
|
||||
|
||||
source.value = 'b';
|
||||
await nextTick();
|
||||
expect(throttled.value).toBe('b');
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('accepts a getter as the source', async () => {
|
||||
const scope = effectScope();
|
||||
await scope.run(async () => {
|
||||
const obj = ref({ n: 1 });
|
||||
const throttled = refThrottled(() => obj.value.n, 100);
|
||||
expect(throttled.value).toBe(1);
|
||||
|
||||
obj.value = { n: 2 };
|
||||
await nextTick();
|
||||
expect(throttled.value).toBe(2);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reopens the leading edge after the window elapses', async () => {
|
||||
const scope = effectScope();
|
||||
await scope.run(async () => {
|
||||
const source = ref(0);
|
||||
const throttled = refThrottled(source, 100);
|
||||
|
||||
source.value = 1;
|
||||
await nextTick();
|
||||
expect(throttled.value).toBe(1);
|
||||
|
||||
// Advance past the window so the next change is a fresh leading update.
|
||||
vi.advanceTimersByTime(100);
|
||||
await nextTick();
|
||||
|
||||
source.value = 2;
|
||||
await nextTick();
|
||||
expect(throttled.value).toBe(2);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ref, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { createFilterWrapper, throttleFilter } from '@/utils/filters';
|
||||
|
||||
export type RefThrottledReturn<T = any> = Ref<T>;
|
||||
|
||||
/**
|
||||
* @name refThrottled
|
||||
* @category Reactivity
|
||||
* @description A ref whose value updates are throttled. The returned ref mirrors
|
||||
* the source but propagates changes at most once per `delay` window, making it
|
||||
* useful for rate-limiting reactive updates driven by high-frequency events such
|
||||
* as `scroll` or `resize`.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<T>} source The ref, getter, or value to watch and throttle
|
||||
* @param {number} [delay=200] A zero-or-greater delay in milliseconds; values around 100–250 (or higher) are most useful
|
||||
* @param {boolean} [trailing=true] Update the value again on the trailing edge after the window elapses
|
||||
* @param {boolean} [leading=true] Update the value on the leading edge of the window
|
||||
* @returns {RefThrottledReturn<T>} A ref reflecting the throttled source value
|
||||
*
|
||||
* @example
|
||||
* const input = ref('');
|
||||
* const throttled = refThrottled(input, 1000);
|
||||
*
|
||||
* @example
|
||||
* const scrollY = ref(0);
|
||||
* const throttledY = refThrottled(scrollY, 100, true, false);
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function refThrottled<T = any>(
|
||||
source: MaybeRefOrGetter<T>,
|
||||
delay = 200,
|
||||
trailing = true,
|
||||
leading = true,
|
||||
): RefThrottledReturn<T> {
|
||||
const throttled = ref(toValue(source)) as Ref<T>;
|
||||
|
||||
// A non-positive delay disables throttling: mirror the source synchronously.
|
||||
if (delay <= 0) {
|
||||
watch(() => toValue(source), (value) => {
|
||||
throttled.value = value;
|
||||
});
|
||||
|
||||
return throttled;
|
||||
}
|
||||
|
||||
const update = createFilterWrapper(
|
||||
throttleFilter(delay, trailing, leading),
|
||||
() => {
|
||||
throttled.value = toValue(source);
|
||||
},
|
||||
);
|
||||
|
||||
watch(() => toValue(source), () => {
|
||||
update();
|
||||
});
|
||||
|
||||
return throttled;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { until } from '.';
|
||||
|
||||
describe(until, () => {
|
||||
it('resolves immediately when the value already matches', async () => {
|
||||
const value = ref(7);
|
||||
await expect(until(value).toBe(7)).resolves.toBe(7);
|
||||
});
|
||||
|
||||
it('resolves once the value changes to match toBe', async () => {
|
||||
const value = ref(0);
|
||||
const promise = until(value).toBe(7);
|
||||
|
||||
value.value = 3;
|
||||
value.value = 7;
|
||||
|
||||
await expect(promise).resolves.toBe(7);
|
||||
});
|
||||
|
||||
it('tracks another ref passed to toBe', async () => {
|
||||
const value = ref(0);
|
||||
const target = ref(5);
|
||||
const promise = until(value).toBe(target);
|
||||
|
||||
value.value = 5;
|
||||
await expect(promise).resolves.toBe(5);
|
||||
});
|
||||
|
||||
it('resolves with a getter source watched against a literal', async () => {
|
||||
const value = ref(0);
|
||||
const promise = until(() => value.value * 2).toBe(18);
|
||||
|
||||
value.value = 9;
|
||||
await expect(promise).resolves.toBe(18);
|
||||
});
|
||||
|
||||
it('resolves on toBeTruthy', async () => {
|
||||
const value = ref(0);
|
||||
const promise = until(value).toBeTruthy();
|
||||
|
||||
value.value = 1;
|
||||
await expect(promise).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it('resolves on toBeNull', async () => {
|
||||
const value = ref<number | null>(1);
|
||||
const promise = until(value).toBeNull();
|
||||
|
||||
value.value = null;
|
||||
await expect(promise).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('resolves on toBeUndefined', async () => {
|
||||
const value = ref<number | undefined>(1);
|
||||
const promise = until(value).toBeUndefined();
|
||||
|
||||
value.value = undefined;
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves on toBeNaN', async () => {
|
||||
const value = ref(0);
|
||||
const promise = until(value).toBeNaN();
|
||||
|
||||
value.value = Number.NaN;
|
||||
await expect(promise).resolves.toBeNaN();
|
||||
});
|
||||
|
||||
it('resolves on toMatch with a predicate', async () => {
|
||||
const value = ref(0);
|
||||
const promise = until(value).toMatch(v => v > 10);
|
||||
|
||||
value.value = 5;
|
||||
value.value = 11;
|
||||
await expect(promise).resolves.toBe(11);
|
||||
});
|
||||
|
||||
it('negates a condition with not.toBe', async () => {
|
||||
const value = ref(0);
|
||||
const promise = until(value).not.toBe(0);
|
||||
|
||||
value.value = 1;
|
||||
await expect(promise).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it('negates toBeTruthy with not', async () => {
|
||||
const value = ref(1);
|
||||
const promise = until(value).not.toBeTruthy();
|
||||
|
||||
value.value = 0;
|
||||
await expect(promise).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('negates a tracked ref with not.toBe', async () => {
|
||||
const value = ref(0);
|
||||
const target = ref(0);
|
||||
const promise = until(value).not.toBe(target);
|
||||
|
||||
value.value = 4;
|
||||
await expect(promise).resolves.toBe(4);
|
||||
});
|
||||
|
||||
it('resolves after a single change with changed', async () => {
|
||||
const value = ref(0);
|
||||
const promise = until(value).changed();
|
||||
|
||||
value.value = 1;
|
||||
await expect(promise).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it('resolves after n changes with changedTimes', async () => {
|
||||
const value = ref(0);
|
||||
const promise = until(value).changedTimes(3);
|
||||
|
||||
value.value = 1;
|
||||
value.value = 2;
|
||||
value.value = 3;
|
||||
await expect(promise).resolves.toBe(3);
|
||||
});
|
||||
|
||||
it('works with array sources via toContains', async () => {
|
||||
const list = ref<number[]>([1, 2]);
|
||||
const promise = until(list).toContains(3);
|
||||
|
||||
list.value = [1, 2, 3];
|
||||
await expect(promise).resolves.toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('negates toContains via not', async () => {
|
||||
const list = ref<number[]>([1, 2, 3]);
|
||||
const promise = until(list).not.toContains(3);
|
||||
|
||||
list.value = [1, 2];
|
||||
await expect(promise).resolves.toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('resolves with the current value on timeout when not throwing', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const value = ref(0);
|
||||
const promise = until(value).toBe(99, { timeout: 100 });
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
await expect(promise).resolves.toBe(0);
|
||||
}
|
||||
finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects on timeout when throwOnTimeout is set', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const value = ref(0);
|
||||
const promise = until(value).toBe(99, { timeout: 100, throwOnTimeout: true });
|
||||
// attach a catch synchronously so the rejection is observed
|
||||
const assertion = expect(promise).rejects.toBe('Timeout');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await assertion;
|
||||
}
|
||||
finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves before the timeout fires when condition is met', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const value = ref(0);
|
||||
const promise = until(value).toBe(7, { timeout: 1000, throwOnTimeout: true });
|
||||
|
||||
value.value = 7;
|
||||
await expect(promise).resolves.toBe(7);
|
||||
}
|
||||
finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops watching after it resolves', async () => {
|
||||
const value = ref(0);
|
||||
const promise = until(value).toBe(1);
|
||||
|
||||
value.value = 1;
|
||||
await promise;
|
||||
|
||||
// mutating further should not throw or re-trigger anything
|
||||
value.value = 2;
|
||||
value.value = 3;
|
||||
expect(value.value).toBe(3);
|
||||
});
|
||||
|
||||
it('does not leak a watcher into the owning scope', async () => {
|
||||
const value = ref(0);
|
||||
const scope = effectScope();
|
||||
|
||||
const promise = scope.run(() => until(value).toBe(1))!;
|
||||
value.value = 1;
|
||||
await expect(promise).resolves.toBe(1);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('supports a post flush timing', async () => {
|
||||
const value = ref(0);
|
||||
const promise = until(value).toBe(5, { flush: 'post' });
|
||||
|
||||
value.value = 5;
|
||||
await nextTick();
|
||||
await expect(promise).resolves.toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { isRef, nextTick, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, WatchOptions, WatchSource } from 'vue';
|
||||
|
||||
type ElementOf<T> = T extends Array<infer E> ? E : never;
|
||||
|
||||
type Falsy = false | void | null | undefined | 0 | 0n | '';
|
||||
|
||||
export interface UntilToMatchOptions {
|
||||
/**
|
||||
* Milliseconds timeout for promise to resolve/reject if the when condition does not meet.
|
||||
* 0 for never timed out
|
||||
*
|
||||
* @default 0
|
||||
*/
|
||||
timeout?: number;
|
||||
|
||||
/**
|
||||
* Reject the promise when timeout
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
throwOnTimeout?: boolean;
|
||||
|
||||
/**
|
||||
* `flush` option for internal watch
|
||||
*
|
||||
* @default 'sync'
|
||||
*/
|
||||
flush?: WatchOptions['flush'];
|
||||
|
||||
/**
|
||||
* `deep` option for internal watch
|
||||
*
|
||||
* @default 'false'
|
||||
*/
|
||||
deep?: WatchOptions['deep'];
|
||||
}
|
||||
|
||||
export interface UntilBaseInstance<T, Not extends boolean = false> {
|
||||
toMatch: (<U extends T = T>(
|
||||
condition: (v: T) => v is U,
|
||||
options?: UntilToMatchOptions,
|
||||
) => Not extends true ? Promise<Exclude<T, U>> : Promise<U>) & ((
|
||||
condition: (v: T) => boolean,
|
||||
options?: UntilToMatchOptions,
|
||||
) => Promise<T>);
|
||||
changed: (options?: UntilToMatchOptions) => Promise<T>;
|
||||
changedTimes: (n?: number, options?: UntilToMatchOptions) => Promise<T>;
|
||||
}
|
||||
|
||||
export interface UntilValueInstance<T, Not extends boolean = false> extends UntilBaseInstance<T, Not> {
|
||||
readonly not: UntilValueInstance<T, Not extends true ? false : true>;
|
||||
toBe: <P = T>(value: MaybeRefOrGetter<P>, options?: UntilToMatchOptions) => Not extends true ? Promise<T> : Promise<P>;
|
||||
toBeTruthy: (options?: UntilToMatchOptions) => Not extends true ? Promise<T & Falsy> : Promise<Exclude<T, Falsy>>;
|
||||
toBeNull: (options?: UntilToMatchOptions) => Not extends true ? Promise<Exclude<T, null>> : Promise<null>;
|
||||
toBeUndefined: (options?: UntilToMatchOptions) => Not extends true ? Promise<Exclude<T, undefined>> : Promise<undefined>;
|
||||
toBeNaN: (options?: UntilToMatchOptions) => Promise<T>;
|
||||
}
|
||||
|
||||
export interface UntilArrayInstance<T> extends UntilBaseInstance<T> {
|
||||
readonly not: UntilArrayInstance<T>;
|
||||
toContains: (value: MaybeRefOrGetter<ElementOf<T>>, options?: UntilToMatchOptions) => Promise<T>;
|
||||
}
|
||||
|
||||
function promiseTimeout(ms: number, throwOnTimeout = false, reason = 'Timeout'): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (throwOnTimeout)
|
||||
setTimeout(() => reject(reason), ms);
|
||||
else
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function createUntil<T>(r: WatchSource<T> | MaybeRefOrGetter<T>, isNot = false): UntilValueInstance<T, boolean> | UntilArrayInstance<T> {
|
||||
function toMatch(
|
||||
condition: (v: T) => boolean,
|
||||
{ flush = 'sync', deep = false, timeout, throwOnTimeout }: UntilToMatchOptions = {},
|
||||
): Promise<T> {
|
||||
let stop: (() => void) | null = null;
|
||||
const watcher = new Promise<T>((resolve) => {
|
||||
stop = watch(
|
||||
r as WatchSource<T>,
|
||||
(v) => {
|
||||
if (condition(v) !== isNot) {
|
||||
if (stop)
|
||||
stop();
|
||||
else
|
||||
nextTick(() => stop?.());
|
||||
|
||||
resolve(v);
|
||||
}
|
||||
},
|
||||
{
|
||||
flush,
|
||||
deep,
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const promises = [watcher];
|
||||
if (timeout !== null && timeout !== undefined) {
|
||||
promises.push(
|
||||
promiseTimeout(timeout, throwOnTimeout)
|
||||
.then(() => toValue(r as MaybeRefOrGetter<T>))
|
||||
.finally(() => stop?.()),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.race(promises);
|
||||
}
|
||||
|
||||
function toBe<P>(value: MaybeRefOrGetter<P | T>, options?: UntilToMatchOptions): Promise<T> {
|
||||
if (!isRef(value))
|
||||
return toMatch(v => v === value, options);
|
||||
|
||||
const { flush = 'sync', deep = false, timeout, throwOnTimeout } = options ?? {};
|
||||
let stop: (() => void) | null = null;
|
||||
const watcher = new Promise<T>((resolve) => {
|
||||
stop = watch(
|
||||
[r as WatchSource<T>, value],
|
||||
([v1, v2]) => {
|
||||
if (isNot !== (v1 === v2)) {
|
||||
if (stop)
|
||||
stop();
|
||||
else
|
||||
nextTick(() => stop?.());
|
||||
|
||||
resolve(v1 as T);
|
||||
}
|
||||
},
|
||||
{
|
||||
flush,
|
||||
deep,
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const promises = [watcher];
|
||||
if (timeout !== null && timeout !== undefined) {
|
||||
promises.push(
|
||||
promiseTimeout(timeout, throwOnTimeout)
|
||||
.then(() => toValue(r as MaybeRefOrGetter<T>))
|
||||
.finally(() => stop?.()),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.race(promises);
|
||||
}
|
||||
|
||||
function toBeTruthy(options?: UntilToMatchOptions): Promise<T> {
|
||||
return toMatch(v => Boolean(v), options);
|
||||
}
|
||||
|
||||
function toBeNull(options?: UntilToMatchOptions): Promise<T> {
|
||||
return toBe<null>(null, options);
|
||||
}
|
||||
|
||||
function toBeUndefined(options?: UntilToMatchOptions): Promise<T> {
|
||||
return toBe<undefined>(undefined, options);
|
||||
}
|
||||
|
||||
function toBeNaN(options?: UntilToMatchOptions): Promise<T> {
|
||||
return toMatch(Number.isNaN, options);
|
||||
}
|
||||
|
||||
function toContains(value: MaybeRefOrGetter<ElementOf<T>>, options?: UntilToMatchOptions): Promise<T> {
|
||||
return toMatch((v) => {
|
||||
const array = Array.from(v as Iterable<unknown>);
|
||||
return array.includes(value) || array.includes(toValue(value));
|
||||
}, options);
|
||||
}
|
||||
|
||||
function changed(options?: UntilToMatchOptions): Promise<T> {
|
||||
return changedTimes(1, options);
|
||||
}
|
||||
|
||||
function changedTimes(n = 1, options?: UntilToMatchOptions): Promise<T> {
|
||||
let count = -1;
|
||||
return toMatch(() => {
|
||||
count += 1;
|
||||
return count >= n;
|
||||
}, options);
|
||||
}
|
||||
|
||||
if (Array.isArray(toValue(r as MaybeRefOrGetter<T>))) {
|
||||
const instance: UntilArrayInstance<T> = {
|
||||
toMatch: toMatch as UntilArrayInstance<T>['toMatch'],
|
||||
toContains,
|
||||
changed,
|
||||
changedTimes,
|
||||
get not() {
|
||||
return createUntil(r, !isNot) as UntilArrayInstance<T>;
|
||||
},
|
||||
};
|
||||
return instance;
|
||||
}
|
||||
|
||||
const instance: UntilValueInstance<T, boolean> = {
|
||||
toMatch: toMatch as UntilValueInstance<T, boolean>['toMatch'],
|
||||
toBe: toBe as UntilValueInstance<T, boolean>['toBe'],
|
||||
toBeTruthy: toBeTruthy as UntilValueInstance<T, boolean>['toBeTruthy'],
|
||||
toBeNull: toBeNull as UntilValueInstance<T, boolean>['toBeNull'],
|
||||
toBeNaN,
|
||||
toBeUndefined: toBeUndefined as UntilValueInstance<T, boolean>['toBeUndefined'],
|
||||
changed,
|
||||
changedTimes,
|
||||
get not() {
|
||||
return createUntil(r, !isNot) as UntilValueInstance<T, boolean>;
|
||||
},
|
||||
};
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name until
|
||||
* @category Reactivity
|
||||
* @description Promised one-time watch for ref / getter changes. Resolve once the source matches a condition, optionally with a timeout.
|
||||
*
|
||||
* @param {WatchSource<T> | MaybeRefOrGetter<T>} r The reactive source to watch
|
||||
* @returns {UntilValueInstance<T> | UntilArrayInstance<T>} A chainable instance exposing `toBe`, `toBeTruthy`, `toBeNull`, `toBeUndefined`, `toBeNaN`, `toMatch`, `toContains`, `changed`, `changedTimes`, and the `not` negation
|
||||
*
|
||||
* @example
|
||||
* const count = ref(0);
|
||||
* await until(count).toBe(7);
|
||||
*
|
||||
* @example
|
||||
* const ready = ref(false);
|
||||
* await until(ready).toBeTruthy();
|
||||
*
|
||||
* @example
|
||||
* // negation and timeout
|
||||
* await until(count).not.toBe(0, { timeout: 1000, throwOnTimeout: true });
|
||||
*
|
||||
* @example
|
||||
* // resolve once the source changes n times
|
||||
* await until(count).changedTimes(3);
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function until<T extends unknown[]>(r: WatchSource<T> | MaybeRefOrGetter<T>): UntilArrayInstance<T>;
|
||||
export function until<T>(r: WatchSource<T> | MaybeRefOrGetter<T>): UntilValueInstance<T>;
|
||||
export function until<T>(r: WatchSource<T> | MaybeRefOrGetter<T>): UntilValueInstance<T> | UntilArrayInstance<T> {
|
||||
return createUntil(r);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useArrayFilter } from '.';
|
||||
|
||||
describe(useArrayFilter, () => {
|
||||
it('filters reactively', () => {
|
||||
const list = ref([1, 2, 3, 4]);
|
||||
const even = useArrayFilter(list, n => n % 2 === 0);
|
||||
expect(even.value).toEqual([2, 4]);
|
||||
|
||||
list.value = [1, 3, 5, 6];
|
||||
expect(even.value).toEqual([6]);
|
||||
});
|
||||
|
||||
it('unwraps reactive items', () => {
|
||||
const list = [ref(1), ref(2), ref(3)];
|
||||
const odd = useArrayFilter(list, n => n % 2 === 1);
|
||||
expect(odd.value).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
|
||||
/**
|
||||
* @name useArrayFilter
|
||||
* @category Reactivity
|
||||
* @description Reactive `Array.prototype.filter`.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
|
||||
* @param {(element: T, index: number, array: T[]) => boolean} fn Predicate
|
||||
* @returns {ComputedRef<T[]>} The filtered array
|
||||
*
|
||||
* @example
|
||||
* const list = ref([1, 2, 3, 4]);
|
||||
* const even = useArrayFilter(list, n => n % 2 === 0); // [2, 4]
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useArrayFilter<T>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
fn: (element: T, index: number, array: T[]) => boolean,
|
||||
): ComputedRef<T[]> {
|
||||
return computed(() => toValue(list).map(i => toValue(i)).filter(fn));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useArrayFind } from '.';
|
||||
|
||||
describe(useArrayFind, () => {
|
||||
it('finds reactively', () => {
|
||||
const list = ref([1, 2, 3]);
|
||||
const found = useArrayFind(list, n => n > 1);
|
||||
expect(found.value).toBe(2);
|
||||
|
||||
list.value = [10, 20];
|
||||
expect(found.value).toBe(10);
|
||||
});
|
||||
|
||||
it('returns undefined when nothing matches', () => {
|
||||
const found = useArrayFind(ref([1, 2]), n => n > 5);
|
||||
expect(found.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
|
||||
/**
|
||||
* @name useArrayFind
|
||||
* @category Reactivity
|
||||
* @description Reactive `Array.prototype.find`.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
|
||||
* @param {(element: T, index: number, array: T[]) => boolean} fn Predicate
|
||||
* @returns {ComputedRef<T | undefined>} The first matching element
|
||||
*
|
||||
* @example
|
||||
* const list = ref([1, 2, 3]);
|
||||
* const found = useArrayFind(list, n => n > 1); // 2
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useArrayFind<T>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
fn: (element: T, index: number, array: T[]) => boolean,
|
||||
): ComputedRef<T | undefined> {
|
||||
return computed(() => toValue(list).map(i => toValue(i)).find(fn));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useArrayMap } from '.';
|
||||
|
||||
describe(useArrayMap, () => {
|
||||
it('maps reactively', () => {
|
||||
const list = ref([1, 2, 3]);
|
||||
const doubled = useArrayMap(list, n => n * 2);
|
||||
expect(doubled.value).toEqual([2, 4, 6]);
|
||||
|
||||
list.value = [4, 5];
|
||||
expect(doubled.value).toEqual([8, 10]);
|
||||
});
|
||||
|
||||
it('unwraps reactive items', () => {
|
||||
const list = [ref(1), ref(2)];
|
||||
const mapped = useArrayMap(list, n => n + 1);
|
||||
expect(mapped.value).toEqual([2, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
|
||||
/**
|
||||
* @name useArrayMap
|
||||
* @category Reactivity
|
||||
* @description Reactive `Array.prototype.map`.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
|
||||
* @param {(element: T, index: number, array: T[]) => U} fn Mapper
|
||||
* @returns {ComputedRef<U[]>} The mapped array
|
||||
*
|
||||
* @example
|
||||
* const list = ref([1, 2, 3]);
|
||||
* const doubled = useArrayMap(list, n => n * 2); // [2, 4, 6]
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useArrayMap<T, U = T>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
fn: (element: T, index: number, array: T[]) => U,
|
||||
): ComputedRef<U[]> {
|
||||
return computed(() => toValue(list).map(i => toValue(i)).map(fn));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ref, nextTick, reactive } from 'vue';
|
||||
import { nextTick, reactive, ref } from 'vue';
|
||||
import { useCached } from '.';
|
||||
|
||||
const arrayEquals = (a: number[], b: number[]) => a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, watch, toValue } from 'vue';
|
||||
import { ref, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref, WatchOptions } from 'vue';
|
||||
|
||||
export type Comparator<Value> = (a: Value, b: Value) => boolean;
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { nextTick, reactive, ref } from 'vue';
|
||||
import { cloneFnDefault, useCloned } from '.';
|
||||
|
||||
describe(useCloned, () => {
|
||||
it('clones the initial source value (deep, not referentially equal)', () => {
|
||||
const source = ref({ nested: { count: 0 } });
|
||||
const { cloned } = useCloned(source);
|
||||
|
||||
expect(cloned.value).toEqual({ nested: { count: 0 } });
|
||||
expect(cloned.value).not.toBe(source.value);
|
||||
expect(cloned.value.nested).not.toBe(source.value.nested);
|
||||
});
|
||||
|
||||
it('re-clones automatically when the source ref changes', async () => {
|
||||
const source = ref({ count: 0 });
|
||||
const { cloned } = useCloned(source);
|
||||
|
||||
source.value = { count: 5 };
|
||||
await nextTick();
|
||||
|
||||
expect(cloned.value).toEqual({ count: 5 });
|
||||
expect(cloned.value).not.toBe(source.value);
|
||||
});
|
||||
|
||||
it('re-clones automatically when a deep source property changes', async () => {
|
||||
const source = ref({ nested: { count: 0 } });
|
||||
const { cloned } = useCloned(source);
|
||||
|
||||
source.value.nested.count = 9;
|
||||
await nextTick();
|
||||
|
||||
expect(cloned.value.nested.count).toBe(9);
|
||||
});
|
||||
|
||||
it('tracks modification of the cloned value via isModified', () => {
|
||||
const source = ref({ count: 0 });
|
||||
const { cloned, isModified } = useCloned(source);
|
||||
|
||||
expect(isModified.value).toBeFalsy();
|
||||
|
||||
cloned.value.count = 1;
|
||||
|
||||
expect(isModified.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not set isModified when the change came from a source sync', async () => {
|
||||
const source = ref({ count: 0 });
|
||||
const { isModified } = useCloned(source);
|
||||
|
||||
source.value = { count: 1 };
|
||||
await nextTick();
|
||||
|
||||
expect(isModified.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('sync() re-clones from source and resets isModified', () => {
|
||||
const source = ref({ count: 0 });
|
||||
const { cloned, isModified, sync } = useCloned(source);
|
||||
|
||||
cloned.value.count = 42;
|
||||
expect(isModified.value).toBeTruthy();
|
||||
|
||||
sync();
|
||||
|
||||
expect(cloned.value).toEqual({ count: 0 });
|
||||
expect(isModified.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('manual mode does not auto-sync on source change', async () => {
|
||||
const source = ref({ count: 0 });
|
||||
const { cloned, sync } = useCloned(source, { manual: true });
|
||||
|
||||
expect(cloned.value).toEqual({ count: 0 });
|
||||
|
||||
source.value = { count: 100 };
|
||||
await nextTick();
|
||||
|
||||
// still the original clone, manual mode ignores source changes
|
||||
expect(cloned.value).toEqual({ count: 0 });
|
||||
|
||||
sync();
|
||||
expect(cloned.value).toEqual({ count: 100 });
|
||||
});
|
||||
|
||||
it('supports a getter source', async () => {
|
||||
const state = reactive({ count: 1 });
|
||||
const { cloned } = useCloned(() => ({ count: state.count }));
|
||||
|
||||
expect(cloned.value).toEqual({ count: 1 });
|
||||
|
||||
state.count = 2;
|
||||
await nextTick();
|
||||
|
||||
expect(cloned.value).toEqual({ count: 2 });
|
||||
});
|
||||
|
||||
it('supports a plain (non-reactive) source value', () => {
|
||||
const { cloned, isModified } = useCloned({ count: 7 });
|
||||
|
||||
expect(cloned.value).toEqual({ count: 7 });
|
||||
expect(isModified.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('uses a custom clone function when provided', () => {
|
||||
const clone = vi.fn((s: { count: number }) => ({ count: s.count + 1 }));
|
||||
const source = ref({ count: 10 });
|
||||
const { cloned } = useCloned(source, { clone });
|
||||
|
||||
expect(clone).toHaveBeenCalled();
|
||||
expect(cloned.value).toEqual({ count: 11 });
|
||||
});
|
||||
|
||||
it('respects immediate: false (no clone until source changes)', async () => {
|
||||
const source = ref({ count: 0 });
|
||||
const { cloned } = useCloned(source, { immediate: false });
|
||||
|
||||
// not yet synced
|
||||
expect(cloned.value).toBeUndefined();
|
||||
|
||||
source.value = { count: 3 };
|
||||
await nextTick();
|
||||
|
||||
expect(cloned.value).toEqual({ count: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe(cloneFnDefault, () => {
|
||||
it('deep clones via structuredClone when available', () => {
|
||||
const input = { a: 1, b: { c: [1, 2, 3] }, d: new Date(0) };
|
||||
const out = cloneFnDefault(input);
|
||||
|
||||
expect(out).toEqual(input);
|
||||
expect(out).not.toBe(input);
|
||||
expect(out.b).not.toBe(input.b);
|
||||
expect(out.d).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('falls back to JSON when structuredClone is unavailable (SSR / old runtime)', () => {
|
||||
const original = globalThis.structuredClone;
|
||||
// simulate environment without structuredClone
|
||||
(globalThis as { structuredClone?: unknown }).structuredClone = undefined;
|
||||
|
||||
try {
|
||||
const input = { a: 1, b: { c: 2 } };
|
||||
const out = cloneFnDefault(input);
|
||||
|
||||
expect(out).toEqual(input);
|
||||
expect(out).not.toBe(input);
|
||||
expect(out.b).not.toBe(input.b);
|
||||
}
|
||||
finally {
|
||||
globalThis.structuredClone = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to JSON when the value is not structured-cloneable', () => {
|
||||
// functions are not structured-cloneable; JSON drops them
|
||||
const input = { keep: 1, fn: () => {} };
|
||||
const out = cloneFnDefault(input) as { keep: number; fn?: unknown };
|
||||
|
||||
expect(out.keep).toBe(1);
|
||||
expect(out.fn).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { isRef, ref, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref, WatchOptions } from 'vue';
|
||||
import { isFunction } from '@robonen/stdlib';
|
||||
|
||||
export type CloneFn<Source, Target = Source> = (source: Source) => Target;
|
||||
|
||||
export interface UseClonedOptions<T = unknown> extends WatchOptions {
|
||||
/**
|
||||
* Custom clone function.
|
||||
*
|
||||
* By default uses `structuredClone` when available, falling back to
|
||||
* `JSON.parse(JSON.stringify(value))`.
|
||||
*/
|
||||
clone?: CloneFn<T>;
|
||||
|
||||
/**
|
||||
* Manually sync the cloned ref instead of watching the source.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
manual?: boolean;
|
||||
}
|
||||
|
||||
export interface UseClonedReturn<T> {
|
||||
/**
|
||||
* The cloned, mutable ref.
|
||||
*/
|
||||
cloned: Ref<T>;
|
||||
|
||||
/**
|
||||
* Whether the cloned data has been modified since the last sync.
|
||||
*/
|
||||
isModified: Ref<boolean>;
|
||||
|
||||
/**
|
||||
* Sync the cloned data with the source manually.
|
||||
*/
|
||||
sync: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default clone implementation. Prefers the structured clone algorithm and
|
||||
* falls back to a JSON round-trip when `structuredClone` is unavailable
|
||||
* (older runtimes / SSR) or the value is not structured-cloneable.
|
||||
*/
|
||||
export function cloneFnDefault<T>(source: T): T {
|
||||
if (typeof structuredClone === 'function') {
|
||||
try {
|
||||
return structuredClone(source);
|
||||
}
|
||||
catch {
|
||||
// value contains functions, symbols, etc. — fall back to JSON.
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(source)) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useCloned
|
||||
* @category Reactivity
|
||||
* @description Reactive deep clone of a source with a mutable cloned ref, modification tracking, and manual mode.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<T>} source The reactive source to clone (ref, getter, or plain value)
|
||||
* @param {UseClonedOptions<T>} [options={}] Options: `clone`, `manual`, and watch options (`deep`, `immediate`, `flush`)
|
||||
* @returns {UseClonedReturn<T>} The cloned ref, an `isModified` flag, and a `sync` function
|
||||
*
|
||||
* @example
|
||||
* const original = ref({ count: 0 });
|
||||
* const { cloned, isModified, sync } = useCloned(original);
|
||||
* cloned.value.count = 1; // isModified.value === true
|
||||
* sync(); // re-clone from source, isModified.value === false
|
||||
*
|
||||
* @example
|
||||
* const { cloned, sync } = useCloned(source, { manual: true });
|
||||
* // cloned only updates when sync() is called
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useCloned<T>(
|
||||
source: MaybeRefOrGetter<T>,
|
||||
options: UseClonedOptions<T> = {},
|
||||
): UseClonedReturn<T> {
|
||||
const cloned = ref<T>() as Ref<T>;
|
||||
const isModified = ref(false);
|
||||
let lastSync = false;
|
||||
|
||||
const {
|
||||
manual,
|
||||
clone = cloneFnDefault,
|
||||
deep = true,
|
||||
immediate = true,
|
||||
} = options;
|
||||
|
||||
function sync(): void {
|
||||
lastSync = true;
|
||||
isModified.value = false;
|
||||
cloned.value = clone(toValue(source));
|
||||
}
|
||||
|
||||
watch(cloned, () => {
|
||||
if (lastSync) {
|
||||
lastSync = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isModified.value = true;
|
||||
}, {
|
||||
deep: true,
|
||||
flush: 'sync',
|
||||
});
|
||||
|
||||
if (!manual && (isRef(source) || isFunction(source))) {
|
||||
watch(source, sync, {
|
||||
...options,
|
||||
deep,
|
||||
immediate,
|
||||
});
|
||||
}
|
||||
else {
|
||||
sync();
|
||||
}
|
||||
|
||||
return { cloned, isModified, sync };
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { useCycleList } from '.';
|
||||
|
||||
describe(useCycleList, () => {
|
||||
it('starts at the first item', () => {
|
||||
const { state, index } = useCycleList(['a', 'b', 'c']);
|
||||
expect(state.value).toBe('a');
|
||||
expect(index.value).toBe(0);
|
||||
});
|
||||
|
||||
it('cycles forward and wraps around', () => {
|
||||
const { state, next } = useCycleList(['a', 'b', 'c']);
|
||||
expect(next()).toBe('b');
|
||||
expect(next()).toBe('c');
|
||||
expect(next()).toBe('a');
|
||||
expect(state.value).toBe('a');
|
||||
});
|
||||
|
||||
it('cycles backward and wraps around', () => {
|
||||
const { prev } = useCycleList(['a', 'b', 'c']);
|
||||
expect(prev()).toBe('c');
|
||||
expect(prev()).toBe('b');
|
||||
});
|
||||
|
||||
it('honors initialValue', () => {
|
||||
const { state, index } = useCycleList(['a', 'b', 'c'], { initialValue: 'b' });
|
||||
expect(state.value).toBe('b');
|
||||
expect(index.value).toBe(1);
|
||||
});
|
||||
|
||||
it('honors a ref initialValue', () => {
|
||||
const initialValue = ref('c');
|
||||
const { state, index } = useCycleList(['a', 'b', 'c'], { initialValue });
|
||||
expect(state.value).toBe('c');
|
||||
expect(index.value).toBe(2);
|
||||
});
|
||||
|
||||
it('go jumps to an index', () => {
|
||||
const { go, state } = useCycleList(['a', 'b', 'c']);
|
||||
expect(go(2)).toBe('c');
|
||||
expect(state.value).toBe('c');
|
||||
});
|
||||
|
||||
it('go wraps negative and out-of-range indices', () => {
|
||||
const { go } = useCycleList(['a', 'b', 'c']);
|
||||
expect(go(-1)).toBe('c');
|
||||
expect(go(3)).toBe('a');
|
||||
expect(go(4)).toBe('b');
|
||||
});
|
||||
|
||||
it('next/prev accept a step count', () => {
|
||||
const { next, prev } = useCycleList(['a', 'b', 'c', 'd']);
|
||||
expect(next(2)).toBe('c');
|
||||
expect(prev(3)).toBe('d');
|
||||
});
|
||||
|
||||
it('shift moves by a signed delta', () => {
|
||||
const { shift, state } = useCycleList(['a', 'b', 'c', 'd']);
|
||||
expect(shift(2)).toBe('c');
|
||||
expect(shift(-1)).toBe('b');
|
||||
expect(shift()).toBe('c');
|
||||
expect(state.value).toBe('c');
|
||||
});
|
||||
|
||||
it('exposes a writable index', () => {
|
||||
const { index, state } = useCycleList(['a', 'b', 'c']);
|
||||
index.value = 2;
|
||||
expect(state.value).toBe('c');
|
||||
expect(index.value).toBe(2);
|
||||
|
||||
// Out-of-range assignments wrap into bounds.
|
||||
index.value = 4;
|
||||
expect(state.value).toBe('b');
|
||||
expect(index.value).toBe(1);
|
||||
});
|
||||
|
||||
it('supports a getter-based list', () => {
|
||||
const source = ref(['a', 'b', 'c']);
|
||||
const { state, next, index } = useCycleList(() => source.value);
|
||||
expect(state.value).toBe('a');
|
||||
expect(next()).toBe('b');
|
||||
|
||||
source.value = ['x', 'b', 'y'];
|
||||
expect(state.value).toBe('b');
|
||||
expect(index.value).toBe(1);
|
||||
});
|
||||
|
||||
it('uses a custom getIndexOf resolver', () => {
|
||||
const list = [{ id: 1 }, { id: 2 }, { id: 3 }];
|
||||
const { state, index, next } = useCycleList(list, {
|
||||
initialValue: { id: 2 },
|
||||
getIndexOf: (value, l) => l.findIndex(item => item.id === value.id),
|
||||
});
|
||||
expect(index.value).toBe(1);
|
||||
expect(next()).toEqual({ id: 3 });
|
||||
});
|
||||
|
||||
it('falls back to fallbackIndex when the current item is missing', () => {
|
||||
const { index } = useCycleList(['a', 'b', 'c'], {
|
||||
initialValue: 'z',
|
||||
fallbackIndex: 2,
|
||||
});
|
||||
expect(index.value).toBe(2);
|
||||
});
|
||||
|
||||
it('preserves the current item across list changes when it still exists', () => {
|
||||
const list = ref(['a', 'b', 'c', 'd']);
|
||||
const { go, state, index } = useCycleList(list);
|
||||
go(1);
|
||||
expect(state.value).toBe('b');
|
||||
|
||||
list.value = ['x', 'b', 'y'];
|
||||
expect(state.value).toBe('b');
|
||||
expect(index.value).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to fallbackIndex when the current item disappears', async () => {
|
||||
const list = ref(['a', 'b', 'c']);
|
||||
const { go, state } = useCycleList(list, { fallbackIndex: 0 });
|
||||
go(2);
|
||||
expect(state.value).toBe('c');
|
||||
|
||||
list.value = ['x', 'y'];
|
||||
await nextTick();
|
||||
expect(state.value).toBe('x');
|
||||
});
|
||||
|
||||
it('does not corrupt state when the list is empty', () => {
|
||||
const list = ref<string[]>([]);
|
||||
const { state, next, prev, go } = useCycleList(list, { initialValue: 'a' });
|
||||
expect(state.value).toBe('a');
|
||||
// Operations on an empty list are no-ops rather than producing undefined.
|
||||
expect(next()).toBe('a');
|
||||
expect(prev()).toBe('a');
|
||||
expect(go(5)).toBe('a');
|
||||
expect(state.value).toBe('a');
|
||||
});
|
||||
|
||||
it('does not throw when a non-empty list becomes empty', async () => {
|
||||
const list = ref(['a', 'b', 'c']);
|
||||
const { state, go } = useCycleList(list);
|
||||
go(1);
|
||||
expect(state.value).toBe('b');
|
||||
|
||||
list.value = [];
|
||||
await nextTick();
|
||||
// State is retained (no NaN/undefined) when there is nothing to cycle to.
|
||||
expect(state.value).toBe('b');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { computed, shallowRef, toRef, toValue, watch } from 'vue';
|
||||
import type { MaybeRef, MaybeRefOrGetter, Ref, ShallowRef, WritableComputedRef } from 'vue';
|
||||
|
||||
export interface UseCycleListOptions<T> {
|
||||
/**
|
||||
* The initial value of the state. Defaults to the first item in the list.
|
||||
* A ref can be provided to reuse it.
|
||||
*/
|
||||
initialValue?: MaybeRef<T>;
|
||||
|
||||
/**
|
||||
* Index used when the current value is not found in the list.
|
||||
*
|
||||
* @default 0
|
||||
*/
|
||||
fallbackIndex?: number;
|
||||
|
||||
/**
|
||||
* Custom function to resolve the index of a value in the list.
|
||||
*/
|
||||
getIndexOf?: (value: T, list: T[]) => number;
|
||||
}
|
||||
|
||||
export interface UseCycleListReturn<T> {
|
||||
/**
|
||||
* The currently selected item.
|
||||
*/
|
||||
state: ShallowRef<T>;
|
||||
|
||||
/**
|
||||
* The index of the currently selected item. Writable — assigning jumps to that index.
|
||||
*/
|
||||
index: WritableComputedRef<number>;
|
||||
|
||||
/**
|
||||
* Move forward by `n` items (wraps around). Defaults to 1.
|
||||
*/
|
||||
next: (n?: number) => T;
|
||||
|
||||
/**
|
||||
* Move backward by `n` items (wraps around). Defaults to 1.
|
||||
*/
|
||||
prev: (n?: number) => T;
|
||||
|
||||
/**
|
||||
* Move by a signed `delta` relative to the current item (wraps around). Defaults to 1.
|
||||
*/
|
||||
shift: (delta?: number) => T;
|
||||
|
||||
/**
|
||||
* Jump to a specific index (wraps around out-of-range/negative indices).
|
||||
*/
|
||||
go: (i: number) => T;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useCycleList
|
||||
* @category Reactivity
|
||||
* @description Cycle through a list of items, with `next`/`prev`/`shift`/`go` controls.
|
||||
* Supports a reactive list — the index is kept valid when the list changes.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<T[]>} list The list to cycle through (can be reactive)
|
||||
* @param {UseCycleListOptions<T>} [options={}] Options
|
||||
* @returns {UseCycleListReturn<T>} State and controls
|
||||
*
|
||||
* @example
|
||||
* const { state, next, prev } = useCycleList(['a', 'b', 'c']);
|
||||
* next(); // state.value === 'b'
|
||||
*
|
||||
* @example
|
||||
* const { index } = useCycleList(['a', 'b', 'c']);
|
||||
* index.value = 2; // jump directly to 'c'
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useCycleList<T>(
|
||||
list: MaybeRefOrGetter<T[]>,
|
||||
options: UseCycleListOptions<T> = {},
|
||||
): UseCycleListReturn<T> {
|
||||
const { fallbackIndex = 0, getIndexOf } = options;
|
||||
|
||||
// Normalize the source once: a stable ref we can watch and read cheaply,
|
||||
// regardless of whether the caller passed an array, a ref, or a getter.
|
||||
const listRef = toRef(list) as Ref<T[]>;
|
||||
|
||||
const state = shallowRef(
|
||||
options.initialValue !== undefined ? toValue(options.initialValue) : listRef.value[0],
|
||||
) as ShallowRef<T>;
|
||||
|
||||
const index = computed<number>({
|
||||
get() {
|
||||
const targetList = listRef.value;
|
||||
|
||||
let position = getIndexOf
|
||||
? getIndexOf(state.value, targetList)
|
||||
: targetList.indexOf(state.value);
|
||||
|
||||
if (position < 0)
|
||||
position = fallbackIndex;
|
||||
|
||||
return position;
|
||||
},
|
||||
set(value) {
|
||||
set(value);
|
||||
},
|
||||
});
|
||||
|
||||
function set(i: number): T {
|
||||
const targetList = listRef.value;
|
||||
const length = targetList.length;
|
||||
|
||||
// Nothing to select — keep the current state untouched (avoids NaN indexing).
|
||||
if (length === 0)
|
||||
return state.value;
|
||||
|
||||
// Wrap negative and out-of-range indices into bounds.
|
||||
const position = ((i % length) + length) % length;
|
||||
const value = targetList[position]!;
|
||||
|
||||
state.value = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
function go(i: number): T {
|
||||
return set(i);
|
||||
}
|
||||
|
||||
function shift(delta = 1): T {
|
||||
return set(index.value + delta);
|
||||
}
|
||||
|
||||
function next(n = 1): T {
|
||||
return shift(n);
|
||||
}
|
||||
|
||||
function prev(n = 1): T {
|
||||
return shift(-n);
|
||||
}
|
||||
|
||||
// Keep the state in sync when the list shrinks/changes: re-resolving the
|
||||
// current index falls back automatically if the active item disappeared.
|
||||
watch(listRef, () => set(index.value));
|
||||
|
||||
return {
|
||||
state,
|
||||
index,
|
||||
next,
|
||||
prev,
|
||||
shift,
|
||||
go,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref, nextTick } from 'vue';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { useLastChanged } from '.';
|
||||
import { timestamp } from '@robonen/stdlib';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { timestamp } from '@robonen/stdlib';
|
||||
import { ref, watch } from 'vue';
|
||||
import type { WatchSource, WatchOptions, Ref } from 'vue';
|
||||
import type { Ref, WatchOptions, WatchSource } from 'vue';
|
||||
|
||||
export interface UseLastChangedOptions<
|
||||
Immediate extends boolean,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, isReadonly, nextTick, reactive, ref } from 'vue';
|
||||
import { usePrevious } from '.';
|
||||
|
||||
describe(usePrevious, () => {
|
||||
it('is undefined before any change', () => {
|
||||
const count = ref(0);
|
||||
const prev = usePrevious(count);
|
||||
expect(prev.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the provided initial value', () => {
|
||||
const count = ref(0);
|
||||
const prev = usePrevious(count, -1);
|
||||
expect(prev.value).toBe(-1);
|
||||
});
|
||||
|
||||
it('tracks the previous value on change', () => {
|
||||
const count = ref(0);
|
||||
const prev = usePrevious(count);
|
||||
|
||||
count.value = 1;
|
||||
expect(prev.value).toBe(0);
|
||||
|
||||
count.value = 5;
|
||||
expect(prev.value).toBe(1);
|
||||
});
|
||||
|
||||
it('works with a getter source', () => {
|
||||
const obj = ref({ n: 1 });
|
||||
const prev = usePrevious(() => obj.value.n);
|
||||
obj.value = { n: 2 };
|
||||
expect(prev.value).toBe(1);
|
||||
});
|
||||
|
||||
it('returns a readonly ref', () => {
|
||||
const count = ref(0);
|
||||
const prev = usePrevious(count);
|
||||
expect(isReadonly(prev)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not throw when writing to the readonly ref (warns instead)', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const count = ref(0);
|
||||
const prev = usePrevious(count);
|
||||
// @ts-expect-error: readonly ref must not be writable at the type level
|
||||
prev.value = 99;
|
||||
expect(prev.value).toBeUndefined();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('updates synchronously by default', () => {
|
||||
const count = ref(0);
|
||||
const prev = usePrevious(count);
|
||||
count.value = 1;
|
||||
// no flush/tick needed with the default sync flush
|
||||
expect(prev.value).toBe(0);
|
||||
});
|
||||
|
||||
it('respects a custom flush timing', async () => {
|
||||
const count = ref(0);
|
||||
const prev = usePrevious(count, undefined, { flush: 'post' });
|
||||
|
||||
count.value = 1;
|
||||
// post flush is deferred until after the next tick
|
||||
expect(prev.value).toBeUndefined();
|
||||
|
||||
await nextTick();
|
||||
expect(prev.value).toBe(0);
|
||||
});
|
||||
|
||||
it('tracks deep mutations with the deep option', () => {
|
||||
const state = reactive({ n: 1 });
|
||||
const prev = usePrevious(() => ({ ...state }), undefined, { deep: true });
|
||||
|
||||
state.n = 2;
|
||||
expect(prev.value).toEqual({ n: 1 });
|
||||
|
||||
state.n = 3;
|
||||
expect(prev.value).toEqual({ n: 2 });
|
||||
});
|
||||
|
||||
it('accepts a raw (non-ref) source', () => {
|
||||
const prev = usePrevious(5);
|
||||
expect(prev.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stops tracking when the owning scope is disposed', () => {
|
||||
const count = ref(0);
|
||||
const scope = effectScope();
|
||||
|
||||
const prev = scope.run(() => usePrevious(count))!;
|
||||
|
||||
count.value = 1;
|
||||
expect(prev.value).toBe(0);
|
||||
|
||||
scope.stop();
|
||||
|
||||
count.value = 2;
|
||||
// watcher is torn down with the scope, so the previous value is frozen
|
||||
expect(prev.value).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { shallowReadonly, shallowRef, toRef, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, ShallowRef, WatchOptions } from 'vue';
|
||||
|
||||
export type UsePreviousOptions = Pick<WatchOptions, 'deep' | 'flush'>;
|
||||
|
||||
/**
|
||||
* @name usePrevious
|
||||
* @category Reactivity
|
||||
* @description Track the previous value of a ref, getter, or reactive source.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<T>} value The source value to track
|
||||
* @param {T} [initialValue] The initial previous value, or an options object
|
||||
* @param {UsePreviousOptions} [options={}] Watch options (`deep`, `flush`)
|
||||
* @returns {Readonly<ShallowRef<T | undefined>>} The previous value of the source
|
||||
*
|
||||
* @example
|
||||
* const count = ref(0);
|
||||
* const prev = usePrevious(count);
|
||||
* count.value = 1; // prev.value === 0
|
||||
*
|
||||
* @example
|
||||
* const count = ref(0);
|
||||
* const prev = usePrevious(count, -1); // prev.value === -1 until count changes
|
||||
*
|
||||
* @example
|
||||
* const state = reactive({ n: 1 });
|
||||
* const prev = usePrevious(() => ({ ...state }), undefined, { deep: true });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function usePrevious<T>(value: MaybeRefOrGetter<T>, initialValue: T, options?: UsePreviousOptions): Readonly<ShallowRef<T>>;
|
||||
export function usePrevious<T>(value: MaybeRefOrGetter<T>, initialValue?: undefined, options?: UsePreviousOptions): Readonly<ShallowRef<T | undefined>>;
|
||||
export function usePrevious<T>(
|
||||
value: MaybeRefOrGetter<T>,
|
||||
initialValue?: T,
|
||||
options: UsePreviousOptions = {},
|
||||
): Readonly<ShallowRef<T | undefined>> {
|
||||
const previous = shallowRef<T | undefined>(initialValue);
|
||||
|
||||
watch(
|
||||
toRef(value),
|
||||
(_, oldValue) => {
|
||||
previous.value = oldValue;
|
||||
},
|
||||
{ flush: options.flush ?? 'sync', deep: options.deep },
|
||||
);
|
||||
|
||||
return shallowReadonly(previous);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import { isArray } from '@robonen/stdlib';
|
||||
*/
|
||||
export function useSyncRefs<T = unknown>(
|
||||
source: WatchSource<T>,
|
||||
targets: Ref<T> | Ref<T>[],
|
||||
targets: Ref<T> | Array<Ref<T>>,
|
||||
watchOptions: WatchOptions = {},
|
||||
) {
|
||||
const {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useToNumber } from '.';
|
||||
|
||||
describe(useToNumber, () => {
|
||||
it('parses a numeric string with parseFloat by default', () => {
|
||||
const str = ref('42.5');
|
||||
const num = useToNumber(str);
|
||||
expect(num.value).toBe(42.5);
|
||||
});
|
||||
|
||||
it('reacts to source changes', () => {
|
||||
const str = ref('1');
|
||||
const num = useToNumber(str);
|
||||
expect(num.value).toBe(1);
|
||||
str.value = '2.5';
|
||||
expect(num.value).toBe(2.5);
|
||||
});
|
||||
|
||||
it('passes through numbers unchanged', () => {
|
||||
expect(useToNumber(10).value).toBe(10);
|
||||
});
|
||||
|
||||
it('does not truncate a number source when method is parseInt', () => {
|
||||
expect(useToNumber(3.9, { method: 'parseInt' }).value).toBe(3.9);
|
||||
});
|
||||
|
||||
it('uses parseInt with radix', () => {
|
||||
expect(useToNumber('ff', { method: 'parseInt', radix: 16 }).value).toBe(255);
|
||||
});
|
||||
|
||||
it('resolves NaN to 0 when nanToZero is set', () => {
|
||||
expect(useToNumber('abc', { nanToZero: true }).value).toBe(0);
|
||||
expect(useToNumber('abc').value).toBeNaN();
|
||||
});
|
||||
|
||||
it('supports a custom converter function', () => {
|
||||
const num = useToNumber('10.4', { method: v => Math.round(+v) });
|
||||
expect(num.value).toBe(10);
|
||||
});
|
||||
|
||||
it('applies the custom converter to number sources too', () => {
|
||||
const num = useToNumber(3.7, { method: v => Math.floor(+v) });
|
||||
expect(num.value).toBe(3);
|
||||
});
|
||||
|
||||
it('reacts to source changes with a custom converter', () => {
|
||||
const src = ref<number | string>('5.6');
|
||||
const num = useToNumber(src, { method: v => Math.round(+v) });
|
||||
expect(num.value).toBe(6);
|
||||
src.value = 2.2;
|
||||
expect(num.value).toBe(2);
|
||||
});
|
||||
|
||||
it('clamps to min', () => {
|
||||
expect(useToNumber('-5', { min: 0 }).value).toBe(0);
|
||||
expect(useToNumber('5', { min: 0 }).value).toBe(5);
|
||||
});
|
||||
|
||||
it('clamps to max', () => {
|
||||
expect(useToNumber('150', { max: 100 }).value).toBe(100);
|
||||
expect(useToNumber('50', { max: 100 }).value).toBe(50);
|
||||
});
|
||||
|
||||
it('clamps to both min and max', () => {
|
||||
expect(useToNumber('-10', { min: 0, max: 100 }).value).toBe(0);
|
||||
expect(useToNumber('200', { min: 0, max: 100 }).value).toBe(100);
|
||||
expect(useToNumber('42', { min: 0, max: 100 }).value).toBe(42);
|
||||
});
|
||||
|
||||
it('reacts to clamped source changes', () => {
|
||||
const src = ref('5');
|
||||
const num = useToNumber(src, { min: 0, max: 10 });
|
||||
expect(num.value).toBe(5);
|
||||
src.value = '20';
|
||||
expect(num.value).toBe(10);
|
||||
src.value = '-3';
|
||||
expect(num.value).toBe(0);
|
||||
});
|
||||
|
||||
it('applies nanToZero before clamping', () => {
|
||||
expect(useToNumber('abc', { nanToZero: true, min: 1 }).value).toBe(1);
|
||||
});
|
||||
|
||||
it('supports getter sources', () => {
|
||||
const base = ref(2);
|
||||
const num = useToNumber(() => `${base.value}5`);
|
||||
expect(num.value).toBe(25);
|
||||
base.value = 9;
|
||||
expect(num.value).toBe(95);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
import { clamp, isFunction, isNumber, isString } from '@robonen/stdlib';
|
||||
|
||||
export type UseToNumberMethod = 'parseFloat' | 'parseInt' | ((value: number | string) => number);
|
||||
|
||||
export interface UseToNumberOptions {
|
||||
/**
|
||||
* Parsing method for string input, or a custom converter function
|
||||
*
|
||||
* @default 'parseFloat'
|
||||
*/
|
||||
method?: UseToNumberMethod;
|
||||
|
||||
/**
|
||||
* Radix for `parseInt`
|
||||
*/
|
||||
radix?: number;
|
||||
|
||||
/**
|
||||
* Resolve `NaN` to `0`
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
nanToZero?: boolean;
|
||||
|
||||
/**
|
||||
* Clamp the result to a minimum value (applied after parsing)
|
||||
*/
|
||||
min?: number;
|
||||
|
||||
/**
|
||||
* Clamp the result to a maximum value (applied after parsing)
|
||||
*/
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useToNumber
|
||||
* @category Reactivity
|
||||
* @description Reactively convert a string or number ref to a number.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<number | string>} value The source value (can be reactive)
|
||||
* @param {UseToNumberOptions} [options={}] Options
|
||||
* @returns {ComputedRef<number>} The numeric value
|
||||
*
|
||||
* @example
|
||||
* const str = ref('42.5');
|
||||
* const num = useToNumber(str); // 42.5
|
||||
*
|
||||
* @example
|
||||
* // custom converter and clamping
|
||||
* const n = useToNumber(input, { method: v => Math.round(+v), min: 0, max: 100 });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useToNumber(
|
||||
value: MaybeRefOrGetter<number | string>,
|
||||
options: UseToNumberOptions = {},
|
||||
): ComputedRef<number> {
|
||||
const {
|
||||
method = 'parseFloat',
|
||||
radix,
|
||||
nanToZero = false,
|
||||
min,
|
||||
max,
|
||||
} = options;
|
||||
|
||||
// Hoist the parser resolution out of the computed so the property lookup /
|
||||
// function-type check happens once instead of on every recompute.
|
||||
const parse: (source: number | string) => number = isFunction(method)
|
||||
? method
|
||||
: source => (isNumber(source) ? source : Number[method](source, radix));
|
||||
|
||||
const hasMin = isNumber(min);
|
||||
const hasMax = isNumber(max);
|
||||
|
||||
return computed<number>(() => {
|
||||
const source = toValue(value);
|
||||
|
||||
let resolved = isString(source) || isFunction(method)
|
||||
? parse(source)
|
||||
: source;
|
||||
|
||||
if (nanToZero && Number.isNaN(resolved))
|
||||
resolved = 0;
|
||||
|
||||
if (hasMin && hasMax)
|
||||
resolved = clamp(resolved, min, max);
|
||||
else if (hasMin && resolved < min)
|
||||
resolved = min;
|
||||
else if (hasMax && resolved > max)
|
||||
resolved = max;
|
||||
|
||||
return resolved;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useToString } from '.';
|
||||
|
||||
describe(useToString, () => {
|
||||
it('stringifies a number ref', () => {
|
||||
const num = ref(42);
|
||||
const str = useToString(num);
|
||||
expect(str.value).toBe('42');
|
||||
});
|
||||
|
||||
it('reacts to source changes', () => {
|
||||
const num = ref(1);
|
||||
const str = useToString(num);
|
||||
expect(str.value).toBe('1');
|
||||
num.value = 2;
|
||||
expect(str.value).toBe('2');
|
||||
});
|
||||
|
||||
it('stringifies a plain (non-reactive) value', () => {
|
||||
expect(useToString(10).value).toBe('10');
|
||||
expect(useToString('hello').value).toBe('hello');
|
||||
});
|
||||
|
||||
it('stringifies booleans', () => {
|
||||
expect(useToString(true).value).toBe('true');
|
||||
expect(useToString(false).value).toBe('false');
|
||||
});
|
||||
|
||||
it('stringifies null and undefined like String()', () => {
|
||||
expect(useToString(null).value).toBe('null');
|
||||
expect(useToString(undefined).value).toBe('undefined');
|
||||
});
|
||||
|
||||
it('passes through string sources unchanged', () => {
|
||||
const src = ref('already a string');
|
||||
expect(useToString(src).value).toBe('already a string');
|
||||
});
|
||||
|
||||
it('stringifies objects via their toString', () => {
|
||||
expect(useToString({}).value).toBe('[object Object]');
|
||||
expect(useToString([1, 2, 3]).value).toBe('1,2,3');
|
||||
});
|
||||
|
||||
it('honors a custom toString on objects', () => {
|
||||
const obj = { toString: () => 'custom' };
|
||||
expect(useToString(obj).value).toBe('custom');
|
||||
});
|
||||
|
||||
it('supports getter sources', () => {
|
||||
const base = ref(2);
|
||||
const str = useToString(() => `item-${base.value}`);
|
||||
expect(str.value).toBe('item-2');
|
||||
base.value = 9;
|
||||
expect(str.value).toBe('item-9');
|
||||
});
|
||||
|
||||
it('reacts to a getter returning different types', () => {
|
||||
const src = ref<unknown>(0);
|
||||
const str = useToString(() => src.value);
|
||||
expect(str.value).toBe('0');
|
||||
src.value = true;
|
||||
expect(str.value).toBe('true');
|
||||
src.value = null;
|
||||
expect(str.value).toBe('null');
|
||||
});
|
||||
|
||||
it('returns a readonly computed ref', () => {
|
||||
const str = useToString(ref(1));
|
||||
expect(typeof str.value).toBe('string');
|
||||
// ComputedRef exposes a value getter; result is always a string
|
||||
expect(str.value).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
|
||||
/**
|
||||
* @name useToString
|
||||
* @category Reactivity
|
||||
* @description Reactively stringify a value, equivalent to `computed(() => String(toValue(value)))`.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<unknown>} value The source value (can be a ref, getter, or plain value)
|
||||
* @returns {ComputedRef<string>} The string representation of the value
|
||||
*
|
||||
* @example
|
||||
* const count = ref(42);
|
||||
* const str = useToString(count); // '42'
|
||||
*
|
||||
* @example
|
||||
* // works with getters
|
||||
* const label = useToString(() => `item-${id.value}`);
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useToString(
|
||||
value: MaybeRefOrGetter<unknown>,
|
||||
): ComputedRef<string> {
|
||||
return computed<string>(() => `${toValue(value)}`);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, reactive, ref } from 'vue';
|
||||
import { debouncedWatch, watchDebounced } from '.';
|
||||
|
||||
describe(watchDebounced, () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not fire before the source changes', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchDebounced(count, cb, { debounce: 100, flush: 'sync' });
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defers the callback by the debounce delay', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchDebounced(count, cb, { debounce: 100, flush: 'sync' });
|
||||
|
||||
count.value = 1;
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function));
|
||||
});
|
||||
|
||||
it('coalesces rapid changes into a single trailing call', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchDebounced(count, cb, { debounce: 100, flush: 'sync' });
|
||||
|
||||
count.value = 1;
|
||||
vi.advanceTimersByTime(80);
|
||||
count.value = 2;
|
||||
vi.advanceTimersByTime(80);
|
||||
count.value = 3;
|
||||
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
// The filtered callback receives the args of the latest watch trigger:
|
||||
// new value 3, and old value 2 (the source value just before the last change).
|
||||
expect(cb).toHaveBeenLastCalledWith(3, 2, expect.any(Function));
|
||||
});
|
||||
|
||||
it('fires synchronously with no debounce (debounce = 0)', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchDebounced(count, cb, { flush: 'sync' });
|
||||
|
||||
count.value = 1;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function));
|
||||
});
|
||||
|
||||
it('enforces maxWait under sustained changes', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchDebounced(count, cb, { debounce: 100, maxWait: 250, flush: 'sync' });
|
||||
|
||||
// Keep changing before the debounce timer can ever settle.
|
||||
count.value = 1;
|
||||
vi.advanceTimersByTime(80);
|
||||
count.value = 2;
|
||||
vi.advanceTimersByTime(80);
|
||||
count.value = 3;
|
||||
vi.advanceTimersByTime(80);
|
||||
// 240ms elapsed, debounce has reset each time, but maxWait is 250ms.
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
count.value = 4;
|
||||
vi.advanceTimersByTime(20);
|
||||
// maxWait (250ms) elapsed -> forced invocation with the latest trigger args.
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(4, 3, expect.any(Function));
|
||||
});
|
||||
|
||||
it('does not double-fire when maxWait and debounce settle together', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchDebounced(count, cb, { debounce: 100, maxWait: 200, flush: 'sync' });
|
||||
|
||||
count.value = 1;
|
||||
// debounce settles at 100ms; maxWait would be at 200ms but is cleared.
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('supports a reactive debounce delay', () => {
|
||||
const count = ref(0);
|
||||
const delay = ref(100);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchDebounced(count, cb, { debounce: delay, flush: 'sync' });
|
||||
|
||||
count.value = 1;
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
|
||||
delay.value = 300;
|
||||
count.value = 2;
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(cb).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('works with a getter source', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchDebounced(() => count.value * 2, cb, { debounce: 100, flush: 'sync' });
|
||||
|
||||
count.value = 5;
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(10, 0, expect.any(Function));
|
||||
});
|
||||
|
||||
it('works with an array of sources', () => {
|
||||
const a = ref(0);
|
||||
const b = ref('x');
|
||||
const cb = vi.fn();
|
||||
|
||||
watchDebounced([a, b], cb, { debounce: 100, flush: 'sync' });
|
||||
|
||||
a.value = 1;
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith([1, 'x'], [0, 'x'], expect.any(Function));
|
||||
});
|
||||
|
||||
it('works with a reactive object source and deep option', () => {
|
||||
const state = reactive({ nested: { value: 0 } });
|
||||
const cb = vi.fn();
|
||||
|
||||
watchDebounced(state, cb, { debounce: 100, deep: true, flush: 'sync' });
|
||||
|
||||
state.nested.value = 1;
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fires immediately with the immediate option', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchDebounced(count, cb, { debounce: 100, immediate: true, flush: 'sync' });
|
||||
|
||||
// immediate runs through the filter synchronously only when debounce=0;
|
||||
// with a positive debounce the immediate run is also debounced.
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(0, undefined, expect.any(Function));
|
||||
});
|
||||
|
||||
it('respects a custom flush timing', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchDebounced(count, cb, { debounce: 100, flush: 'post' });
|
||||
|
||||
count.value = 1;
|
||||
await nextTick();
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns a handle that stops watching', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
const stop = watchDebounced(count, cb, { debounce: 100, flush: 'sync' });
|
||||
|
||||
stop();
|
||||
|
||||
count.value = 1;
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops watching when the owning scope is disposed', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => watchDebounced(count, cb, { debounce: 100, flush: 'sync' }));
|
||||
|
||||
scope.stop();
|
||||
|
||||
count.value = 1;
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('honours a caller-supplied eventFilter over debounce/maxWait', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const passthrough = vi.fn((invoke: () => void) => invoke());
|
||||
|
||||
watchDebounced(count, cb, {
|
||||
debounce: 1000,
|
||||
eventFilter: passthrough,
|
||||
flush: 'sync',
|
||||
});
|
||||
|
||||
count.value = 1;
|
||||
// The custom filter invokes immediately, bypassing the debounce timer.
|
||||
expect(passthrough).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function));
|
||||
});
|
||||
|
||||
it('passes onCleanup to the callback', () => {
|
||||
const count = ref(0);
|
||||
const cleanup = vi.fn();
|
||||
|
||||
watchDebounced(count, (_value, _old, onCleanup) => {
|
||||
onCleanup(cleanup);
|
||||
}, { debounce: 100, flush: 'sync' });
|
||||
|
||||
count.value = 1;
|
||||
vi.advanceTimersByTime(100);
|
||||
count.value = 2;
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
// cleanup registered on the first settled run fires before the second.
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('exposes debouncedWatch as an alias', () => {
|
||||
expect(debouncedWatch).toBe(watchDebounced);
|
||||
});
|
||||
|
||||
it('runs in a non-DOM scope without touching globals (SSR-safe)', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
expect(() => {
|
||||
scope.run(() => watchDebounced(count, cb, { debounce: 100, flush: 'sync' }));
|
||||
}).not.toThrow();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { watch } from 'vue';
|
||||
import type {
|
||||
MaybeRefOrGetter,
|
||||
WatchCallback,
|
||||
WatchHandle,
|
||||
WatchOptions,
|
||||
WatchSource,
|
||||
} from 'vue';
|
||||
import { createFilterWrapper, debounceFilter } from '@/utils/filters';
|
||||
import type { ConfigurableEventFilter, EventFilter } from '@/utils/filters';
|
||||
|
||||
type MultiWatchSources = Array<WatchSource<unknown> | object>;
|
||||
|
||||
type MapSources<T> = {
|
||||
[K in keyof T]: T[K] extends WatchSource<infer V> ? V : T[K] extends object ? T[K] : never;
|
||||
};
|
||||
|
||||
type MapOldSources<T, Immediate> = {
|
||||
[K in keyof T]: T[K] extends WatchSource<infer V>
|
||||
? Immediate extends true ? V | undefined : V
|
||||
: T[K] extends object
|
||||
? Immediate extends true ? T[K] | undefined : T[K]
|
||||
: never;
|
||||
};
|
||||
|
||||
export interface WatchDebouncedOptions<Immediate> extends WatchOptions<Immediate>, ConfigurableEventFilter {
|
||||
/**
|
||||
* Delay in milliseconds before the watch callback fires after the last
|
||||
* source change. Resets on every change. Can be reactive.
|
||||
*
|
||||
* @default 0
|
||||
*/
|
||||
debounce?: MaybeRefOrGetter<number>;
|
||||
|
||||
/**
|
||||
* The maximum time the callback is allowed to be delayed before it is
|
||||
* forcibly invoked, even while the source keeps changing. Guarantees the
|
||||
* callback runs at least once per `maxWait` window under sustained input.
|
||||
* When omitted there is no upper bound. Can be reactive.
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
maxWait?: MaybeRefOrGetter<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name watchDebounced
|
||||
* @category Reactivity
|
||||
* @description Debounced `watch`. The callback is postponed until `debounce`
|
||||
* milliseconds have elapsed since the last source change; an optional `maxWait`
|
||||
* caps how long it can be delayed under sustained changes. Implemented via an
|
||||
* event filter so the public surface matches `watch` exactly.
|
||||
*
|
||||
* @param {WatchSource<T> | T} source The reactive source (ref, getter, reactive object, or array of sources) to watch
|
||||
* @param {WatchCallback} cb Invoked with the new value, old value, and `onCleanup` once the debounce settles
|
||||
* @param {WatchDebouncedOptions} [options] Watch options plus `debounce` (ms) and `maxWait` (ms ceiling)
|
||||
* @returns {WatchHandle} A handle to stop watching (also cancels a pending invocation)
|
||||
*
|
||||
* @example
|
||||
* const search = ref('');
|
||||
* watchDebounced(search, value => fetchResults(value), { debounce: 300 });
|
||||
*
|
||||
* @example
|
||||
* // Guarantee the callback runs at least every 1000ms while typing continuously
|
||||
* watchDebounced(input, save, { debounce: 300, maxWait: 1000 });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function watchDebounced<T, Immediate extends Readonly<boolean> = false>(
|
||||
source: WatchSource<T>,
|
||||
cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
|
||||
options?: WatchDebouncedOptions<Immediate>,
|
||||
): WatchHandle;
|
||||
|
||||
export function watchDebounced<T extends Readonly<MultiWatchSources>, Immediate extends Readonly<boolean> = false>(
|
||||
sources: [...T],
|
||||
cb: WatchCallback<MapSources<T>, MapOldSources<T, Immediate>>,
|
||||
options?: WatchDebouncedOptions<Immediate>,
|
||||
): WatchHandle;
|
||||
|
||||
export function watchDebounced<T extends object, Immediate extends Readonly<boolean> = false>(
|
||||
source: T,
|
||||
cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
|
||||
options?: WatchDebouncedOptions<Immediate>,
|
||||
): WatchHandle;
|
||||
|
||||
export function watchDebounced<Immediate extends Readonly<boolean> = false>(
|
||||
source: WatchSource<unknown> | MultiWatchSources | object,
|
||||
cb: WatchCallback,
|
||||
options: WatchDebouncedOptions<Immediate> = {},
|
||||
): WatchHandle {
|
||||
const {
|
||||
debounce = 0,
|
||||
maxWait,
|
||||
eventFilter,
|
||||
...watchOptions
|
||||
} = options;
|
||||
|
||||
// Honour a caller-supplied eventFilter if present; otherwise build a
|
||||
// debounce filter (with optional maxWait) from the timing options.
|
||||
const filter: EventFilter = eventFilter
|
||||
?? debounceFilter(debounce, { maxWait });
|
||||
|
||||
return watch(
|
||||
source,
|
||||
createFilterWrapper(filter, cb),
|
||||
watchOptions as WatchOptions<Immediate>,
|
||||
);
|
||||
}
|
||||
|
||||
export const debouncedWatch = watchDebounced;
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { watchIgnorable } from '.';
|
||||
|
||||
describe(watchIgnorable, () => {
|
||||
it('fires the callback on normal updates (async flush)', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
watchIgnorable(count, cb);
|
||||
|
||||
count.value = 1;
|
||||
await nextTick();
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function));
|
||||
});
|
||||
|
||||
it('suppresses the callback inside ignoreUpdates (async flush)', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const { ignoreUpdates } = watchIgnorable(count, cb);
|
||||
|
||||
ignoreUpdates(() => {
|
||||
count.value = 1;
|
||||
});
|
||||
await nextTick();
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
count.value = 2;
|
||||
await nextTick();
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(2, 1, expect.any(Function));
|
||||
});
|
||||
|
||||
it('commits when an ignored update is followed by a real change before flush (async)', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const { ignoreUpdates } = watchIgnorable(count, cb);
|
||||
|
||||
ignoreUpdates(() => {
|
||||
count.value = 1;
|
||||
});
|
||||
// A real (non-ignored) change after the ignored one: syncCounter > ignoreCounter
|
||||
count.value = 2;
|
||||
await nextTick();
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(2, 0, expect.any(Function));
|
||||
});
|
||||
|
||||
it('supports multiple chained ignored updates (async)', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const { ignoreUpdates } = watchIgnorable(count, cb);
|
||||
|
||||
ignoreUpdates(() => {
|
||||
count.value = 1;
|
||||
count.value = 2;
|
||||
count.value = 3;
|
||||
});
|
||||
await nextTick();
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fires the callback on normal updates (sync flush)', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
watchIgnorable(count, cb, { flush: 'sync' });
|
||||
|
||||
count.value = 1;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function));
|
||||
});
|
||||
|
||||
it('suppresses the callback inside ignoreUpdates (sync flush)', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const { ignoreUpdates } = watchIgnorable(count, cb, { flush: 'sync' });
|
||||
|
||||
ignoreUpdates(() => {
|
||||
count.value = 1;
|
||||
count.value = 2;
|
||||
});
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
count.value = 3;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(3, 2, expect.any(Function));
|
||||
});
|
||||
|
||||
it('ignorePrevAsyncUpdates suppresses already-queued changes (async)', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const { ignorePrevAsyncUpdates } = watchIgnorable(count, cb);
|
||||
|
||||
count.value = 1;
|
||||
// Drop the pending change before the async callback flushes
|
||||
ignorePrevAsyncUpdates();
|
||||
await nextTick();
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignorePrevAsyncUpdates only drops prior changes, not subsequent ones (async)', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const { ignorePrevAsyncUpdates } = watchIgnorable(count, cb);
|
||||
|
||||
count.value = 1;
|
||||
ignorePrevAsyncUpdates();
|
||||
count.value = 2;
|
||||
await nextTick();
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(2, 0, expect.any(Function));
|
||||
});
|
||||
|
||||
it('ignorePrevAsyncUpdates is a no-op for sync flush', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const { ignorePrevAsyncUpdates } = watchIgnorable(count, cb, { flush: 'sync' });
|
||||
|
||||
count.value = 1;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
// Calling after a sync change does nothing
|
||||
ignorePrevAsyncUpdates();
|
||||
count.value = 2;
|
||||
expect(cb).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stop tears down the watcher (async flush)', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const { stop } = watchIgnorable(count, cb);
|
||||
|
||||
stop();
|
||||
count.value = 1;
|
||||
await nextTick();
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stop tears down the watcher (sync flush)', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const { stop } = watchIgnorable(count, cb, { flush: 'sync' });
|
||||
|
||||
stop();
|
||||
count.value = 1;
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('watches an array of sources', async () => {
|
||||
const a = ref(0);
|
||||
const b = ref('x');
|
||||
const cb = vi.fn();
|
||||
const { ignoreUpdates } = watchIgnorable([a, b], cb);
|
||||
|
||||
a.value = 1;
|
||||
await nextTick();
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith([1, 'x'], [0, 'x'], expect.any(Function));
|
||||
|
||||
ignoreUpdates(() => {
|
||||
b.value = 'y';
|
||||
});
|
||||
await nextTick();
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('respects an eventFilter', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
// A filter that drops every invocation
|
||||
watchIgnorable(count, cb, { eventFilter: () => {} });
|
||||
|
||||
count.value = 1;
|
||||
await nextTick();
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('supports the immediate option', () => {
|
||||
const count = ref(5);
|
||||
const cb = vi.fn();
|
||||
watchIgnorable(count, cb, { immediate: true, flush: 'sync' });
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(5, undefined, expect.any(Function));
|
||||
});
|
||||
|
||||
it('stops with the owning effect scope', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => watchIgnorable(count, cb));
|
||||
|
||||
count.value = 1;
|
||||
await nextTick();
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
count.value = 2;
|
||||
await nextTick();
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { watch } from 'vue';
|
||||
import type { WatchCallback, WatchOptions, WatchSource, WatchStopHandle } from 'vue';
|
||||
import { noop } from '@robonen/stdlib';
|
||||
import type { AnyFunction } from '@robonen/stdlib';
|
||||
import { bypassFilter, createFilterWrapper } from '@/utils';
|
||||
import type { ConfigurableEventFilter } from '@/utils';
|
||||
|
||||
type MultiWatchSources = Array<WatchSource<unknown> | object>;
|
||||
|
||||
type MapSources<T> = {
|
||||
[K in keyof T]: T[K] extends WatchSource<infer V> ? V : T[K] extends object ? T[K] : never;
|
||||
};
|
||||
|
||||
type MapOldSources<T, Immediate> = {
|
||||
[K in keyof T]: T[K] extends WatchSource<infer V>
|
||||
? Immediate extends true ? V | undefined : V
|
||||
: T[K] extends object
|
||||
? Immediate extends true ? T[K] | undefined : T[K]
|
||||
: never;
|
||||
};
|
||||
|
||||
export interface WatchWithFilterOptions<Immediate> extends WatchOptions<Immediate>, ConfigurableEventFilter {}
|
||||
|
||||
export type IgnoredUpdater = (updater: () => void) => void;
|
||||
export type IgnoredPrevAsyncUpdates = () => void;
|
||||
|
||||
export interface WatchIgnorableReturn {
|
||||
/**
|
||||
* Run `updater`, suppressing the watch callback for any source writes it performs
|
||||
*/
|
||||
ignoreUpdates: IgnoredUpdater;
|
||||
/**
|
||||
* Ignore the callback for source changes already queued before this call (async flush only)
|
||||
*/
|
||||
ignorePrevAsyncUpdates: IgnoredPrevAsyncUpdates;
|
||||
/**
|
||||
* Stop the underlying watcher(s)
|
||||
*/
|
||||
stop: WatchStopHandle;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name watchIgnorable
|
||||
* @category Reactivity
|
||||
* @description Extended `watch` that exposes `ignoreUpdates(fn)` and `ignorePrevAsyncUpdates()` to suppress reactions to programmatic writes.
|
||||
*
|
||||
* @param {WatchSource<T> | T} source The reactive source (ref, getter, reactive object, or array of sources) to watch
|
||||
* @param {WatchCallback} cb Invoked with the new value, old value, and `onCleanup` when the source changes (unless ignored)
|
||||
* @param {WatchWithFilterOptions} [options={}] Watch options (`immediate`, `deep`, `flush`) plus an optional `eventFilter`
|
||||
* @returns {WatchIgnorableReturn} `{ ignoreUpdates, ignorePrevAsyncUpdates, stop }`
|
||||
*
|
||||
* @example
|
||||
* const count = ref(0);
|
||||
* const { ignoreUpdates } = watchIgnorable(count, value => console.log('changed', value));
|
||||
*
|
||||
* count.value = 1; // logs: changed 1
|
||||
* ignoreUpdates(() => {
|
||||
* count.value = 2; // does NOT log
|
||||
* });
|
||||
* count.value = 3; // logs: changed 3
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function watchIgnorable<T, Immediate extends Readonly<boolean> = false>(
|
||||
source: WatchSource<T>,
|
||||
cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
|
||||
options?: WatchWithFilterOptions<Immediate>,
|
||||
): WatchIgnorableReturn;
|
||||
|
||||
export function watchIgnorable<T extends Readonly<MultiWatchSources>, Immediate extends Readonly<boolean> = false>(
|
||||
sources: [...T],
|
||||
cb: WatchCallback<MapSources<T>, MapOldSources<T, Immediate>>,
|
||||
options?: WatchWithFilterOptions<Immediate>,
|
||||
): WatchIgnorableReturn;
|
||||
|
||||
export function watchIgnorable<T extends object, Immediate extends Readonly<boolean> = false>(
|
||||
source: T,
|
||||
cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
|
||||
options?: WatchWithFilterOptions<Immediate>,
|
||||
): WatchIgnorableReturn;
|
||||
|
||||
export function watchIgnorable<Immediate extends Readonly<boolean> = false>(
|
||||
source: any,
|
||||
cb: AnyFunction,
|
||||
options: WatchWithFilterOptions<Immediate> = {},
|
||||
): WatchIgnorableReturn {
|
||||
const { eventFilter = bypassFilter, ...watchOptions } = options;
|
||||
|
||||
const filteredCb = createFilterWrapper(eventFilter, cb);
|
||||
|
||||
let ignoreUpdates: IgnoredUpdater;
|
||||
let ignorePrevAsyncUpdates: IgnoredPrevAsyncUpdates;
|
||||
let stop: WatchStopHandle;
|
||||
|
||||
if (watchOptions.flush === 'sync') {
|
||||
let ignore = false;
|
||||
|
||||
// No async queue to drain with sync flush
|
||||
ignorePrevAsyncUpdates = noop;
|
||||
|
||||
ignoreUpdates = (updater: () => void) => {
|
||||
ignore = true;
|
||||
updater();
|
||||
ignore = false;
|
||||
};
|
||||
|
||||
stop = watch(
|
||||
source,
|
||||
(...args: any[]) => {
|
||||
if (!ignore)
|
||||
filteredCb(...args);
|
||||
},
|
||||
watchOptions,
|
||||
);
|
||||
}
|
||||
else {
|
||||
// flush: 'pre' | 'post'
|
||||
const disposables: WatchStopHandle[] = [];
|
||||
|
||||
// `syncCounter` increments on every source change (tracked synchronously).
|
||||
// `ignoreCounter` records how many of those changes should be suppressed.
|
||||
// Comparing the two on the async callback lets us know whether the change
|
||||
// came purely from an ignored update or includes a real modification.
|
||||
let ignoreCounter = 0;
|
||||
let syncCounter = 0;
|
||||
|
||||
ignorePrevAsyncUpdates = () => {
|
||||
ignoreCounter = syncCounter;
|
||||
};
|
||||
|
||||
disposables.push(
|
||||
watch(
|
||||
source,
|
||||
() => {
|
||||
syncCounter++;
|
||||
},
|
||||
{ ...watchOptions, flush: 'sync' },
|
||||
),
|
||||
);
|
||||
|
||||
ignoreUpdates = (updater: () => void) => {
|
||||
const syncCounterPrev = syncCounter;
|
||||
updater();
|
||||
ignoreCounter += syncCounter - syncCounterPrev;
|
||||
};
|
||||
|
||||
disposables.push(
|
||||
watch(
|
||||
source,
|
||||
(...args: any[]) => {
|
||||
const ignore = ignoreCounter > 0 && ignoreCounter === syncCounter;
|
||||
ignoreCounter = 0;
|
||||
syncCounter = 0;
|
||||
|
||||
if (ignore)
|
||||
return;
|
||||
|
||||
filteredCb(...args);
|
||||
},
|
||||
watchOptions,
|
||||
),
|
||||
);
|
||||
|
||||
stop = () => {
|
||||
for (const dispose of disposables) dispose();
|
||||
};
|
||||
}
|
||||
|
||||
return { ignoreUpdates, ignorePrevAsyncUpdates, stop };
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, reactive, ref } from 'vue';
|
||||
import { watchOnce } from '.';
|
||||
|
||||
describe(watchOnce, () => {
|
||||
it('does not fire before the source changes', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchOnce(count, cb, { flush: 'sync' });
|
||||
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fires once on the first change', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchOnce(count, cb, { flush: 'sync' });
|
||||
|
||||
count.value = 1;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function));
|
||||
});
|
||||
|
||||
it('auto-stops after the first trigger', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchOnce(count, cb, { flush: 'sync' });
|
||||
|
||||
count.value = 1;
|
||||
count.value = 2;
|
||||
count.value = 3;
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function));
|
||||
});
|
||||
|
||||
it('works with a getter source', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchOnce(() => count.value * 2, cb, { flush: 'sync' });
|
||||
|
||||
count.value = 5;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(10, 0, expect.any(Function));
|
||||
|
||||
count.value = 6;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('works with an array of sources', () => {
|
||||
const a = ref(0);
|
||||
const b = ref('x');
|
||||
const cb = vi.fn();
|
||||
|
||||
watchOnce([a, b], cb, { flush: 'sync' });
|
||||
|
||||
a.value = 1;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith([1, 'x'], [0, 'x'], expect.any(Function));
|
||||
|
||||
b.value = 'y';
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('works with a reactive object source and deep option', () => {
|
||||
const state = reactive({ nested: { value: 0 } });
|
||||
const cb = vi.fn();
|
||||
|
||||
watchOnce(state, cb, { deep: true, flush: 'sync' });
|
||||
|
||||
state.nested.value = 1;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
|
||||
state.nested.value = 2;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fires immediately with the immediate option and then stops', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchOnce(count, cb, { immediate: true, flush: 'sync' });
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(0, undefined, expect.any(Function));
|
||||
|
||||
count.value = 1;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('respects a custom flush timing', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchOnce(count, cb, { flush: 'post' });
|
||||
|
||||
count.value = 1;
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
await nextTick();
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
|
||||
count.value = 2;
|
||||
await nextTick();
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns a handle that stops watching before the first trigger', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
const stop = watchOnce(count, cb, { flush: 'sync' });
|
||||
|
||||
stop();
|
||||
|
||||
count.value = 1;
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops watching when the owning scope is disposed', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => watchOnce(count, cb, { flush: 'sync' }));
|
||||
|
||||
scope.stop();
|
||||
|
||||
count.value = 1;
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes an onCleanup function to the callback', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchOnce(count, cb, { flush: 'sync' });
|
||||
|
||||
count.value = 1;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb.mock.calls[0]![2]).toBeTypeOf('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { watch } from 'vue';
|
||||
import type { WatchCallback, WatchHandle, WatchOptions, WatchSource } from 'vue';
|
||||
|
||||
type MultiWatchSources = Array<WatchSource<unknown> | object>;
|
||||
|
||||
type MapSources<T> = {
|
||||
[K in keyof T]: T[K] extends WatchSource<infer V> ? V : T[K] extends object ? T[K] : never;
|
||||
};
|
||||
|
||||
type MapOldSources<T, Immediate> = {
|
||||
[K in keyof T]: T[K] extends WatchSource<infer V>
|
||||
? Immediate extends true ? V | undefined : V
|
||||
: T[K] extends object
|
||||
? Immediate extends true ? T[K] | undefined : T[K]
|
||||
: never;
|
||||
};
|
||||
|
||||
export type WatchOnceOptions<Immediate = boolean> = Omit<WatchOptions<Immediate>, 'once'>;
|
||||
|
||||
/**
|
||||
* @name watchOnce
|
||||
* @category Reactivity
|
||||
* @description Shorthand for `watch` that automatically stops after the callback fires once.
|
||||
*
|
||||
* @param {WatchSource<T> | T} source The reactive source (ref, getter, reactive object, or array of sources) to watch
|
||||
* @param {WatchCallback} cb Invoked once with the new value, old value, and `onCleanup`
|
||||
* @param {WatchOnceOptions} [options] Watch options (`immediate`, `deep`, `flush`); `once` is forced on
|
||||
* @returns {WatchHandle} A handle to stop watching before the first trigger
|
||||
*
|
||||
* @example
|
||||
* const count = ref(0);
|
||||
* watchOnce(count, value => console.log('fired once with', value));
|
||||
*
|
||||
* @example
|
||||
* watchOnce([a, b], ([a, b]) => console.log(a, b));
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function watchOnce<T>(
|
||||
source: WatchSource<T>,
|
||||
cb: WatchCallback<T, T | undefined>,
|
||||
options?: WatchOnceOptions<true>,
|
||||
): WatchHandle;
|
||||
|
||||
export function watchOnce<T extends Readonly<MultiWatchSources>>(
|
||||
source: [...T],
|
||||
cb: WatchCallback<MapSources<T>, MapOldSources<T, true>>,
|
||||
options?: WatchOnceOptions<true>,
|
||||
): WatchHandle;
|
||||
|
||||
export function watchOnce<T extends object>(
|
||||
source: T,
|
||||
cb: WatchCallback<T, T | undefined>,
|
||||
options?: WatchOnceOptions<true>,
|
||||
): WatchHandle;
|
||||
|
||||
export function watchOnce(
|
||||
source: WatchSource<unknown> | MultiWatchSources | object,
|
||||
cb: WatchCallback,
|
||||
options?: WatchOnceOptions,
|
||||
): WatchHandle {
|
||||
// Vue's native `once` stops the watcher after its first trigger (the
|
||||
// immediate run counts as that trigger), so no manual teardown is needed.
|
||||
return watch(source, cb, { ...options, once: true });
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, isReadonly, nextTick, reactive, ref } from 'vue';
|
||||
import { pausableWatch, watchPausable } from '.';
|
||||
import { debounceFilter } from '@/utils/filters';
|
||||
|
||||
describe(watchPausable, () => {
|
||||
it('invokes the callback on source change when active', async () => {
|
||||
const scope = effectScope();
|
||||
await scope.run(async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
watchPausable(count, cb);
|
||||
|
||||
count.value = 1;
|
||||
await nextTick();
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.anything());
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('starts active by default', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const { isActive } = watchPausable(ref(0), () => {});
|
||||
expect(isActive.value).toBeTruthy();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not invoke the callback while paused', async () => {
|
||||
const scope = effectScope();
|
||||
await scope.run(async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const { pause } = watchPausable(count, cb);
|
||||
|
||||
pause();
|
||||
count.value = 1;
|
||||
await nextTick();
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('isActive reflects pause/resume', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const { pause, resume, isActive } = watchPausable(ref(0), () => {});
|
||||
|
||||
expect(isActive.value).toBeTruthy();
|
||||
pause();
|
||||
expect(isActive.value).toBeFalsy();
|
||||
resume();
|
||||
expect(isActive.value).toBeTruthy();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resumes reacting to changes after resume', async () => {
|
||||
const scope = effectScope();
|
||||
await scope.run(async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const { pause, resume } = watchPausable(count, cb);
|
||||
|
||||
pause();
|
||||
count.value = 1;
|
||||
await nextTick();
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
resume();
|
||||
count.value = 2;
|
||||
await nextTick();
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(2, 1, expect.anything());
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not replay changes that happened while paused', async () => {
|
||||
const scope = effectScope();
|
||||
await scope.run(async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const { pause, resume } = watchPausable(count, cb);
|
||||
|
||||
pause();
|
||||
count.value = 1;
|
||||
count.value = 2;
|
||||
await nextTick();
|
||||
resume();
|
||||
await nextTick();
|
||||
|
||||
// Resume alone must not fire the callback for the missed changes.
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('respects initialState: paused', async () => {
|
||||
const scope = effectScope();
|
||||
await scope.run(async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const { isActive, resume } = watchPausable(count, cb, { initialState: 'paused' });
|
||||
|
||||
expect(isActive.value).toBeFalsy();
|
||||
count.value = 1;
|
||||
await nextTick();
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
resume();
|
||||
count.value = 2;
|
||||
await nextTick();
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('stop() halts the watcher permanently', async () => {
|
||||
const scope = effectScope();
|
||||
await scope.run(async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const { stop, resume } = watchPausable(count, cb);
|
||||
|
||||
stop();
|
||||
count.value = 1;
|
||||
await nextTick();
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
// resume cannot revive a stopped watcher
|
||||
resume();
|
||||
count.value = 2;
|
||||
await nextTick();
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('returns a readonly isActive ref', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const { isActive } = watchPausable(ref(0), () => {});
|
||||
expect(isReadonly(isActive)).toBeTruthy();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('supports multiple sources', async () => {
|
||||
const scope = effectScope();
|
||||
await scope.run(async () => {
|
||||
const a = ref(0);
|
||||
const b = ref('x');
|
||||
const cb = vi.fn();
|
||||
watchPausable([a, b], cb);
|
||||
|
||||
a.value = 1;
|
||||
await nextTick();
|
||||
expect(cb).toHaveBeenLastCalledWith([1, 'x'], [0, 'x'], expect.anything());
|
||||
|
||||
b.value = 'y';
|
||||
await nextTick();
|
||||
expect(cb).toHaveBeenLastCalledWith([1, 'y'], [1, 'x'], expect.anything());
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('supports a getter source', async () => {
|
||||
const scope = effectScope();
|
||||
await scope.run(async () => {
|
||||
const state = reactive({ n: 1 });
|
||||
const cb = vi.fn();
|
||||
watchPausable(() => state.n, cb);
|
||||
|
||||
state.n = 2;
|
||||
await nextTick();
|
||||
expect(cb).toHaveBeenLastCalledWith(2, 1, expect.anything());
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('supports a reactive object source with deep', async () => {
|
||||
const scope = effectScope();
|
||||
await scope.run(async () => {
|
||||
const state = reactive({ nested: { n: 1 } });
|
||||
const cb = vi.fn();
|
||||
watchPausable(state, cb, { deep: true });
|
||||
|
||||
state.nested.n = 2;
|
||||
await nextTick();
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('fires synchronously with flush: sync', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
watchPausable(count, cb, { flush: 'sync' });
|
||||
|
||||
count.value = 1;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('honors immediate option', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
watchPausable(count, cb, { immediate: true, flush: 'sync' });
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(0, undefined, expect.anything());
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('composes with a custom eventFilter (debounce)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const { pause } = watchPausable(count, cb, {
|
||||
eventFilter: debounceFilter(100),
|
||||
flush: 'sync',
|
||||
});
|
||||
|
||||
count.value = 1;
|
||||
count.value = 2;
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
|
||||
// While paused the filter must not even be reached.
|
||||
pause();
|
||||
count.value = 3;
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
scope.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('pausableWatch is an alias for watchPausable', () => {
|
||||
expect(pausableWatch).toBe(watchPausable);
|
||||
});
|
||||
|
||||
it('works outside an effect scope (SSR-style, manual stop)', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const { stop } = watchPausable(count, cb);
|
||||
|
||||
count.value = 1;
|
||||
await nextTick();
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { ref, shallowReadonly, watch } from 'vue';
|
||||
import type {
|
||||
MultiWatchSources,
|
||||
Ref,
|
||||
WatchCallback,
|
||||
WatchOptions,
|
||||
WatchSource,
|
||||
WatchStopHandle,
|
||||
} from 'vue';
|
||||
import { bypassFilter, createFilterWrapper } from '@/utils/filters';
|
||||
import type { ConfigurableEventFilter, EventFilter } from '@/utils/filters';
|
||||
|
||||
type MapSources<T> = {
|
||||
[K in keyof T]: T[K] extends WatchSource<infer V> ? V : never;
|
||||
};
|
||||
|
||||
type MapOldSources<T, Immediate> = {
|
||||
[K in keyof T]: T[K] extends WatchSource<infer V>
|
||||
? Immediate extends true ? V | undefined : V
|
||||
: never;
|
||||
};
|
||||
|
||||
export interface UseWatchPausableOptions<Immediate>
|
||||
extends WatchOptions<Immediate>, ConfigurableEventFilter {
|
||||
/**
|
||||
* Whether the watcher starts in an active (running) or paused state.
|
||||
*
|
||||
* @default 'active'
|
||||
*/
|
||||
initialState?: 'active' | 'paused';
|
||||
}
|
||||
|
||||
export interface UseWatchPausableReturn {
|
||||
/**
|
||||
* Whether the watcher is currently active. While `false`, source changes are
|
||||
* ignored and the callback is not invoked.
|
||||
*/
|
||||
isActive: Readonly<Ref<boolean>>;
|
||||
/**
|
||||
* Pause the watcher. Changes to the source are ignored until {@link resume}.
|
||||
*/
|
||||
pause: () => void;
|
||||
/**
|
||||
* Resume the watcher so it reacts to source changes again.
|
||||
*/
|
||||
resume: () => void;
|
||||
/**
|
||||
* Stop the watcher entirely. It cannot be restarted afterwards.
|
||||
*/
|
||||
stop: WatchStopHandle;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name watchPausable
|
||||
* @category Reactivity
|
||||
* @description A `watch` whose execution can be paused and resumed on demand via a pausable event filter.
|
||||
*
|
||||
* @param {WatchSource | WatchSource[] | object} source The watch source (ref, getter, reactive object, or an array of sources)
|
||||
* @param {WatchCallback} cb The callback invoked when an active source changes
|
||||
* @param {UseWatchPausableOptions} [options={}] Watch options plus `eventFilter` and `initialState`
|
||||
* @returns {UseWatchPausableReturn} `{ stop, pause, resume, isActive }`
|
||||
*
|
||||
* @example
|
||||
* const count = ref(0);
|
||||
* const { pause, resume, isActive } = watchPausable(count, (value) => {
|
||||
* console.log('changed to', value);
|
||||
* });
|
||||
*
|
||||
* pause();
|
||||
* count.value++; // callback not called
|
||||
* resume();
|
||||
* count.value++; // callback called
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function watchPausable<T extends Readonly<MultiWatchSources>, Immediate extends Readonly<boolean> = false>(
|
||||
sources: [...T],
|
||||
cb: WatchCallback<MapSources<T>, MapOldSources<T, Immediate>>,
|
||||
options?: UseWatchPausableOptions<Immediate>,
|
||||
): UseWatchPausableReturn;
|
||||
export function watchPausable<T, Immediate extends Readonly<boolean> = false>(
|
||||
source: WatchSource<T>,
|
||||
cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
|
||||
options?: UseWatchPausableOptions<Immediate>,
|
||||
): UseWatchPausableReturn;
|
||||
export function watchPausable<T extends object, Immediate extends Readonly<boolean> = false>(
|
||||
source: T,
|
||||
cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
|
||||
options?: UseWatchPausableOptions<Immediate>,
|
||||
): UseWatchPausableReturn;
|
||||
export function watchPausable<Immediate extends Readonly<boolean> = false>(
|
||||
source: any,
|
||||
cb: any,
|
||||
options: UseWatchPausableOptions<Immediate> = {},
|
||||
): UseWatchPausableReturn {
|
||||
const {
|
||||
eventFilter: filter = bypassFilter,
|
||||
initialState = 'active',
|
||||
...watchOptions
|
||||
} = options;
|
||||
|
||||
const isActive = ref(initialState !== 'paused');
|
||||
|
||||
const eventFilter: EventFilter = (invoke) => {
|
||||
if (isActive.value)
|
||||
filter(invoke);
|
||||
};
|
||||
|
||||
const stop = watch(
|
||||
source,
|
||||
createFilterWrapper(eventFilter, cb),
|
||||
watchOptions,
|
||||
);
|
||||
|
||||
return {
|
||||
isActive: shallowReadonly(isActive),
|
||||
pause: () => { isActive.value = false; },
|
||||
resume: () => { isActive.value = true; },
|
||||
stop,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for {@link watchPausable}.
|
||||
*/
|
||||
export const pausableWatch = watchPausable;
|
||||
@@ -0,0 +1,214 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, reactive, ref } from 'vue';
|
||||
import { watchThrottled } from '.';
|
||||
|
||||
describe(watchThrottled, () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not fire before the source changes', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchThrottled(count, cb, { throttle: 100 });
|
||||
|
||||
await nextTick();
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fires immediately on the leading edge', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchThrottled(count, cb, { throttle: 100, flush: 'sync' });
|
||||
|
||||
count.value = 1;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function));
|
||||
});
|
||||
|
||||
it('throttles rapid changes to one leading + one trailing call', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchThrottled(count, cb, { throttle: 100, flush: 'sync' });
|
||||
|
||||
count.value = 1; // leading -> fires with 1
|
||||
count.value = 2;
|
||||
count.value = 3; // scheduled trailing -> fires with latest (3)
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(2);
|
||||
expect(cb).toHaveBeenLastCalledWith(3, 2, expect.any(Function));
|
||||
});
|
||||
|
||||
it('suppresses the leading call when leading is false', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchThrottled(count, cb, { throttle: 100, leading: false, flush: 'sync' });
|
||||
|
||||
count.value = 1;
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function));
|
||||
});
|
||||
|
||||
it('suppresses the trailing call when trailing is false', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchThrottled(count, cb, { throttle: 100, trailing: false, flush: 'sync' });
|
||||
|
||||
count.value = 1; // leading
|
||||
count.value = 2;
|
||||
count.value = 3;
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
// no trailing invocation
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('behaves like a plain watch when throttle is 0', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchThrottled(count, cb, { throttle: 0, flush: 'sync' });
|
||||
|
||||
count.value = 1;
|
||||
count.value = 2;
|
||||
count.value = 3;
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(3);
|
||||
expect(cb).toHaveBeenLastCalledWith(3, 2, expect.any(Function));
|
||||
});
|
||||
|
||||
it('works with a getter source', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchThrottled(() => count.value * 2, cb, { throttle: 100, flush: 'sync' });
|
||||
|
||||
count.value = 5;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(10, 0, expect.any(Function));
|
||||
});
|
||||
|
||||
it('works with an array of sources', async () => {
|
||||
const a = ref(0);
|
||||
const b = ref('x');
|
||||
const cb = vi.fn();
|
||||
|
||||
watchThrottled([a, b], cb, { throttle: 100, flush: 'sync' });
|
||||
|
||||
a.value = 1;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith([1, 'x'], [0, 'x'], expect.any(Function));
|
||||
});
|
||||
|
||||
it('works with a reactive object source and deep option', async () => {
|
||||
const state = reactive({ nested: { value: 0 } });
|
||||
const cb = vi.fn();
|
||||
|
||||
watchThrottled(state, cb, { throttle: 100, deep: true, flush: 'sync' });
|
||||
|
||||
state.nested.value = 1;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('honors a reactive throttle interval', async () => {
|
||||
const count = ref(0);
|
||||
const interval = ref(100);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchThrottled(count, cb, { throttle: interval, flush: 'sync' });
|
||||
|
||||
count.value = 1; // leading at t=0
|
||||
count.value = 2; // schedules trailing at t=100
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(cb).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('respects a post flush timing', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchThrottled(count, cb, { throttle: 100, flush: 'post' });
|
||||
|
||||
count.value = 1;
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
await nextTick();
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fires immediately with the immediate option', async () => {
|
||||
const count = ref(5);
|
||||
const cb = vi.fn();
|
||||
|
||||
watchThrottled(count, cb, { throttle: 100, immediate: true, flush: 'sync' });
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(5, undefined, expect.any(Function));
|
||||
});
|
||||
|
||||
it('returns a handle that stops watching', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
const stop = watchThrottled(count, cb, { throttle: 100, flush: 'sync' });
|
||||
|
||||
stop();
|
||||
|
||||
count.value = 1;
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops watching when the owning scope is disposed', async () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => watchThrottled(count, cb, { throttle: 100, flush: 'sync' }));
|
||||
|
||||
scope.stop();
|
||||
|
||||
count.value = 1;
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes onCleanup to the callback', async () => {
|
||||
const count = ref(0);
|
||||
const cleanup = vi.fn();
|
||||
|
||||
watchThrottled(count, (_value, _old, onCleanup) => {
|
||||
onCleanup(cleanup);
|
||||
}, { throttle: 100, flush: 'sync' });
|
||||
|
||||
count.value = 1;
|
||||
count.value = 2; // schedules trailing, which triggers cleanup of the leading run
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(cleanup).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, WatchCallback, WatchHandle, WatchOptions, WatchSource } from 'vue';
|
||||
import { createFilterWrapper, throttleFilter } from '@/utils/filters';
|
||||
import type { EventFilter } from '@/utils/filters';
|
||||
|
||||
type MultiWatchSources = Array<WatchSource<unknown> | object>;
|
||||
|
||||
type MapSources<T> = {
|
||||
[K in keyof T]: T[K] extends WatchSource<infer V> ? V : T[K] extends object ? T[K] : never;
|
||||
};
|
||||
|
||||
type MapOldSources<T, Immediate> = {
|
||||
[K in keyof T]: T[K] extends WatchSource<infer V>
|
||||
? Immediate extends true ? V | undefined : V
|
||||
: T[K] extends object
|
||||
? Immediate extends true ? T[K] | undefined : T[K]
|
||||
: never;
|
||||
};
|
||||
|
||||
export interface WatchThrottledOptions<Immediate> extends WatchOptions<Immediate> {
|
||||
/**
|
||||
* Throttle interval in milliseconds (can be reactive)
|
||||
*
|
||||
* @default 0
|
||||
*/
|
||||
throttle?: MaybeRefOrGetter<number>;
|
||||
/**
|
||||
* Invoke the callback on the trailing edge of the interval
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
trailing?: boolean;
|
||||
/**
|
||||
* Invoke the callback on the leading edge of the interval
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
leading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name watchThrottled
|
||||
* @category Reactivity
|
||||
* @description Like `watch`, but throttles the callback so it fires at most once per interval.
|
||||
*
|
||||
* @param {WatchSource<T> | T} source The reactive source (ref, getter, reactive object, or array of sources) to watch
|
||||
* @param {WatchCallback} cb Invoked with the new value, old value, and `onCleanup`, throttled by the interval
|
||||
* @param {WatchThrottledOptions} [options] Watch options plus `throttle` (ms), `leading`, and `trailing`
|
||||
* @returns {WatchHandle} A handle to stop watching
|
||||
*
|
||||
* @example
|
||||
* const count = ref(0);
|
||||
* watchThrottled(count, value => console.log(value), { throttle: 500 });
|
||||
*
|
||||
* @example
|
||||
* watchThrottled([a, b], ([a, b]) => save(a, b), { throttle: 1000, leading: false });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function watchThrottled<T, Immediate extends Readonly<boolean> = false>(
|
||||
source: WatchSource<T>,
|
||||
cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
|
||||
options?: WatchThrottledOptions<Immediate>,
|
||||
): WatchHandle;
|
||||
|
||||
export function watchThrottled<T extends Readonly<MultiWatchSources>, Immediate extends Readonly<boolean> = false>(
|
||||
source: [...T],
|
||||
cb: WatchCallback<MapSources<T>, MapOldSources<T, Immediate>>,
|
||||
options?: WatchThrottledOptions<Immediate>,
|
||||
): WatchHandle;
|
||||
|
||||
export function watchThrottled<T extends object, Immediate extends Readonly<boolean> = false>(
|
||||
source: T,
|
||||
cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
|
||||
options?: WatchThrottledOptions<Immediate>,
|
||||
): WatchHandle;
|
||||
|
||||
export function watchThrottled(
|
||||
source: WatchSource<unknown> | MultiWatchSources | object,
|
||||
cb: WatchCallback,
|
||||
options: WatchThrottledOptions<boolean> = {},
|
||||
): WatchHandle {
|
||||
const {
|
||||
throttle = 0,
|
||||
trailing = true,
|
||||
leading = true,
|
||||
eventFilter = throttleFilter(throttle, trailing, leading),
|
||||
...watchOptions
|
||||
} = options as WatchThrottledOptions<boolean> & { eventFilter?: EventFilter };
|
||||
|
||||
return watch(
|
||||
source,
|
||||
createFilterWrapper(eventFilter, cb),
|
||||
watchOptions,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, reactive, ref } from 'vue';
|
||||
import { whenever } from '.';
|
||||
|
||||
describe(whenever, () => {
|
||||
it('does not fire while the source is falsy', () => {
|
||||
const ready = ref(false);
|
||||
const cb = vi.fn();
|
||||
|
||||
whenever(ready, cb, { flush: 'sync' });
|
||||
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fires when the source becomes truthy', () => {
|
||||
const ready = ref(false);
|
||||
const cb = vi.fn();
|
||||
|
||||
whenever(ready, cb, { flush: 'sync' });
|
||||
|
||||
ready.value = true;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(true, false, expect.any(Function));
|
||||
});
|
||||
|
||||
it('does not fire when the source becomes falsy again', () => {
|
||||
const ready = ref(true);
|
||||
const cb = vi.fn();
|
||||
|
||||
whenever(ready, cb, { flush: 'sync' });
|
||||
|
||||
ready.value = false;
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
ready.value = true;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fires repeatedly on each truthy transition', () => {
|
||||
const value = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
whenever(value, cb, { flush: 'sync' });
|
||||
|
||||
value.value = 1;
|
||||
value.value = 0;
|
||||
value.value = 2;
|
||||
value.value = 0;
|
||||
value.value = 3;
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('works with a getter source', () => {
|
||||
const count = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
whenever(() => count.value > 5, cb, { flush: 'sync' });
|
||||
|
||||
count.value = 3;
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
count.value = 10;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(true, false, expect.any(Function));
|
||||
});
|
||||
|
||||
it('fires immediately when source is already truthy with immediate', () => {
|
||||
const ready = ref(true);
|
||||
const cb = vi.fn();
|
||||
|
||||
whenever(ready, cb, { immediate: true, flush: 'sync' });
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenLastCalledWith(true, undefined, expect.any(Function));
|
||||
});
|
||||
|
||||
it('does not fire immediately when source is falsy with immediate', () => {
|
||||
const ready = ref(false);
|
||||
const cb = vi.fn();
|
||||
|
||||
whenever(ready, cb, { immediate: true, flush: 'sync' });
|
||||
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('only fires once with the once option', async () => {
|
||||
const value = ref(0);
|
||||
const cb = vi.fn();
|
||||
|
||||
whenever(value, cb, { once: true, flush: 'sync' });
|
||||
|
||||
value.value = 1;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
|
||||
// once schedules teardown on the next tick
|
||||
await nextTick();
|
||||
|
||||
value.value = 0;
|
||||
value.value = 2;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('tracks deep mutations with the deep option', () => {
|
||||
const state = reactive({ active: false });
|
||||
const cb = vi.fn();
|
||||
|
||||
whenever(() => state.active, cb, { deep: true, flush: 'sync' });
|
||||
|
||||
state.active = true;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('respects a custom flush timing', async () => {
|
||||
const ready = ref(false);
|
||||
const cb = vi.fn();
|
||||
|
||||
whenever(ready, cb, { flush: 'post' });
|
||||
|
||||
ready.value = true;
|
||||
// post flush is deferred until after the next tick
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
await nextTick();
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns a handle that stops watching when called', () => {
|
||||
const ready = ref(false);
|
||||
const cb = vi.fn();
|
||||
|
||||
const stop = whenever(ready, cb, { flush: 'sync' });
|
||||
|
||||
stop();
|
||||
|
||||
ready.value = true;
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops watching when the owning scope is disposed', () => {
|
||||
const ready = ref(false);
|
||||
const cb = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => whenever(ready, cb, { flush: 'sync' }));
|
||||
|
||||
ready.value = true;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
|
||||
ready.value = false;
|
||||
ready.value = true;
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes the truthy value to the callback for non-boolean sources', () => {
|
||||
const user = ref<{ id: number } | null>(null);
|
||||
const cb = vi.fn();
|
||||
|
||||
whenever(user, cb, { flush: 'sync' });
|
||||
|
||||
const next = { id: 1 };
|
||||
user.value = next;
|
||||
expect(cb).toHaveBeenLastCalledWith(next, null, expect.any(Function));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { nextTick, watch } from 'vue';
|
||||
import type { WatchCallback, WatchHandle, WatchOptions, WatchSource } from 'vue';
|
||||
|
||||
type Truthy<T> = T extends false | null | undefined | 0 | '' ? never : T;
|
||||
|
||||
export interface WheneverOptions<Immediate = boolean> extends WatchOptions<Immediate> {
|
||||
/**
|
||||
* Only trigger the callback once when the source becomes truthy.
|
||||
*
|
||||
* Overrides the `once` option inherited from `WatchOptions`.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
once?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name whenever
|
||||
* @category Reactivity
|
||||
* @description Shorthand for watching a source to be truthy. Behaves like `watch`, but the callback only fires when the resolved value is truthy.
|
||||
*
|
||||
* @param {WatchSource<T>} source The reactive source to watch
|
||||
* @param {WatchCallback} cb Invoked with the truthy value, previous value, and `onCleanup`
|
||||
* @param {WheneverOptions} [options] Watch options (`immediate`, `deep`, `flush`, `once`)
|
||||
* @returns {WatchHandle} A handle to stop watching
|
||||
*
|
||||
* @example
|
||||
* const ready = ref(false);
|
||||
* whenever(ready, () => console.log('ready!'));
|
||||
*
|
||||
* @example
|
||||
* whenever(() => count.value > 5, () => console.log('over five'), { once: true });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function whenever<T>(source: WatchSource<T>, cb: WatchCallback<Truthy<T>, T | undefined>, options?: WheneverOptions<true>): WatchHandle;
|
||||
export function whenever<T>(source: WatchSource<T>, cb: WatchCallback<Truthy<T>, T>, options?: WheneverOptions<false>): WatchHandle;
|
||||
export function whenever<T>(
|
||||
source: WatchSource<T>,
|
||||
cb: WatchCallback<Truthy<T>, T | undefined>,
|
||||
options?: WheneverOptions,
|
||||
): WatchHandle {
|
||||
const stop = watch(
|
||||
source,
|
||||
(value, oldValue, onCleanup) => {
|
||||
if (value) {
|
||||
if (options?.once)
|
||||
nextTick(() => stop());
|
||||
|
||||
cb(value as Truthy<T>, oldValue, onCleanup);
|
||||
}
|
||||
},
|
||||
{
|
||||
...options,
|
||||
once: false,
|
||||
} as WatchOptions,
|
||||
);
|
||||
|
||||
return stop;
|
||||
}
|
||||
Reference in New Issue
Block a user