feat(vue): expand @robonen/vue composable collection

Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
2026-06-08 15:51:16 +07:00
parent 9a912f7a77
commit 59e995d0b5
369 changed files with 36554 additions and 188 deletions
@@ -1,147 +0,0 @@
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 '.';
type MessageHandler = ((event: MessageEvent) => void) | null;
class MockBroadcastChannel {
static instances: MockBroadcastChannel[] = [];
name: string;
onmessage: MessageHandler = null;
closed = false;
constructor(name: string) {
this.name = name;
MockBroadcastChannel.instances.push(this);
}
postMessage(data: unknown) {
if (this.closed) return;
for (const instance of MockBroadcastChannel.instances) {
if (instance !== this && instance.name === this.name && !instance.closed && instance.onmessage) {
instance.onmessage(new MessageEvent('message', { data }));
}
}
}
close() {
this.closed = true;
const index = MockBroadcastChannel.instances.indexOf(this);
if (index > -1) MockBroadcastChannel.instances.splice(index, 1);
}
}
const mountWithRef = (setup: () => Record<string, any> | void) => {
return mount(
defineComponent({
setup,
template: '<div></div>',
}),
);
};
describe(broadcastedRef, () => {
let component: ReturnType<typeof mountWithRef>;
beforeEach(() => {
MockBroadcastChannel.instances = [];
vi.stubGlobal('BroadcastChannel', MockBroadcastChannel);
});
afterEach(() => {
component?.unmount();
vi.unstubAllGlobals();
});
it('create a ref with the initial value', () => {
component = mountWithRef(() => {
const count = broadcastedRef('test-key', 42);
expect(count.value).toBe(42);
});
});
it('broadcast value changes to other channels with the same key', () => {
const ref1 = broadcastedRef('shared', 0);
const ref2 = broadcastedRef('shared', 0);
ref1.value = 100;
expect(ref2.value).toBe(100);
});
it('not broadcast to channels with a different key', () => {
const ref1 = broadcastedRef('key-a', 0);
const ref2 = broadcastedRef('key-b', 0);
ref1.value = 100;
expect(ref2.value).toBe(0);
});
it('receive values from other channels and trigger reactivity', async () => {
const callback = vi.fn();
component = mountWithRef(() => {
const data = broadcastedRef('reactive-test', 'initial');
watch(data, callback, { flush: 'sync' });
});
const sender = broadcastedRef('reactive-test', '');
sender.value = 'updated';
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith('updated', 'initial', expect.anything());
});
it('not broadcast initial value by default', () => {
const ref1 = broadcastedRef('no-immediate', 'first');
const ref2 = broadcastedRef('no-immediate', 'second');
expect(ref1.value).toBe('first');
expect(ref2.value).toBe('second');
});
it('broadcast initial value when immediate is true', () => {
const ref1 = broadcastedRef('immediate-test', 'existing');
broadcastedRef('immediate-test', 'new-value', { immediate: true });
expect(ref1.value).toBe('new-value');
});
it('close channel on scope dispose', () => {
const scope = effectScope();
scope.run(() => {
broadcastedRef('dispose-test', 0);
});
expect(MockBroadcastChannel.instances).toHaveLength(1);
scope.stop();
expect(MockBroadcastChannel.instances).toHaveLength(0);
});
it('handle complex object values via structured clone', () => {
const ref1 = broadcastedRef('object-test', { status: 'pending', amount: 0 });
const ref2 = broadcastedRef('object-test', { status: 'pending', amount: 0 });
ref1.value = { status: 'paid', amount: 99.99 };
expect(ref2.value).toEqual({ status: 'paid', amount: 99.99 });
});
it('fallback to a regular ref when BroadcastChannel is not available', () => {
vi.stubGlobal('BroadcastChannel', undefined);
const data = broadcastedRef('fallback', 'value');
expect(data.value).toBe('value');
data.value = 'updated';
expect(data.value).toBe('updated');
});
});
@@ -1,68 +0,0 @@
import { customRef, ref } from 'vue';
import type { Ref } from 'vue';
import { defaultWindow } from '@/types';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface BroadcastedRefOptions {
/**
* Immediately broadcast the initial value to other tabs on creation
* @default false
*/
immediate?: boolean;
}
/**
* @name broadcastedRef
* @category Reactivity
* @description Creates a custom ref that syncs its value across browser tabs via the BroadcastChannel API
*
* @param {string} key The channel key to use for broadcasting
* @param {T} initialValue The initial value of the ref
* @param {BroadcastedRefOptions} [options={}] Options
* @returns {Ref<T>} A custom ref that broadcasts value changes across tabs
*
* @example
* const count = broadcastedRef('counter', 0);
*
* @example
* const state = broadcastedRef('payment-status', { status: 'pending' });
*
* @since 0.0.13
*/
export function broadcastedRef<T>(key: string, initialValue: T, options: BroadcastedRefOptions = {}): Ref<T> {
const { immediate = false } = options;
if (!defaultWindow || typeof BroadcastChannel === 'undefined') {
return ref(initialValue) as Ref<T>;
}
const channel = new BroadcastChannel(key);
let value = initialValue;
const data = customRef<T>((track, trigger) => {
channel.onmessage = (event: MessageEvent<T>) => {
value = event.data;
trigger();
};
return {
get() {
track();
return value;
},
set(newValue: T) {
value = newValue;
channel.postMessage(newValue);
trigger();
},
};
});
if (immediate) {
channel.postMessage(initialValue);
}
tryOnScopeDispose(() => channel.close());
return data;
}
@@ -0,0 +1,212 @@
import { describe, expect, it, vi } from 'vitest';
import { effectScope, isReadonly, nextTick, ref } from 'vue';
import { asyncComputed, computedAsync } from '.';
function flushPromises(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0));
}
describe(computedAsync, () => {
it('uses the initial state until the first evaluation resolves', async () => {
const value = computedAsync(async () => {
await flushPromises();
return 'resolved';
}, 'initial');
expect(value.value).toBe('initial');
await flushPromises();
await nextTick();
expect(value.value).toBe('resolved');
});
it('defaults to undefined when no initial state is given', () => {
const value = computedAsync(async () => 42);
expect(value.value).toBeUndefined();
});
it('re-evaluates when a reactive dependency changes', async () => {
const id = ref(1);
const spy = vi.fn(async () => `user-${id.value}`);
const value = computedAsync(spy, undefined);
await flushPromises();
await nextTick();
expect(value.value).toBe('user-1');
expect(spy).toHaveBeenCalledTimes(1);
id.value = 2;
await nextTick();
await flushPromises();
await nextTick();
expect(value.value).toBe('user-2');
expect(spy).toHaveBeenCalledTimes(2);
});
it('tracks pending state through the evaluating ref', async () => {
const evaluating = ref(false);
const value = computedAsync(async () => {
await flushPromises();
return 'done';
}, 'init', { evaluating });
// deferred to a microtask after the effect runs
await nextTick();
expect(evaluating.value).toBeTruthy();
expect(value.value).toBe('init');
await flushPromises();
await nextTick();
expect(evaluating.value).toBeFalsy();
expect(value.value).toBe('done');
});
it('accepts a bare Ref<boolean> as the evaluating ref', async () => {
const evaluating = ref(false);
const value = computedAsync(async () => {
await flushPromises();
return 1;
}, 0, evaluating);
await nextTick();
expect(evaluating.value).toBeTruthy();
await flushPromises();
await nextTick();
expect(evaluating.value).toBeFalsy();
expect(value.value).toBe(1);
});
it('invokes onError and keeps the previous value when the callback rejects', async () => {
const onError = vi.fn();
const fail = ref(false);
const value = computedAsync(async () => {
if (fail.value)
throw new Error('boom');
return 'ok';
}, 'init', { onError });
await flushPromises();
await nextTick();
expect(value.value).toBe('ok');
fail.value = true;
await nextTick();
await flushPromises();
await nextTick();
expect(onError).toHaveBeenCalledTimes(1);
expect((onError.mock.calls[0]![0] as Error).message).toBe('boom');
// value is retained on error
expect(value.value).toBe('ok');
});
it('does not throw or call onError by default when no handler is provided', async () => {
const value = computedAsync(async () => {
throw new Error('silent');
}, 'fallback');
await flushPromises();
await nextTick();
// default onError is noop; value stays at the initial state
expect(value.value).toBe('fallback');
});
it('does not evaluate until read when lazy', async () => {
const spy = vi.fn(async () => 'lazy-value');
const value = computedAsync(spy, 'pending', { lazy: true });
await flushPromises();
await nextTick();
expect(spy).not.toHaveBeenCalled();
// first read triggers evaluation
expect(value.value).toBe('pending');
await flushPromises();
await nextTick();
expect(spy).toHaveBeenCalledTimes(1);
expect(value.value).toBe('lazy-value');
});
it('returns a readonly computed when lazy', () => {
const value = computedAsync(async () => 1, 0, { lazy: true });
expect(isReadonly(value)).toBeTruthy();
});
it('discards out-of-order resolutions so only the latest run wins', async () => {
const delays = ref(0);
const value = computedAsync(async () => {
const wait = delays.value;
await new Promise(resolve => setTimeout(resolve, wait));
return wait;
}, -1);
// first run: slow (30ms)
delays.value = 30;
await nextTick();
// second run: fast (0ms) — triggered before the slow one settles
delays.value = 0;
await nextTick();
await new Promise(resolve => setTimeout(resolve, 50));
await nextTick();
// the fast (latest) run committed; the slow stale run was discarded
expect(value.value).toBe(0);
});
it('invokes the onCancel callback when a stale run is invalidated', async () => {
const cancelled = vi.fn();
const tick = ref(0);
computedAsync(async (onCancel) => {
void tick.value;
onCancel(cancelled);
await new Promise(resolve => setTimeout(resolve, 20));
return tick.value;
}, 0);
await nextTick();
// trigger a re-run before the first settles
tick.value = 1;
await nextTick();
await new Promise(resolve => setTimeout(resolve, 40));
expect(cancelled).toHaveBeenCalled();
});
it('supports a deep ref backing when shallow is false', async () => {
const value = computedAsync(async () => ({ nested: { count: 1 } }), undefined, { shallow: false });
await flushPromises();
await nextTick();
expect(value.value).toEqual({ nested: { count: 1 } });
});
it('stops evaluating when the owning scope is disposed', async () => {
const trigger = ref(0);
const spy = vi.fn(async () => trigger.value);
const scope = effectScope();
scope.run(() => {
computedAsync(spy, undefined);
});
await flushPromises();
await nextTick();
expect(spy).toHaveBeenCalledTimes(1);
scope.stop();
trigger.value = 1;
await nextTick();
await flushPromises();
// effect torn down with the scope; no further evaluation
expect(spy).toHaveBeenCalledTimes(1);
});
it('exposes asyncComputed as an alias of computedAsync', () => {
expect(asyncComputed).toBe(computedAsync);
});
});
@@ -0,0 +1,183 @@
import { noop } from '@robonen/stdlib';
import { computed, ref as deepRef, isRef, shallowRef, watchEffect } from 'vue';
import type { ComputedRef, Ref } from 'vue';
import type { ConfigurableFlush } from '@/types';
/**
* Handle overlapping async evaluations.
*
* The provided callback is invoked when a re-evaluation of the computed value
* is triggered before the previous one finished, letting you abort stale work.
*/
export type AsyncComputedOnCancel = (cancelCallback: () => void) => void;
export interface UseComputedAsyncOptions<Lazy = boolean> extends ConfigurableFlush {
/**
* Should the value be evaluated lazily, i.e. only on first read.
*
* @default false
*/
lazy?: Lazy;
/**
* Ref that receives the in-flight state of the async evaluation.
* `true` while the callback is pending, `false` once it settles.
*/
evaluating?: Ref<boolean>;
/**
* Use `shallowRef` instead of a deep `ref` to back the resolved value.
*
* @default true
*/
shallow?: boolean;
/**
* Callback invoked when the evaluation callback throws or rejects.
*
* @default noop
*/
onError?: (error: unknown) => void;
}
export type UseComputedAsyncReturn<T>
= Ref<T> | ComputedRef<T>;
/**
* @name computedAsync
* @category Reactivity
* @description Computed value driven by an async (promise-returning) evaluation
* callback. The value updates reactively when its dependencies change, exposing
* an optional `evaluating` ref for pending state, an `onError` handler, lazy
* evaluation, and a default value used until the first resolution settles.
* Out-of-order resolutions are discarded so only the latest run wins, and an
* `onCancel` hook lets callbacks abort stale work.
*
* @param {(onCancel: AsyncComputedOnCancel) => T | Promise<T>} evaluationCallback Promise-returning function producing the value
* @param {T} [initialState] Value used until the first evaluation resolves
* @param {UseComputedAsyncOptions | Ref<boolean>} [optionsOrRef] Options object, or a `Ref<boolean>` used as the `evaluating` ref
* @returns {Ref<T> | ComputedRef<T>} A ref holding the latest resolved value (a `ComputedRef` when `lazy`)
*
* @example
* const id = ref(1);
* const user = computedAsync(async () => {
* const res = await fetch(`/api/users/${id.value}`);
* return res.json();
* }, null);
*
* @example
* const evaluating = ref(false);
* const data = computedAsync(async () => fetchData(), [], { evaluating });
* // evaluating.value is true while the promise is pending
*
* @example
* // Abort stale requests when dependencies change mid-flight
* const result = computedAsync(async (onCancel) => {
* const controller = new AbortController();
* onCancel(() => controller.abort());
* const res = await fetch(url.value, { signal: controller.signal });
* return res.json();
* }, undefined, { lazy: true });
*
* @since 0.0.15
*/
export function computedAsync<T>(
evaluationCallback: (onCancel: AsyncComputedOnCancel) => T | Promise<T>,
initialState: T,
optionsOrRef: UseComputedAsyncOptions<true>,
): ComputedRef<T>;
export function computedAsync<T>(
evaluationCallback: (onCancel: AsyncComputedOnCancel) => T | Promise<T>,
initialState: undefined,
optionsOrRef: UseComputedAsyncOptions<true>,
): ComputedRef<T | undefined>;
export function computedAsync<T>(
evaluationCallback: (onCancel: AsyncComputedOnCancel) => T | Promise<T>,
initialState: T,
optionsOrRef?: Ref<boolean> | UseComputedAsyncOptions,
): Ref<T>;
export function computedAsync<T>(
evaluationCallback: (onCancel: AsyncComputedOnCancel) => T | Promise<T>,
initialState?: undefined,
optionsOrRef?: Ref<boolean> | UseComputedAsyncOptions,
): Ref<T | undefined>;
export function computedAsync<T>(
evaluationCallback: (onCancel: AsyncComputedOnCancel) => T | Promise<T>,
initialState?: T,
optionsOrRef?: Ref<boolean> | UseComputedAsyncOptions,
): UseComputedAsyncReturn<T> | UseComputedAsyncReturn<T | undefined> {
const options: UseComputedAsyncOptions = isRef(optionsOrRef)
? { evaluating: optionsOrRef }
: optionsOrRef ?? {};
const {
lazy = false,
flush = 'pre',
evaluating,
shallow = true,
onError = noop,
} = options;
const started = shallowRef(!lazy);
const current = (shallow ? shallowRef(initialState) : deepRef(initialState)) as Ref<T>;
let counter = 0;
watchEffect(async (onInvalidate) => {
if (!started.value)
return;
const runId = ++counter;
let hasFinished = false;
// Defer flipping `evaluating` to true so it is not tracked as a dependency
// of this effect (which would cause an infinite re-run loop).
if (evaluating) {
Promise.resolve().then(() => {
evaluating.value = true;
});
}
try {
const result = await evaluationCallback((cancelCallback) => {
onInvalidate(() => {
if (evaluating)
evaluating.value = false;
if (!hasFinished)
cancelCallback();
});
});
// Discard out-of-order resolutions: only the latest run commits.
if (runId === counter)
current.value = result;
}
catch (error) {
onError(error);
}
finally {
if (evaluating && runId === counter)
evaluating.value = false;
hasFinished = true;
}
}, { flush });
if (lazy) {
return computed(() => {
started.value = true;
return current.value;
});
}
return current;
}
/**
* @name asyncComputed
* @category Reactivity
* @description Alias for {@link computedAsync}.
*
* @since 0.0.15
*/
export const asyncComputed = computedAsync;
@@ -0,0 +1,132 @@
import { describe, expect, it, vi } from 'vitest';
import { computed, effectScope, isReadonly, nextTick, ref } from 'vue';
import { computedEager, eagerComputed } from '.';
describe(computedEager, () => {
it('computes the initial value eagerly', () => {
const count = ref(2);
const doubled = computedEager(() => count.value * 2);
expect(doubled.value).toBe(4);
});
it('updates synchronously when a dependency changes', () => {
const count = ref(0);
const isEven = computedEager(() => count.value % 2 === 0);
expect(isEven.value).toBeTruthy();
count.value = 1;
// no flush/tick needed with the default sync flush
expect(isEven.value).toBeFalsy();
count.value = 4;
expect(isEven.value).toBeTruthy();
});
it('tracks multiple reactive sources', () => {
const a = ref(1);
const b = ref(2);
const sum = computedEager(() => a.value + b.value);
expect(sum.value).toBe(3);
a.value = 10;
expect(sum.value).toBe(12);
b.value = 20;
expect(sum.value).toBe(30);
});
it('depends on other computed refs', () => {
const n = ref(3);
const squared = computed(() => n.value * n.value);
const label = computedEager(() => `value:${squared.value}`);
expect(label.value).toBe('value:9');
n.value = 4;
expect(label.value).toBe('value:16');
});
it('returns a readonly ref', () => {
const count = ref(0);
const derived = computedEager(() => count.value);
expect(isReadonly(derived)).toBeTruthy();
});
it('does not mutate when writing to the readonly ref (warns instead)', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const count = ref(5);
const derived = computedEager(() => count.value);
// @ts-expect-error: readonly ref must not be writable at the type level
derived.value = 99;
expect(derived.value).toBe(5);
warn.mockRestore();
});
it('is eager rather than lazy: getter runs without being read', () => {
const count = ref(0);
const spy = vi.fn(() => count.value);
computedEager(spy);
// eager: getter ran once on creation even though .value was never read
expect(spy).toHaveBeenCalledTimes(1);
count.value = 1;
// and again on dependency change, still without any read
expect(spy).toHaveBeenCalledTimes(2);
});
it('respects a custom flush timing', async () => {
const count = ref(0);
const derived = computedEager(() => count.value, { flush: 'post' });
// initial post-flush effect resolves after a tick
await nextTick();
expect(derived.value).toBe(0);
count.value = 1;
// post flush is deferred until after the next tick
expect(derived.value).toBe(0);
await nextTick();
expect(derived.value).toBe(1);
});
it('stops recomputing when the owning scope is disposed', () => {
const count = ref(0);
const scope = effectScope();
const derived = scope.run(() => computedEager(() => count.value))!;
count.value = 1;
expect(derived.value).toBe(1);
scope.stop();
count.value = 2;
// effect is torn down with the scope, so the value is frozen
expect(derived.value).toBe(1);
});
it('handles getters that return objects', () => {
const flag = ref(true);
const obj = computedEager(() => ({ ok: flag.value }));
expect(obj.value).toEqual({ ok: true });
flag.value = false;
expect(obj.value).toEqual({ ok: false });
});
it('exposes eagerComputed as an alias of computedEager', () => {
expect(eagerComputed).toBe(computedEager);
const count = ref(1);
const derived = eagerComputed(() => count.value + 1);
expect(derived.value).toBe(2);
count.value = 9;
expect(derived.value).toBe(10);
});
});
@@ -0,0 +1,52 @@
import { shallowReadonly, shallowRef, watchEffect } from 'vue';
import type { ShallowRef, WatchOptionsBase } from 'vue';
export type ComputedEagerOptions = Pick<WatchOptionsBase, 'flush' | 'onTrack' | 'onTrigger'>;
export type ComputedEagerReturn<T>
= Readonly<ShallowRef<T>>;
/**
* @name computedEager
* @category Reactivity
* @description Eager (non-lazy) computed value backed by a `watchEffect`-driven
* `shallowRef`. Unlike `computed`, the getter runs immediately and on every
* dependency change rather than lazily on read, so the cached value is always
* up to date. Best for cheap derived values that are read in many places.
*
* @param {() => T} getter The effect function deriving the value
* @param {ComputedEagerOptions} [options={}] Watch options (`flush` defaults to `'sync'`)
* @returns {Readonly<ShallowRef<T>>} A readonly shallow ref holding the derived value
*
* @example
* const count = ref(0);
* const isEven = computedEager(() => count.value % 2 === 0);
* isEven.value; // true
*
* @example
* // Defer recomputation until after the component update flush
* const total = computedEager(() => a.value + b.value, { flush: 'post' });
*
* @since 0.0.15
*/
export function computedEager<T>(getter: () => T, options: ComputedEagerOptions = {}): ComputedEagerReturn<T> {
const result = shallowRef<T>();
watchEffect(() => {
result.value = getter();
}, {
...options,
flush: options.flush ?? 'sync',
});
return shallowReadonly(result) as ComputedEagerReturn<T>;
}
/**
* @name eagerComputed
* @category Reactivity
* @description Alias for {@link computedEager}.
*
* @since 0.0.15
*/
export const eagerComputed = computedEager;
@@ -0,0 +1,273 @@
import { describe, expect, it, vi } from 'vitest';
import { computed, effectScope, isReadonly, isRef, nextTick, ref, watch } from 'vue';
import type { ComputedRefWithControl } from '.';
import { computedWithControl, controlledComputed } from '.';
describe(computedWithControl, () => {
it('is a ref that reflects the getter', () => {
const source = ref(1);
const result = computedWithControl(source, () => source.value * 2);
expect(isRef(result)).toBeTruthy();
expect(result.value).toBe(2);
});
it('only recomputes when the controlled source changes', () => {
const source = ref(1);
const unrelated = ref(10);
const getter = vi.fn(() => source.value + unrelated.value);
const result = computedWithControl(source, getter);
// first access computes lazily
expect(result.value).toBe(11);
expect(getter).toHaveBeenCalledTimes(1);
// reading again is cached
expect(result.value).toBe(11);
expect(getter).toHaveBeenCalledTimes(1);
// mutating an undeclared dependency does NOT recompute
unrelated.value = 20;
expect(result.value).toBe(11);
expect(getter).toHaveBeenCalledTimes(1);
// mutating the declared source recomputes (and now picks up unrelated too)
source.value = 2;
expect(result.value).toBe(22);
expect(getter).toHaveBeenCalledTimes(2);
});
it('is lazy: the getter does not run until first access', () => {
const getter = vi.fn(() => 42);
computedWithControl(ref(0), getter);
expect(getter).not.toHaveBeenCalled();
});
it('passes the previous value to the getter', () => {
const source = ref(0);
const seen: Array<number | undefined> = [];
const result = computedWithControl(source, (prev?: number) => {
seen.push(prev);
return source.value;
});
expect(result.value).toBe(0);
source.value = 5;
expect(result.value).toBe(5);
expect(seen).toEqual([undefined, 0]);
});
it('triggers reactive effects when the source changes', () => {
const scope = effectScope();
const source = ref(1);
const spy = vi.fn();
let result: ComputedRefWithControl<number>;
scope.run(() => {
result = computedWithControl(source, () => source.value * 2);
watch(result, spy, { flush: 'sync' });
});
source.value = 2;
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenLastCalledWith(4, 2, expect.anything());
scope.stop();
});
it('is reactive inside a downstream computed', () => {
const source = ref(2);
const controlled = computedWithControl(source, () => source.value);
const doubled = computed(() => controlled.value * 2);
expect(doubled.value).toBe(4);
source.value = 3;
expect(doubled.value).toBe(6);
});
describe('trigger', () => {
it('forces recomputation for sources Vue cannot track', () => {
let external = 0;
const result = computedWithControl(() => {}, () => external);
expect(result.value).toBe(0);
external = 10;
// not picked up automatically
expect(result.value).toBe(0);
result.trigger();
expect(result.value).toBe(10);
});
it('notifies subscribers', () => {
const scope = effectScope();
let external = 1;
const spy = vi.fn();
let result: ComputedRefWithControl<number>;
scope.run(() => {
result = computedWithControl(() => {}, () => external);
watch(result, spy, { flush: 'sync' });
});
external = 2;
result!.trigger();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenLastCalledWith(2, 1, expect.anything());
scope.stop();
});
});
describe('peek', () => {
it('reads the value without registering customRef tracking', () => {
const scope = effectScope();
// drive the value from an external (non-reactive) source so the only
// reactive dependency a tracked read could create is the customRef itself
let external = 0;
const result = computedWithControl(() => {}, () => external);
let reads = 0;
scope.run(() => {
watch(() => result.peek(), () => {
reads++;
}, { flush: 'sync' });
});
external = 1;
result.trigger();
// the outer watcher did NOT re-run, because peek() never tracked the ref
expect(reads).toBe(0);
expect(result.peek()).toBe(1);
scope.stop();
});
it('computes lazily on first peek without tracking', () => {
const source = ref(7);
const getter = vi.fn(() => source.value);
const result = computedWithControl(source, getter);
expect(result.peek()).toBe(7);
expect(getter).toHaveBeenCalledTimes(1);
});
});
describe('writable computed', () => {
it('supports get and set', () => {
const base = ref(1);
const doubled = computedWithControl(base, {
get: () => base.value * 2,
set: (v: number) => {
base.value = v / 2;
},
});
expect(doubled.value).toBe(2);
doubled.value = 10;
expect(base.value).toBe(5);
// source changed, so the controlled value recomputes
expect(doubled.value).toBe(10);
});
it('is not readonly when a setter is provided', () => {
const base = ref(1);
const writable = computedWithControl(base, {
get: () => base.value,
set: (v: number) => {
base.value = v;
},
});
expect(isReadonly(writable)).toBeFalsy();
});
});
describe('multiple sources', () => {
it('recomputes when any source in the array changes', () => {
const a = ref(1);
const b = ref(2);
const getter = vi.fn(() => a.value + b.value);
const result = computedWithControl([a, b], getter);
expect(result.value).toBe(3);
expect(getter).toHaveBeenCalledTimes(1);
a.value = 10;
expect(result.value).toBe(12);
b.value = 20;
expect(result.value).toBe(30);
expect(getter).toHaveBeenCalledTimes(3);
});
});
describe('stop', () => {
it('detaches the source watcher', () => {
const source = ref(1);
const getter = vi.fn(() => source.value);
const result = computedWithControl(source, getter);
expect(result.value).toBe(1);
result.stop();
source.value = 5;
// watcher gone, so no recompute happens
expect(result.value).toBe(1);
expect(getter).toHaveBeenCalledTimes(1);
// manual trigger still works
result.trigger();
expect(result.value).toBe(5);
});
});
it('forwards custom watch options (flush: post)', async () => {
const scope = effectScope();
const source = ref(0);
const spy = vi.fn();
let result: ComputedRefWithControl<number>;
scope.run(() => {
result = computedWithControl(source, () => source.value, { flush: 'post' });
watch(result, spy, { flush: 'post' });
});
source.value = 1;
expect(spy).not.toHaveBeenCalled();
await nextTick();
expect(result!.value).toBe(1);
expect(spy).toHaveBeenCalledTimes(1);
scope.stop();
});
it('exposes controlledComputed as an alias', () => {
expect(controlledComputed).toBe(computedWithControl);
const source = ref('a');
const result = controlledComputed(source, () => source.value.toUpperCase());
expect(result.value).toBe('A');
source.value = 'b';
expect(result.value).toBe('B');
});
it('runs without an active effect scope (SSR-style, no host component)', () => {
// No effectScope / no component: the customRef + sync watcher must still
// work, and reading must not throw even though there are no subscribers.
let external = 1;
const result = computedWithControl(() => {}, () => external);
expect(result.value).toBe(1);
external = 2;
result.trigger();
expect(result.value).toBe(2);
expect(result.peek()).toBe(2);
});
});
@@ -0,0 +1,162 @@
import { customRef, watch } from 'vue';
import type {
ComputedGetter,
ComputedRef,
WatchOptions,
WatchSource,
WatchStopHandle,
WritableComputedOptions,
WritableComputedRef,
} from 'vue';
type MultiWatchSources = Array<WatchSource<unknown> | object>;
export interface ComputedWithControlExtra<T> {
/**
* Force the computed value to recompute on next access and notify subscribers.
*/
trigger: () => void;
/**
* Read the current value without recomputing or registering reactive tracking.
*
* Returns the last cached value. If the value has never been computed it is
* computed once (lazily) without tracking.
*/
peek: () => T;
/**
* Stop watching the controlled dependency source.
*
* After calling this the value only updates via {@link ComputedWithControlExtra.trigger} or a manual set.
*/
stop: WatchStopHandle;
}
export type ComputedRefWithControl<T>
= ComputedRef<T> & ComputedWithControlExtra<T>;
export type WritableComputedRefWithControl<T>
= WritableComputedRef<T> & ComputedWithControlExtra<T>;
export type ComputedWithControlRef<T>
= ComputedRefWithControl<T> | WritableComputedRefWithControl<T>;
/**
* @name computedWithControl
* @category Reactivity
* @description A computed ref whose recomputation is driven only by an
* explicitly declared dependency `source`, plus a manual `.trigger()`. Built on
* `customRef` with a single `flush: 'sync'` watcher and a lazy `dirty` flag, so
* the getter is cached and only re-runs when the source changes or you trigger
* it — never on unrelated reactive reads. Also exposes `.peek()` (untracked
* read) and `.stop()` (detach the source watcher).
*
* @param {WatchSource | MultiWatchSources} source The dependency (or array of dependencies) that controls recomputation
* @param {ComputedGetter<T> | WritableComputedOptions<T>} fn A getter, or a `{ get, set }` object for a writable computed
* @param {WatchOptions} [options={}] Watch options forwarded to the internal watcher (`flush` defaults to `'sync'`)
* @returns {ComputedWithControlRef<T>} A computed ref extended with `trigger`/`peek`/`stop`
*
* @example
* const source = ref(0);
* const unrelated = ref('a');
* // only recomputes when `source` changes, not when `unrelated` does
* const result = computedWithControl(source, () => source.value + unrelated.value.length);
*
* @example
* // manual control: recompute on demand
* let counter = 0;
* const value = computedWithControl(() => {}, () => counter);
* counter = 10;
* value.trigger(); // value.value is now 10
*
* @example
* // writable computed with controlled dependency
* const base = ref(1);
* const doubled = computedWithControl(base, {
* get: () => base.value * 2,
* set: (v) => { base.value = v / 2; },
* });
*
* @since 0.0.15
*/
export function computedWithControl<T>(
source: WatchSource | MultiWatchSources,
fn: ComputedGetter<T>,
options?: WatchOptions,
): ComputedRefWithControl<T>;
export function computedWithControl<T>(
source: WatchSource | MultiWatchSources,
fn: WritableComputedOptions<T>,
options?: WatchOptions,
): WritableComputedRefWithControl<T>;
export function computedWithControl<T>(
source: WatchSource | MultiWatchSources,
fn: ComputedGetter<T> | WritableComputedOptions<T>,
options: WatchOptions = {},
): ComputedWithControlRef<T> {
let value: T = undefined!;
let dirty = true;
let track: () => void = noopTrack;
let trigger: () => void = noopTrigger;
const get = isGetter(fn) ? fn : fn.get;
const set = isGetter(fn) ? undefined : fn.set;
function compute(): T {
if (dirty) {
value = get(value);
dirty = false;
}
return value;
}
function update(): void {
dirty = true;
trigger();
}
const stop = watch(source, update, { flush: 'sync', ...options });
const result = customRef<T>((_track, _trigger) => {
track = _track;
trigger = _trigger;
return {
get() {
const next = compute();
track();
return next;
},
set(incoming) {
set?.(incoming);
},
};
}) as ComputedWithControlRef<T>;
result.trigger = update;
result.peek = compute;
result.stop = stop;
return result;
}
function isGetter<T>(
fn: ComputedGetter<T> | WritableComputedOptions<T>,
): fn is ComputedGetter<T> {
return typeof fn === 'function';
}
function noopTrack(): void {}
function noopTrigger(): void {}
/**
* @name controlledComputed
* @category Reactivity
* @description Alias of {@link computedWithControl}.
*
* @since 0.0.15
*/
export const controlledComputed = computedWithControl;
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import { computed, isRef, ref } from 'vue';
import type { Ref } from 'vue';
import { extendRef } from '.';
describe(extendRef, () => {
it('returns the same ref instance', () => {
const source = ref('content');
const extended = extendRef(source, { foo: 'bar' });
expect(extended).toBe(source);
expect(isRef(extended)).toBeTruthy();
});
it('keeps the ref value accessible and writable', () => {
const extended = extendRef(ref('content'), { foo: 'bar' });
expect(extended.value).toBe('content');
extended.value = 'updated';
expect(extended.value).toBe('updated');
});
it('attaches static (non-ref) properties', () => {
const extended = extendRef(ref(0), { foo: 'bar', n: 1 });
expect(extended.foo).toBe('bar');
expect(extended.n).toBe(1);
});
it('never overwrites the ref value via a "value" key in extend', () => {
const extended = extendRef(ref('keep'), { value: 'overwrite' });
expect(extended.value).toBe('keep');
});
it('unwraps ref-valued properties by default (read)', () => {
const count = ref(0);
const extended = extendRef(count, { double: computed(() => count.value * 2) });
expect(extended.double).toBe(0);
count.value = 4;
expect(extended.double).toBe(8);
});
it('unwraps ref-valued properties with two-way write', () => {
const inner = ref(1);
// `unwrap` (default true) auto-`.value`s the property at runtime, so it reads/writes as a number.
const extended = extendRef(ref(0), { inner }) as unknown as Ref<number> & { inner: number };
expect(extended.inner).toBe(1);
extended.inner = 5;
expect(inner.value).toBe(5);
expect(extended.inner).toBe(5);
});
it('keeps refs as refs when unwrap is false', () => {
const inner = ref(1);
// With `unwrap: false` the property stays a real ref at runtime.
const extended = extendRef(ref(0), { inner }, { unwrap: false }) as unknown as Ref<number> & { inner: Ref<number> };
expect(isRef(extended.inner)).toBeTruthy();
expect(extended.inner.value).toBe(1);
inner.value = 2;
expect(extended.inner.value).toBe(2);
});
it('extended properties are non-enumerable by default', () => {
const extended = extendRef(ref(0), { foo: 'bar' });
expect(Object.keys(extended)).not.toContain('foo');
expect(extended.foo).toBe('bar');
});
it('extended properties become enumerable with enumerable: true', () => {
const extended = extendRef(ref(0), { foo: 'bar' }, { enumerable: true });
expect(Object.keys(extended)).toContain('foo');
});
it('enumerable applies to unwrapped ref properties too', () => {
const extended = extendRef(ref(0), { inner: ref(1) }, { enumerable: true });
expect(Object.keys(extended)).toContain('inner');
expect(extended.inner).toBe(1);
});
it('supports multiple properties of mixed kinds', () => {
const r = ref(2);
const extended = extendRef(ref('x'), {
label: 'tag',
live: r,
frozen: 99,
});
expect(extended.label).toBe('tag');
expect(extended.live).toBe(2);
expect(extended.frozen).toBe(99);
r.value = 3;
expect(extended.live).toBe(3);
});
it('remains reactive after extension (value tracked by computed)', () => {
const source = ref(1);
const extended = extendRef(source, { meta: 'm' });
const derived = computed(() => extended.value * 10);
expect(derived.value).toBe(10);
extended.value = 5;
expect(derived.value).toBe(50);
});
});
@@ -0,0 +1,90 @@
import { isRef } from 'vue';
import type { Ref, ShallowUnwrapRef } from 'vue';
export interface ExtendRefOptions<Unwrap extends boolean = boolean> {
/**
* Whether the extended properties are enumerable.
*
* @default false
*/
enumerable?: boolean;
/**
* Whether to unwrap (auto-`.value`) extended properties that are themselves refs.
*
* @default true
*/
unwrap?: Unwrap;
}
export type ExtendRefReturn<R extends Ref<unknown>, Extend extends object, Unwrap extends boolean>
= Unwrap extends false ? (R & ShallowUnwrapRef<Extend>) : (R & Extend);
/**
* @name extendRef
* @category Reactivity
* @description Attach extra (optionally reactive) attributes to a ref while keeping it a usable ref.
*
* @param {Ref<T>} ref The ref to extend
* @param {object} extend The properties to attach; ref-valued props are unwrapped by default
* @param {ExtendRefOptions} [options={}] `enumerable` (default `false`) and `unwrap` (default `true`)
* @returns {Ref<T> & Extend} The same ref instance, now carrying the extended properties
*
* @example
* const myRef = ref('content');
* const extended = extendRef(myRef, { foo: 'bar' });
* extended.value; // 'content'
* extended.foo; // 'bar'
*
* @example
* // reactive extension: unwrapped two-way by default
* const count = ref(0);
* const extended = extendRef(count, { double: computed(() => count.value * 2) });
* extended.double; // 0 (no .value needed)
*
* @example
* // keep refs as refs with unwrap: false
* const extended = extendRef(ref(0), { inner: ref(1) }, { unwrap: false });
* extended.inner.value; // 1
*
* @since 0.0.15
*/
export function extendRef<R extends Ref<unknown>, Extend extends object, Options extends ExtendRefOptions<false>>(ref: R, extend: Extend, options: Options): R & ShallowUnwrapRef<Extend>;
export function extendRef<R extends Ref<unknown>, Extend extends object, Options extends ExtendRefOptions>(ref: R, extend: Extend, options?: Options): R & Extend;
export function extendRef<R extends Ref<unknown>, Extend extends object>(
ref: R,
extend: Extend,
options: ExtendRefOptions = {},
): R & Extend {
const { enumerable = false, unwrap = true } = options;
for (const key in extend) {
if (key === 'value')
continue;
const value = extend[key];
if (unwrap && isRef(value)) {
Object.defineProperty(ref, key, {
get() {
return value.value;
},
set(v) {
value.value = v;
},
enumerable,
configurable: true,
});
}
else {
Object.defineProperty(ref, key, {
value,
enumerable,
configurable: true,
writable: true,
});
}
}
return ref as R & Extend;
}
+13 -13
View File
@@ -1,22 +1,22 @@
export * from './broadcastedRef';
export * from './computedAsync';
export * from './computedEager';
export * from './computedWithControl';
export * from './extendRef';
export * from './reactiveComputed';
export * from './reactiveOmit';
export * from './reactivePick';
export * from './refAutoReset';
export * from './refDebounced';
export * from './refDefault';
export * from './refThrottled';
export * from './until';
export * from './useArrayFilter';
export * from './useArrayFind';
export * from './useArrayMap';
export * from './refWithControl';
export * from './syncRef';
export * from './toReactive';
export * from './useCached';
export * from './useCloned';
export * from './useCycleList';
export * from './useLastChanged';
export * from './useDebounceFn';
export * from './usePrevious';
export * from './useSyncRefs';
export * from './useThrottleFn';
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,159 @@
import { describe, expect, it, vi } from 'vitest';
import { effectScope, isReactive, nextTick, ref, toRefs, watch } from 'vue';
import { reactiveComputed } from '.';
describe(reactiveComputed, () => {
it('returns a reactive object backed by the getter', () => {
const count = ref(1);
const state = reactiveComputed(() => ({
foo: count.value,
bar: count.value * 2,
}));
expect(isReactive(state)).toBeTruthy();
expect(state.foo).toBe(1);
expect(state.bar).toBe(2);
});
it('updates field values when a dependency changes', async () => {
const count = ref(1);
const state = reactiveComputed(() => ({
doubled: count.value * 2,
}));
expect(state.doubled).toBe(2);
count.value = 5;
await nextTick();
expect(state.doubled).toBe(10);
});
it('only re-runs the getter when a dependency changes (cached)', () => {
const count = ref(1);
const getter = vi.fn(() => ({ value: count.value }));
const state = reactiveComputed(getter);
// Multiple reads of the same / different keys without a dep change
// should not re-invoke the getter beyond the first lazy evaluation.
void state.value;
void state.value;
void state.value;
expect(getter).toHaveBeenCalledTimes(1);
count.value = 2;
void state.value;
expect(getter).toHaveBeenCalledTimes(2);
});
it('tracks individual fields independently', async () => {
const a = ref(0);
const b = ref(0);
const state = reactiveComputed(() => ({
a: a.value,
b: b.value,
}));
const spyA = vi.fn();
const scope = effectScope();
scope.run(() => {
watch(() => state.a, spyA);
});
// Changing `b` should not trigger a watcher on `a`.
b.value = 1;
await nextTick();
expect(spyA).not.toHaveBeenCalled();
a.value = 1;
await nextTick();
expect(spyA).toHaveBeenCalledTimes(1);
scope.stop();
});
it('unwraps nested refs returned by the getter', () => {
const name = ref('alice');
const state = reactiveComputed(() => ({ name }));
expect(state.name).toBe('alice');
});
it('writes a raw value through to an underlying ref', async () => {
const name = ref('alice');
const state = reactiveComputed(() => ({ name }));
state.name = 'bob';
expect(name.value).toBe('bob');
await nextTick();
expect(state.name).toBe('bob');
});
it('writes plain (non-ref) fields back onto the computed value', () => {
const state = reactiveComputed<{ count: number }>(() => ({ count: 1 }));
state.count = 42;
expect(state.count).toBe(42);
});
it('supports the `in` operator via the has trap', () => {
const state = reactiveComputed(() => ({ foo: 1 }));
expect('foo' in state).toBeTruthy();
expect('missing' in state).toBeFalsy();
});
it('enumerates own keys', () => {
const state = reactiveComputed(() => ({ a: 1, b: 2, c: 3 }));
expect(Object.keys(state).sort()).toEqual(['a', 'b', 'c']);
});
it('supports property deletion', () => {
const state = reactiveComputed<{ a?: number; b: number }>(() => ({ a: 1, b: 2 }));
delete state.a;
expect('a' in state).toBeFalsy();
expect('b' in state).toBeTruthy();
});
it('works with toRefs while preserving reactivity', async () => {
const count = ref(1);
const state = reactiveComputed(() => ({ doubled: count.value * 2 }));
const { doubled } = toRefs(state);
expect(doubled.value).toBe(2);
count.value = 3;
await nextTick();
expect(doubled.value).toBe(6);
});
it('is observable by a deep watcher', async () => {
const count = ref(1);
const state = reactiveComputed(() => ({ count: count.value }));
const spy = vi.fn();
const scope = effectScope();
scope.run(() => {
watch(state, spy, { deep: true });
});
count.value = 2;
await nextTick();
expect(spy).toHaveBeenCalledTimes(1);
scope.stop();
});
it('handles getters returning nested object structures', async () => {
const open = ref(false);
const state = reactiveComputed(() => ({
ui: {
open: open.value,
},
}));
expect(state.ui.open).toBeFalsy();
open.value = true;
await nextTick();
expect(state.ui.open).toBeTruthy();
});
});
@@ -0,0 +1,82 @@
import { computed, isRef, reactive, unref } from 'vue';
import type { ComputedGetter, UnwrapNestedRefs } from 'vue';
export type ReactiveComputedReturn<T extends object>
= UnwrapNestedRefs<T>;
/**
* @name reactiveComputed
* @category Reactivity
* @description Computed that resolves to a reactive object whose individual
* fields stay reactive — read a single property and only that property is
* tracked, instead of the whole getter re-running on every access.
*
* The getter is wrapped in a single cached `computed`, so the object is
* recomputed only when one of its reactive dependencies changes. The returned
* value is a `reactive` proxy over that computed: destructuring with `toRefs`,
* spreading and writing back individual fields all work as on a normal
* `reactive` object.
*
* @param {ComputedGetter<T>} getter Factory returning the object to expose reactively
* @returns {ReactiveComputedReturn<T>} A reactive object backed by the cached computed
*
* @example
* const state = reactiveComputed(() => ({
* foo: count.value,
* bar: count.value * 2,
* }));
* // reading state.bar only depends on `bar`
*
* @example
* // nested refs returned by the getter are unwrapped and kept writable
* const name = ref('a');
* const obj = reactiveComputed(() => ({ name }));
* obj.name; // 'a'
* obj.name = 'b'; // writes through to name.value
*
* @since 0.0.15
*/
export function reactiveComputed<T extends object>(
getter: ComputedGetter<T>,
): ReactiveComputedReturn<T> {
const source = computed<T>(getter);
// A Proxy over the computed's current value: each trap reads
// `source.value` lazily, so only the accessed key is tracked and the proxy
// itself is created exactly once (no re-creation per recompute).
const proxy = new Proxy({} as T, {
get(_, key, receiver) {
return unref(Reflect.get(source.value, key, receiver));
},
set(_, key, value) {
const current = source.value as Record<PropertyKey, unknown>;
const existing = current[key];
// Preserve ref identity: assigning a raw value to a field that holds a
// ref writes through to `.value` instead of clobbering the ref.
if (isRef(existing) && !isRef(value))
existing.value = value;
else
current[key] = value;
return true;
},
deleteProperty(_, key) {
return Reflect.deleteProperty(source.value, key);
},
has(_, key) {
return Reflect.has(source.value, key);
},
ownKeys() {
return Reflect.ownKeys(source.value);
},
getOwnPropertyDescriptor() {
return {
enumerable: true,
configurable: true,
};
},
});
return reactive(proxy) as ReactiveComputedReturn<T>;
}
@@ -0,0 +1,144 @@
import { describe, expect, it, vi } from 'vitest';
import { computed, effectScope, nextTick, reactive, ref, watch } from 'vue';
import { reactiveOmit } from '.';
describe(reactiveOmit, () => {
it('omits a single key', () => {
const source = reactive({ name: 'a', count: 1, hidden: true });
const result = reactiveOmit(source, 'hidden');
expect({ ...result }).toEqual({ name: 'a', count: 1 });
});
it('omits multiple keys passed variadically', () => {
const source = reactive({ a: 1, b: 2, c: 3, d: 4 });
const result = reactiveOmit(source, 'a', 'c');
expect({ ...result }).toEqual({ b: 2, d: 4 });
});
it('omits keys passed as an array', () => {
const source = reactive({ a: 1, b: 2, c: 3 });
const result = reactiveOmit(source, ['a', 'b']);
expect({ ...result }).toEqual({ c: 3 });
});
it('omits a mix of single keys and arrays', () => {
const source = reactive({ a: 1, b: 2, c: 3, d: 4 });
const result = reactiveOmit(source, 'a', ['c', 'd']);
expect({ ...result }).toEqual({ b: 2 });
});
it('returns a shallow copy of all fields when no keys are given', () => {
const source = reactive({ a: 1, b: 2 });
const result = reactiveOmit(source);
expect({ ...result }).toEqual({ a: 1, b: 2 });
});
it('reacts to source mutations', async () => {
const source = reactive({ a: 1, b: 2, c: 3 });
const result = reactiveOmit(source, 'c');
expect(result.a).toBe(1);
source.a = 10;
await nextTick();
expect(result.a).toBe(10);
expect({ ...result }).toEqual({ a: 10, b: 2 });
});
it('reflects keys added to the source after creation', async () => {
const source = reactive<Record<string, number>>({ a: 1 });
const result = reactiveOmit(source, 'a');
expect({ ...result }).toEqual({});
source.b = 2;
await nextTick();
expect({ ...result }).toEqual({ b: 2 });
});
it('supports a predicate dropping fields by value', () => {
const source = reactive({ name: 'a', count: 1, enabled: true });
const result = reactiveOmit(source, value => typeof value === 'boolean');
expect({ ...result }).toEqual({ name: 'a', count: 1 });
});
it('supports a predicate dropping fields by key', () => {
const source = reactive({ id: 1, _internal: 2, label: 'x' });
const result = reactiveOmit(source, (_value, key) => key.startsWith('_'));
expect({ ...result }).toEqual({ id: 1, label: 'x' });
});
it('re-evaluates the predicate reactively', async () => {
const source = reactive({ a: 1, b: -2, c: 3 });
const result = reactiveOmit(source, value => (value as number) < 0);
expect({ ...result }).toEqual({ a: 1, c: 3 });
source.a = -1;
await nextTick();
expect({ ...result }).toEqual({ c: 3 });
});
it('unwraps refs held on the source object', () => {
const count = ref(5);
const source = reactive({ count, label: 'x' });
const result = reactiveOmit(source, 'label');
expect(result.count).toBe(5);
});
it('tracks only the accessed field (granular reactivity)', async () => {
const source = reactive({ a: 1, b: 2, c: 3 });
const result = reactiveOmit(source, 'c');
const spy = vi.fn();
const scope = effectScope();
scope.run(() => {
watch(() => result.a, spy);
});
// Mutating an unwatched field must not trigger the `a` watcher.
source.b = 20;
await nextTick();
expect(spy).not.toHaveBeenCalled();
source.a = 10;
await nextTick();
expect(spy).toHaveBeenCalledTimes(1);
scope.stop();
});
it('does not re-run the getter for an untouched dependency (cached computed)', async () => {
const source = reactive({ a: 1, b: 2 });
const getter = vi.fn(() => source.a);
const tracked = computed(getter);
const result = reactiveOmit(reactive({ value: tracked, other: 99 }), 'other');
expect(result.value).toBe(1);
expect(getter).toHaveBeenCalledTimes(1);
// Reading again without changing the dependency does not re-run the getter.
expect(result.value).toBe(1);
expect(getter).toHaveBeenCalledTimes(1);
source.a = 2;
await nextTick();
expect(result.value).toBe(2);
});
it('works on a plain (non-reactive) source object', () => {
const result = reactiveOmit({ a: 1, b: 2, c: 3 }, 'b');
expect({ ...result }).toEqual({ a: 1, c: 3 });
});
it('is SSR-safe (no DOM globals touched, runs without window)', () => {
const original = globalThis.window;
// @ts-expect-error force a non-DOM environment for the duration of the call
delete globalThis.window;
try {
const source = reactive({ a: 1, secret: 2 });
const result = reactiveOmit(source, 'secret');
expect({ ...result }).toEqual({ a: 1 });
}
finally {
globalThis.window = original;
}
});
});
@@ -0,0 +1,101 @@
import { toValue } from 'vue';
import { omit } from '@robonen/stdlib';
import { reactiveComputed } from '@/composables/reactivity/reactiveComputed';
/**
* Resolved type of `reactiveOmit`: a reactive object that drops either the
* listed keys (`Omit`) or — when a predicate is used — an arbitrary subset
* (`Partial`), since the kept keys are only known at runtime.
*/
export type ReactiveOmitReturn<
T extends object,
K extends keyof T | undefined = undefined,
>
= [K] extends [undefined]
? Partial<T>
: Omit<T, Extract<K, keyof T>>;
/**
* Predicate deciding, per field, whether a key should be omitted.
* Return `true` to drop the field.
*/
export type ReactiveOmitPredicate<T extends object>
= (value: T[keyof T], key: keyof T) => boolean;
export function reactiveOmit<T extends object, K extends keyof T>(
obj: T,
...keys: Array<K | K[]>
): ReactiveOmitReturn<T, K>;
export function reactiveOmit<T extends object>(
obj: T,
predicate: ReactiveOmitPredicate<T>,
): ReactiveOmitReturn<T>;
/**
* @name reactiveOmit
* @category Reactivity
* @description Reactively omit keys from a reactive object or ref. Accepts a
* variadic list of keys (mixing single keys and key arrays) or a predicate that
* decides per field whether to drop it. The result is a reactive object backed
* by a single cached `computed`, so reading one field tracks only that field and
* the underlying selection is recomputed only when a dependency changes.
*
* Keys are removed via the stdlib `omit`, which builds the result without
* `delete` (avoiding V8 dictionary-mode deopts on the kept object).
*
* @param {T} obj The source reactive object (or ref-bearing object) to omit from
* @param {...(K | K[])[] | [ReactiveOmitPredicate<T>]} keys Keys to drop (single or arrays), or a single predicate
* @returns {ReactiveOmitReturn<T, K>} A reactive object without the omitted fields
*
* @example
* const state = reactive({ name: 'a', count: 1, hidden: true });
* const visible = reactiveOmit(state, 'hidden');
* visible; // reactive { name: 'a', count: 1 }
*
* @example
* // mix single keys and arrays
* const slim = reactiveOmit(state, 'hidden', ['count']);
*
* @example
* // predicate: drop every boolean field
* const noFlags = reactiveOmit(state, (value) => typeof value === 'boolean');
*
* @since 0.0.15
*/
export function reactiveOmit<T extends object, K extends keyof T>(
obj: T,
...keys: Array<K | K[] | ReactiveOmitPredicate<T>>
): ReactiveOmitReturn<T, K> {
const first = keys[0];
const predicate = typeof first === 'function'
? first as ReactiveOmitPredicate<T>
: undefined;
// Flatten the variadic key list once, outside the reactive getter, so the
// recompute below does not re-flatten on every dependency change.
const flatKeys: K[] = predicate
? []
: (keys as Array<K | K[]>).flat() as K[];
if (predicate) {
return reactiveComputed<Partial<T>>(() => {
const source = toValue(obj) as T;
const result = {} as Partial<T>;
for (const key in source) {
if (!Object.hasOwn(source, key))
continue;
const value = source[key];
if (!predicate(value as T[keyof T], key as unknown as keyof T))
result[key] = value;
}
return result;
}) as ReactiveOmitReturn<T, K>;
}
return reactiveComputed<Omit<T, K>>(
() => omit<T, K>(toValue(obj) as T, flatKeys),
) as ReactiveOmitReturn<T, K>;
}
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest';
import { computed, effectScope, isReactive, nextTick, reactive, ref, watch } from 'vue';
import { reactivePick } from '.';
describe(reactivePick, () => {
it('picks the listed keys from a reactive object', () => {
const state = reactive({ x: 1, y: 2, z: 3 });
const picked = reactivePick(state, 'x', 'y');
expect(picked.x).toBe(1);
expect(picked.y).toBe(2);
expect((picked as Record<string, unknown>).z).toBeUndefined();
});
it('returns a reactive proxy', () => {
const picked = reactivePick(reactive({ a: 1 }), 'a');
expect(isReactive(picked)).toBeTruthy();
});
it('accepts an array of keys', () => {
const state = reactive({ a: 1, b: 2, c: 3 });
const picked = reactivePick(state, ['a', 'c']);
expect(Object.keys(picked).sort()).toEqual(['a', 'c']);
});
it('accepts mixed spread keys and arrays', () => {
const state = reactive({ a: 1, b: 2, c: 3, d: 4 });
const picked = reactivePick(state, 'a', ['b', 'c']);
expect(Object.keys(picked).sort()).toEqual(['a', 'b', 'c']);
});
it('stays in sync with source mutations', () => {
const state = reactive({ x: 1, y: 2 });
const picked = reactivePick(state, 'x');
state.x = 99;
expect(picked.x).toBe(99);
});
it('writes pass back to the source', () => {
const state = reactive({ x: 1, y: 2 });
const picked = reactivePick(state, 'x');
picked.x = 42;
expect(state.x).toBe(42);
});
it('ignores writes to keys that were not picked', () => {
const state = reactive({ x: 1, y: 2 });
const picked = reactivePick(state, 'x') as Record<string, number>;
picked.y = 100;
expect(state.y).toBe(2);
});
it('supports the in operator (has trap)', () => {
const state = reactive({ x: 1, y: 2 });
const picked = reactivePick(state, 'x');
expect('x' in picked).toBeTruthy();
expect('y' in picked).toBeFalsy();
});
it('enumerates only the picked own keys', () => {
const state = reactive({ a: 1, b: 2, c: 3 });
const picked = reactivePick(state, 'a', 'b');
expect(Object.keys(picked).sort()).toEqual(['a', 'b']);
});
it('spreads only the picked enumerable properties', () => {
const state = reactive({ a: 1, b: 2, c: 3 });
const picked = reactivePick(state, 'a', 'c');
expect({ ...picked }).toEqual({ a: 1, c: 3 });
});
it('is reactive inside an effect', async () => {
const state = reactive({ x: 0, y: 0 });
const picked = reactivePick(state, 'x');
const seen: number[] = [];
const scope = effectScope();
scope.run(() => {
watch(() => picked.x, value => seen.push(value));
});
state.x = 1;
await nextTick();
picked.x = 2;
await nextTick();
expect(seen).toEqual([1, 2]);
scope.stop();
});
it('works as a computed source', () => {
const state = reactive({ first: 'John', last: 'Doe', age: 30 });
const picked = reactivePick(state, 'first', 'last');
const full = computed(() => `${picked.first} ${picked.last}`);
expect(full.value).toBe('John Doe');
state.first = 'Jane';
expect(full.value).toBe('Jane Doe');
});
it('unwraps nested refs on read', () => {
const inner = ref(1);
const state = reactive({ inner, other: 2 });
const picked = reactivePick(state, 'inner');
expect(picked.inner as unknown).toBe(1);
inner.value = 5;
expect(picked.inner as unknown).toBe(5);
});
describe('predicate form', () => {
it('keeps only keys matched by the predicate', () => {
const state = reactive({ a: 1, b: 'x', c: 3 });
const picked = reactivePick(state, value => typeof value === 'number');
expect(Object.keys(picked).sort()).toEqual(['a', 'c']);
});
it('passes the key as the second predicate argument', () => {
const state = reactive({ keepMe: 1, dropMe: 2 });
const picked = reactivePick(state, (_value, key) => key === 'keepMe');
expect(Object.keys(picked)).toEqual(['keepMe']);
});
it('re-evaluates membership reactively as values change', () => {
const state = reactive<{ a: number | string; b: number }>({ a: 1, b: 2 });
const picked = reactivePick(state, value => typeof value === 'number');
expect(Object.keys(picked).sort()).toEqual(['a', 'b']);
state.a = 'now a string';
expect(Object.keys(picked)).toEqual(['b']);
});
it('reads picked values through the predicate proxy', () => {
const state = reactive({ a: 1, b: 2, c: 3 });
const picked = reactivePick(state, value => value > 1);
expect((picked as Record<string, number>).b).toBe(2);
expect((picked as Record<string, number>).a).toBeUndefined();
});
});
it('works with a ref-of-object value passed via toValue resolution on reads', () => {
// Source is a plain reactive whose property holds a ref — covers unwrap path.
const flag = ref(true);
const state = reactive({ flag, count: 1 });
const picked = reactivePick(state, 'flag', 'count');
expect(picked.flag as unknown).toBeTruthy();
expect(picked.count).toBe(1);
});
});
@@ -0,0 +1,113 @@
import { reactive, toValue } from 'vue';
import type { UnwrapRef } from 'vue';
import { isFunction } from '@robonen/stdlib';
/**
* The reactive object produced by {@link reactivePick}: a subset of `T`
* restricted to the picked keys `K`, with each value unwrapped.
*/
export type ReactivePickReturn<T extends object, K extends keyof T>
= { [Key in K]: UnwrapRef<T[Key]> };
/**
* Predicate form: receive each `(value, key)` pair of the source object and
* return `true` to keep the key in the resulting reactive view.
*/
export type ReactivePickPredicate<T extends object>
= (value: T[keyof T], key: keyof T) => boolean;
/**
* @name reactivePick
* @category Reactivity
* @description Reactively pick a subset of keys (or keys matched by a
* predicate) from a reactive object. The result is a live `reactive` proxy:
* reads forward to the source's current value (so it tracks reassignment of
* nested refs) and writes pass straight back to the source. Unlike a
* `computed` of an object literal, no new object is allocated per recompute —
* the proxy is built once and lookups are resolved lazily on access.
*
* @param {T} obj The reactive source object (a `reactive`, `ref` value, or plain object)
* @param {...((K | K[]))} keys One or more keys (or arrays of keys) to pick
* @returns {ReactivePickReturn<T, K>} A reactive view limited to the picked keys
*
* @example
* const state = reactive({ x: 1, y: 2, z: 3 });
* const picked = reactivePick(state, 'x', 'y');
* picked.x; // 1 — stays in sync with state.x
* picked.x = 10; // writes back: state.x === 10
*
* @example
* // predicate form — keep only numeric values
* const filtered = reactivePick(state, (value) => typeof value === 'number');
*
* @since 0.0.15
*/
export function reactivePick<T extends object, K extends keyof T>(
obj: T,
...keys: Array<K | K[]>
): ReactivePickReturn<T, K>;
export function reactivePick<T extends object>(
obj: T,
predicate: ReactivePickPredicate<T>,
): ReactivePickReturn<T, keyof T>;
export function reactivePick<T extends object, K extends keyof T>(
obj: T,
...keys: Array<K | K[] | ReactivePickPredicate<T>>
): ReactivePickReturn<T, K> {
const first = keys[0];
// Predicate form: the set of kept keys is data-dependent, so it must be
// evaluated on every key-set access (`ownKeys`/`has`/`get`). Value reads
// still forward straight to the source, keeping the view reactive.
if (isFunction(first)) {
const predicate = first as ReactivePickPredicate<T>;
const keeps = (key: PropertyKey): boolean =>
key in obj && predicate(toValue((obj as Record<PropertyKey, unknown>)[key]) as T[keyof T], key as keyof T);
return reactive(new Proxy({} as ReactivePickReturn<T, K>, {
get(_, key, receiver) {
return keeps(key) ? Reflect.get(obj, key, receiver) : undefined;
},
set(_, key, value) {
return keeps(key) ? Reflect.set(obj as object, key, value) : true;
},
has(_, key) {
return keeps(key);
},
ownKeys() {
return Reflect.ownKeys(obj).filter(keeps);
},
getOwnPropertyDescriptor(_, key) {
if (!keeps(key))
return undefined;
return { enumerable: true, configurable: true };
},
})) as ReactivePickReturn<T, K>;
}
// Key form: flatten the (possibly nested) key arguments into a stable Set
// once at construction time, then resolve membership in O(1) per access.
const picked = new Set<PropertyKey>((keys as Array<K | K[]>).flat());
return reactive(new Proxy({} as ReactivePickReturn<T, K>, {
get(_, key, receiver) {
return picked.has(key) ? Reflect.get(obj, key, receiver) : undefined;
},
set(_, key, value) {
return picked.has(key) ? Reflect.set(obj as object, key, value) : true;
},
has(_, key) {
return picked.has(key) && key in obj;
},
ownKeys() {
return Reflect.ownKeys(obj).filter(key => picked.has(key));
},
getOwnPropertyDescriptor(_, key) {
if (!picked.has(key) || !(key in obj))
return undefined;
return { enumerable: true, configurable: true };
},
})) as ReactivePickReturn<T, K>;
}
@@ -1,6 +1,6 @@
import { customRef, toValue } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import { useTimeoutFn } from '@/composables/utilities/useTimeoutFn';
import { useTimeoutFn } from '@/composables/animation/useTimeoutFn';
export type RefAutoResetReturn<T> = Ref<T>;
@@ -1,7 +1,7 @@
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';
import { useDebounceFn } from '@/composables/reactivity/useDebounceFn';
import type { UseDebounceFnOptions } from '@/composables/reactivity/useDebounceFn';
export type RefDebouncedOptions = UseDebounceFnOptions;
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import { isReadonly, ref } from 'vue';
import { refDefault } from '.';
describe(refDefault, () => {
it('returns the default when the source is null', () => {
const source = ref<string | null>(null);
const wrapped = refDefault(source, 'fallback');
expect(wrapped.value).toBe('fallback');
});
it('returns the default when the source is undefined', () => {
const source = ref<string | undefined>(undefined);
const wrapped = refDefault(source, 'fallback');
expect(wrapped.value).toBe('fallback');
});
it('returns the source value when it is present', () => {
const source = ref<string | null>('actual');
const wrapped = refDefault(source, 'fallback');
expect(wrapped.value).toBe('actual');
});
it('reacts when the source becomes null or non-null', () => {
const source = ref<string | null>('initial');
const wrapped = refDefault(source, 'fallback');
expect(wrapped.value).toBe('initial');
source.value = null;
expect(wrapped.value).toBe('fallback');
source.value = 'restored';
expect(wrapped.value).toBe('restored');
});
it('does NOT replace falsy-but-defined values (0, empty string, false)', () => {
expect(refDefault(ref<number | null>(0), 99).value).toBe(0);
expect(refDefault(ref<string | null>(''), 'x').value).toBe('');
expect(refDefault(ref<boolean | null>(false), true).value).toBeFalsy();
});
it('writes pass straight through to the source', () => {
const source = ref<string | null>(null);
const wrapped = refDefault(source, 'fallback');
wrapped.value = 'written';
expect(source.value).toBe('written');
expect(wrapped.value).toBe('written');
});
it('can be written back to null, which re-exposes the default on read', () => {
const source = ref<string | null>('present');
const wrapped = refDefault(source, 'fallback');
wrapped.value = null as never;
expect(source.value).toBeNull();
expect(wrapped.value).toBe('fallback');
});
it('supports a reactive (ref) default value', () => {
const fallback = ref('guest');
const wrapped = refDefault(ref<string | null>(null), fallback);
expect(wrapped.value).toBe('guest');
fallback.value = 'visitor';
expect(wrapped.value).toBe('visitor');
});
it('supports a getter default value', () => {
const base = ref(2);
const wrapped = refDefault(ref<string | null>(null), () => `item-${base.value}`);
expect(wrapped.value).toBe('item-2');
base.value = 5;
expect(wrapped.value).toBe('item-5');
});
it('prefers the source over a reactive default once the source is set', () => {
const fallback = ref('guest');
const source = ref<string | null>(null);
const wrapped = refDefault(source, fallback);
expect(wrapped.value).toBe('guest');
source.value = 'ada';
expect(wrapped.value).toBe('ada');
fallback.value = 'changed';
expect(wrapped.value).toBe('ada');
});
it('works with object sources', () => {
const fallback = { id: 0 };
const source = ref<{ id: number } | null>(null);
const wrapped = refDefault(source, fallback);
// ref does not proxy the plain default object, so identity is preserved
expect(wrapped.value).toBe(fallback);
source.value = { id: 1 };
// ref() wraps object source values in a reactive proxy; compare by shape
expect(wrapped.value).toStrictEqual({ id: 1 });
expect(wrapped.value).toBe(source.value);
});
it('returns a writable (non-readonly) computed ref', () => {
const wrapped = refDefault(ref<string | null>(null), 'fallback');
expect(isReadonly(wrapped)).toBeFalsy();
});
});
@@ -0,0 +1,50 @@
import { computed, toValue } from 'vue';
import type { MaybeRefOrGetter, Ref, WritableComputedRef } from 'vue';
export type RefDefaultReturn<T> = WritableComputedRef<T>;
/**
* @name refDefault
* @category Reactivity
* @description Wrap a writable `ref` so that reads fall back to a default value
* whenever the source holds `null` or `undefined`, while writes pass straight
* through to the source. The default may itself be reactive (a ref, getter, or
* plain value), so the fallback can track other state. Implemented as a single
* writable `computed` — no watchers, no extra refs, nothing to tear down — which
* keeps it allocation-light and SSR-safe (it never touches the DOM).
*
* @param {Ref<T | null | undefined>} source The source ref to read through and write back to
* @param {MaybeRefOrGetter<T>} defaultValue Fallback returned when the source is `null`/`undefined` (can be reactive)
* @returns {RefDefaultReturn<T>} A writable computed ref that never reads as `null`/`undefined`
*
* @example
* const raw = ref<string | null>(null);
* const name = refDefault(raw, 'anonymous');
* name.value; // 'anonymous'
* raw.value = 'ada';
* name.value; // 'ada'
* name.value = 'grace';
* raw.value; // 'grace' — writes pass through
*
* @example
* // The default can be reactive
* const fallback = ref('guest');
* const user = refDefault(ref<string | null>(null), fallback);
* fallback.value = 'visitor';
* user.value; // 'visitor'
*
* @since 0.0.15
*/
export function refDefault<T>(
source: Ref<T | null | undefined>,
defaultValue: MaybeRefOrGetter<T>,
): RefDefaultReturn<T> {
return computed<T>({
get() {
return source.value ?? toValue(defaultValue);
},
set(value) {
source.value = value;
},
});
}
@@ -0,0 +1,249 @@
import { describe, expect, it, vi } from 'vitest';
import { computed, effectScope, isRef, nextTick, watch } from 'vue';
import { controlledRef, refWithControl } from '.';
describe(refWithControl, () => {
it('behaves like a normal ref by default', () => {
const num = refWithControl(0);
expect(isRef(num)).toBeTruthy();
expect(num.value).toBe(0);
num.value = 1;
expect(num.value).toBe(1);
num.value++;
expect(num.value).toBe(2);
});
it('triggers reactive effects on a tracked set', async () => {
const scope = effectScope();
const num = refWithControl(0);
const spy = vi.fn();
scope.run(() => {
watch(num, spy, { flush: 'sync' });
});
num.value = 5;
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenLastCalledWith(5, 0, expect.anything());
scope.stop();
});
it('is reactive inside computed', () => {
const num = refWithControl(2);
const double = computed(() => num.value * 2);
expect(double.value).toBe(4);
num.value = 3;
expect(double.value).toBe(6);
});
it('does not trigger effects when the value is unchanged', () => {
const scope = effectScope();
const num = refWithControl(0);
const spy = vi.fn();
scope.run(() => {
watch(num, spy, { flush: 'sync' });
});
num.value = 0;
expect(spy).not.toHaveBeenCalled();
scope.stop();
});
describe('peek / untrackedGet', () => {
it('reads the current value', () => {
const num = refWithControl(42);
expect(num.peek()).toBe(42);
expect(num.untrackedGet()).toBe(42);
});
it('does not register reactive tracking', () => {
const scope = effectScope();
const num = refWithControl(0);
let read = 0;
scope.run(() => {
watch(() => num.peek(), () => {
read++;
}, { flush: 'sync' });
});
num.value = 1;
expect(read).toBe(0);
scope.stop();
});
});
describe('lay / silentSet', () => {
it('writes without triggering effects', () => {
const scope = effectScope();
const num = refWithControl(0);
const spy = vi.fn();
scope.run(() => {
watch(num, spy, { flush: 'sync' });
});
num.lay(10);
expect(num.peek()).toBe(10);
expect(spy).not.toHaveBeenCalled();
num.silentSet(20);
expect(num.peek()).toBe(20);
expect(spy).not.toHaveBeenCalled();
scope.stop();
});
});
describe('get / set explicit control', () => {
it('get(false) skips tracking and set(v, false) skips triggering', () => {
const scope = effectScope();
const num = refWithControl(0);
const spy = vi.fn();
scope.run(() => {
watch(num, spy, { flush: 'sync' });
});
expect(num.get(false)).toBe(0);
num.set(7, false);
expect(num.get()).toBe(7);
expect(spy).not.toHaveBeenCalled();
num.set(8);
expect(spy).toHaveBeenCalledTimes(1);
scope.stop();
});
});
describe('onBeforeChange', () => {
it('receives the new and old value', () => {
const onBeforeChange = vi.fn();
const num = refWithControl(1 as number, { onBeforeChange });
num.value = 2;
expect(onBeforeChange).toHaveBeenCalledWith(2, 1);
});
it('rejects the change when it returns false', () => {
const num = refWithControl(1, {
onBeforeChange: value => value > 0,
});
num.value = 5;
expect(num.value).toBe(5);
num.value = -1;
expect(num.value).toBe(5);
});
it('allows the change for any non-false return', () => {
const num = refWithControl(0, {
onBeforeChange: () => undefined,
});
num.value = 3;
expect(num.value).toBe(3);
});
it('runs on silent sets too and can still veto', () => {
const num = refWithControl(0, {
onBeforeChange: value => value !== 99,
});
num.lay(99);
expect(num.peek()).toBe(0);
num.lay(7);
expect(num.peek()).toBe(7);
});
});
describe('onChanged', () => {
it('fires after a tracked change', () => {
const onChanged = vi.fn();
const num = refWithControl(1 as number, { onChanged });
num.value = 2;
expect(onChanged).toHaveBeenCalledWith(2, 1);
});
it('fires on silent sets as well', () => {
const onChanged = vi.fn();
const num = refWithControl(0 as number, { onChanged });
num.silentSet(9);
expect(onChanged).toHaveBeenCalledWith(9, 0);
});
it('does not fire when the change was vetoed', () => {
const onChanged = vi.fn();
const num = refWithControl(0 as number, {
onBeforeChange: () => false,
onChanged,
});
num.value = 1;
expect(onChanged).not.toHaveBeenCalled();
expect(num.value).toBe(0);
});
it('does not fire when the value is unchanged', () => {
const onChanged = vi.fn();
const num = refWithControl(5, { onChanged });
num.value = 5;
expect(onChanged).not.toHaveBeenCalled();
});
});
it('works with post-flush watchers', async () => {
const scope = effectScope();
const num = refWithControl(0);
const spy = vi.fn();
scope.run(() => {
watch(num, spy, { flush: 'post' });
});
num.value = 1;
expect(spy).not.toHaveBeenCalled();
await nextTick();
expect(spy).toHaveBeenCalledTimes(1);
scope.stop();
});
it('exposes controlledRef as an alias', () => {
expect(controlledRef).toBe(refWithControl);
const num = controlledRef('a');
expect(num.value).toBe('a');
num.value = 'b';
expect(num.peek()).toBe('b');
});
it('supports object values via reference equality', () => {
const a = { id: 1 };
const b = { id: 2 };
const onChanged = vi.fn();
const obj = refWithControl(a, { onChanged });
obj.value = a;
expect(onChanged).not.toHaveBeenCalled();
obj.value = b;
expect(onChanged).toHaveBeenCalledWith(b, a);
expect(obj.value).toBe(b);
});
});
@@ -0,0 +1,159 @@
import { customRef } from 'vue';
import type { Ref, ShallowUnwrapRef } from 'vue';
import { extendRef } from '@/composables/reactivity/extendRef';
export interface RefWithControlOptions<T> {
/**
* Called right before the value is about to change.
*
* Return `false` to reject the change and keep the current value.
*
* @param value The incoming value
* @param oldValue The current value
*/
onBeforeChange?: (value: T, oldValue: T) => void | boolean;
/**
* Called after the value has changed and (optionally) the ref has triggered.
*
* @param value The new value
* @param oldValue The previous value
*/
onChanged?: (value: T, oldValue: T) => void;
}
export interface ControlledRefMethods<T> {
/**
* Read the value, optionally registering reactive tracking.
*
* @param tracking Whether to register the dependency. Defaults to `true`.
*/
get: (tracking?: boolean) => T;
/**
* Write the value, optionally triggering reactive effects.
*
* @param value The new value
* @param triggering Whether to trigger effects. Defaults to `true`.
*/
set: (value: T, triggering?: boolean) => void;
/**
* Read the value without registering reactive tracking. Alias of `get(false)`.
*/
peek: () => T;
/**
* Write the value without triggering reactive effects. Alias of `set(v, false)`.
*/
lay: (value: T) => void;
/**
* Read the value without registering reactive tracking. Alias of `peek`.
*/
untrackedGet: () => T;
/**
* Write the value without triggering reactive effects. Alias of `lay`.
*/
silentSet: (value: T) => void;
}
export type RefWithControlReturn<T>
= Ref<T> & ShallowUnwrapRef<ControlledRefMethods<T>>;
/**
* @name refWithControl
* @category Reactivity
* @description A ref with fine-grained control over its reactivity: read/write
* without tracking or triggering, plus `onBeforeChange` (vetoable) and
* `onChanged` hooks. Built on `customRef`, so there are no extra watchers.
*
* @param {T} initial The initial value
* @param {RefWithControlOptions<T>} [options={}] `onBeforeChange` (return `false` to veto) and `onChanged` hooks
* @returns {RefWithControlReturn<T>} A ref extended with `get`/`set`/`peek`/`lay`/`untrackedGet`/`silentSet`
*
* @example
* const num = refWithControl(0);
* num.value++; // tracked + triggered, like a normal ref
* num.peek(); // read without tracking
* num.lay(5); // write without triggering effects
*
* @example
* // veto changes with onBeforeChange
* const positive = refWithControl(1, {
* onBeforeChange: (value) => value > 0, // reject non-positive values
* onChanged: (value, old) => log(`${old} -> ${value}`),
* });
* positive.value = -1; // rejected, stays 1
*
* @since 0.0.15
*/
export function refWithControl<T>(
initial: T,
options: RefWithControlOptions<T> = {},
): RefWithControlReturn<T> {
const { onBeforeChange, onChanged } = options;
let source = initial;
let track: () => void;
let trigger: () => void;
const controlled = customRef<T>((_track, _trigger) => {
track = _track;
trigger = _trigger;
return {
get: () => get(),
set: value => set(value),
};
});
function get(tracking = true): T {
if (tracking)
track();
return source;
}
function set(value: T, triggering = true): void {
if (value === source)
return;
const oldValue = source;
if (onBeforeChange?.(value, oldValue) === false)
return;
source = value;
onChanged?.(value, oldValue);
if (triggering)
trigger();
}
const peek = (): T => get(false);
const lay = (value: T): void => set(value, false);
return extendRef(
controlled,
{
get,
set,
peek,
lay,
untrackedGet: peek,
silentSet: lay,
},
{ enumerable: true },
) as RefWithControlReturn<T>;
}
/**
* @name controlledRef
* @category Reactivity
* @description Alias of {@link refWithControl}.
*
* @since 0.0.15
*/
export const controlledRef = refWithControl;
@@ -0,0 +1,184 @@
import { describe, expect, it } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { syncRef } from '.';
describe(syncRef, () => {
it('keeps both refs in sync two-way by default', () => {
const left = ref('foo');
const right = ref('bar');
syncRef(left, right);
// immediate sync: ltr propagates left -> right on setup
expect(right.value).toBe('foo');
left.value = 'left-change';
expect(right.value).toBe('left-change');
right.value = 'right-change';
expect(left.value).toBe('right-change');
});
it('does not enter an infinite feedback loop', () => {
const left = ref(0);
const right = ref(0);
syncRef(left, right);
left.value = 1;
expect(left.value).toBe(1);
expect(right.value).toBe(1);
right.value = 2;
expect(left.value).toBe(2);
expect(right.value).toBe(2);
});
it('respects direction: ltr (one-way left -> right)', () => {
const left = ref('a');
const right = ref('b');
syncRef(left, right, { direction: 'ltr' });
// immediate ltr sync
expect(right.value).toBe('a');
left.value = 'c';
expect(right.value).toBe('c');
// right does not propagate back to left
right.value = 'd';
expect(left.value).toBe('c');
});
it('respects direction: rtl (one-way right -> left)', () => {
const left = ref('a');
const right = ref('b');
syncRef(left, right, { direction: 'rtl' });
// immediate rtl sync
expect(left.value).toBe('b');
right.value = 'c';
expect(left.value).toBe('c');
// left does not propagate to right
left.value = 'd';
expect(right.value).toBe('c');
});
it('applies transforms for both directions', () => {
const left = ref(10);
const right = ref('0');
syncRef(left, right, {
transform: {
ltr: value => String(value),
rtl: value => Number(value),
},
});
// immediate: left (10) -> right ('10')
expect(right.value).toBe('10');
left.value = 42;
expect(right.value).toBe('42');
right.value = '7';
expect(left.value).toBe(7);
});
it('applies a one-way ltr transform', () => {
const count = ref(0);
const text = ref('');
syncRef(count, text, {
direction: 'ltr',
transform: { ltr: value => `count: ${value}` },
});
expect(text.value).toBe('count: 0');
count.value = 5;
expect(text.value).toBe('count: 5');
});
it('skips the immediate sync when immediate is false', () => {
const left = ref('initial-left');
const right = ref('initial-right');
syncRef(left, right, { immediate: false });
// no initial sync
expect(right.value).toBe('initial-right');
expect(left.value).toBe('initial-left');
left.value = 'updated';
expect(right.value).toBe('updated');
});
it('stops synchronizing after stop() is called', () => {
const left = ref(0);
const right = ref(0);
const { stop } = syncRef(left, right);
left.value = 1;
expect(right.value).toBe(1);
stop();
left.value = 2;
right.value = 3;
expect(right.value).toBe(3);
expect(left.value).toBe(2);
});
it('supports async flush (pre) with nextTick', async () => {
const left = ref('x');
const right = ref('y');
syncRef(left, right, { flush: 'pre', immediate: false });
left.value = 'changed';
// pre flush is async
expect(right.value).toBe('y');
await nextTick();
expect(right.value).toBe('changed');
right.value = 'back';
await nextTick();
expect(left.value).toBe('back');
});
it('syncs deep object changes when deep is enabled', () => {
const left = ref({ nested: { count: 0 } });
const right = ref({ nested: { count: 0 } });
syncRef(left, right, { deep: true });
left.value.nested.count = 5;
expect(right.value.nested.count).toBe(5);
});
it('works inside an effect scope and is disposed with it', () => {
const left = ref(0);
const right = ref(0);
const scope = effectScope();
scope.run(() => {
syncRef(left, right);
});
left.value = 1;
expect(right.value).toBe(1);
scope.stop();
left.value = 2;
// watchers torn down with the scope
expect(right.value).toBe(1);
});
});
@@ -0,0 +1,167 @@
import type { Ref, WatchStopHandle } from 'vue';
import type { ConfigurableFlush } from '@/types';
import { watchIgnorable } from '@/composables/watch/watchIgnorable';
export type SyncRefDirection = 'ltr' | 'rtl' | 'both';
/**
* Conversion functions used when the two refs hold different value types.
*
* - `ltr` maps a left value to a right value (used when the left ref changes).
* - `rtl` maps a right value to a left value (used when the right ref changes).
*/
export interface SyncRefTransform<L, R> {
/**
* Transform a left value into a right value. Required for `ltr`/`both` when `L !== R`.
*/
ltr?: (left: L) => R;
/**
* Transform a right value into a left value. Required for `rtl`/`both` when `L !== R`.
*/
rtl?: (right: R) => L;
}
export interface SyncRefOptions<L, R> extends ConfigurableFlush {
/**
* Watch the refs deeply.
*
* @default false
*/
deep?: boolean;
/**
* Sync the values immediately on setup (in the chosen direction).
*
* @default true
*/
immediate?: boolean;
/**
* Direction of synchronization.
*
* - `both` keeps both refs in sync.
* - `ltr` only propagates `left` -> `right`.
* - `rtl` only propagates `right` -> `left`.
*
* @default 'both'
*/
direction?: SyncRefDirection;
/**
* Conversion functions to apply when the refs hold different value types.
* Provide `ltr` and/or `rtl` matching the active {@link SyncRefOptions.direction}.
*/
transform?: SyncRefTransform<L, R>;
}
export interface SyncRefReturn {
/**
* Stop all underlying watchers. Synchronization cannot be resumed afterwards.
*/
stop: WatchStopHandle;
}
const identity = <T>(value: T): T => value;
type IgnoredUpdater = (updater: () => void) => void;
const runDirect: IgnoredUpdater = updater => updater();
/**
* @name syncRef
* @category Reactivity
* @description Keeps two refs in sync (two-way by default, or one-way via `direction`), with optional value transforms.
*
* @param {Ref<L>} left The left ref to synchronize
* @param {Ref<R>} right The right ref to synchronize
* @param {SyncRefOptions<L, R>} [options={}] `direction`, `transform`, `immediate`, `flush`, and `deep`
* @returns {SyncRefReturn} `{ stop }` to tear down the synchronization
*
* @example
* const left = ref('hello');
* const right = ref('hello');
* syncRef(left, right);
*
* left.value = 'world'; // right.value === 'world'
* right.value = 'foo'; // left.value === 'foo'
*
* @example
* // One-way with a transform (left number -> right string)
* const count = ref(0);
* const text = ref('0');
* syncRef(count, text, {
* direction: 'ltr',
* transform: { ltr: value => String(value) },
* });
*
* @since 0.0.15
*/
export function syncRef<L, R = L>(
left: Ref<L>,
right: Ref<R>,
options: SyncRefOptions<L, R> = {},
): SyncRefReturn {
const {
flush = 'sync',
deep = false,
immediate = true,
direction = 'both',
transform = {},
} = options;
// Identity is the safe fallback when both refs share the same value type.
const transformLTR = (transform.ltr ?? identity) as (left: L) => R;
const transformRTL = (transform.rtl ?? identity) as (right: R) => L;
const syncLTR = direction === 'both' || direction === 'ltr';
const syncRTL = direction === 'both' || direction === 'rtl';
// Each callback wraps its cross-write in the OPPOSITE watcher's
// `ignoreUpdates` so a programmatic write never re-triggers the watcher that
// observes the written ref — preventing feedback loops without pausing every
// watcher on every change. The handles are bound after both watchers exist,
// so callbacks read them through these late-bound slots.
let ignoreLeftWrites: IgnoredUpdater = runDirect;
let ignoreRightWrites: IgnoredUpdater = runDirect;
const watchers: WatchStopHandle[] = [];
if (syncLTR) {
const { stop, ignoreUpdates } = watchIgnorable(
left,
(newValue) => {
// Writing `right`; suppress the rtl watcher.
ignoreRightWrites(() => {
right.value = transformLTR(newValue as L);
});
},
{ flush, deep, immediate },
);
// Writes to `left` (done by the rtl watcher) must be ignored here.
ignoreLeftWrites = ignoreUpdates;
watchers.push(stop);
}
if (syncRTL) {
// The ltr watcher already performed the initial sync, so a `both` setup
// must not immediately back-sync (it would clobber `left` from `right`).
const rtlImmediate = direction === 'rtl' ? immediate : false;
const { stop, ignoreUpdates } = watchIgnorable(
right,
(newValue) => {
// Writing `left`; suppress the ltr watcher.
ignoreLeftWrites(() => {
left.value = transformRTL(newValue as R);
});
},
{ flush, deep, immediate: rtlImmediate },
);
// Writes to `right` (done by the ltr watcher) must be ignored here.
ignoreRightWrites = ignoreUpdates;
watchers.push(stop);
}
const stop = (): void => {
for (const stopWatcher of watchers) stopWatcher();
};
return { stop };
}
@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest';
import { computed, effectScope, isReactive, nextTick, ref, shallowRef, watch } from 'vue';
import { toReactive } from '.';
describe(toReactive, () => {
it('reads properties through the ref', () => {
const state = ref({ count: 0, name: 'a' });
const r = toReactive(state);
expect(r.count).toBe(0);
expect(r.name).toBe('a');
});
it('returns a reactive proxy', () => {
const r = toReactive(ref({ count: 0 }));
expect(isReactive(r)).toBeTruthy();
});
it('writes pass through to the ref value', () => {
const state = ref({ count: 0 });
const r = toReactive(state);
r.count = 5;
expect(state.value.count).toBe(5);
});
it('reflects external ref mutations', () => {
const state = ref({ count: 0 });
const r = toReactive(state);
state.value.count = 9;
expect(r.count).toBe(9);
});
it('survives reassignment of the whole ref value', () => {
const state = ref({ name: 'a' });
const r = toReactive(state);
expect(r.name).toBe('a');
state.value = { name: 'b' };
expect(r.name).toBe('b');
});
it('unwraps nested refs on read', () => {
// shallowRef preserves nested refs as values (a deep ref would unwrap them)
const inner = ref(1);
const state = shallowRef({ inner });
const r = toReactive(state);
expect(r.inner as unknown).toBe(1);
inner.value = 2;
expect(r.inner as unknown).toBe(2);
});
it('writes a plain value into a nested ref via .value', () => {
const inner = ref(1);
const state = shallowRef({ inner });
const r = toReactive(state);
(r as unknown as { inner: number }).inner = 42;
expect(inner.value).toBe(42);
// the property is still a ref, not overwritten by a plain number
expect(state.value.inner).toBe(inner);
});
it('replaces a nested ref when assigning another ref', () => {
const a = ref(1);
const b = ref(2);
const state = shallowRef<{ x: unknown }>({ x: a });
const r = toReactive(state) as { x: unknown };
r.x = b;
expect(state.value.x).toBe(b);
});
it('supports the in operator (has trap)', () => {
const state = ref<Record<string, number>>({ a: 1 });
const r = toReactive(state);
expect('a' in r).toBeTruthy();
expect('b' in r).toBeFalsy();
});
it('supports key deletion', () => {
const state = ref<Record<string, number>>({ a: 1, b: 2 });
const r = toReactive(state);
delete r.a;
expect('a' in state.value).toBeFalsy();
expect(r.b).toBe(2);
});
it('enumerates own keys via Object.keys', () => {
const state = ref<Record<string, number>>({ a: 1, b: 2 });
const r = toReactive(state);
expect(Object.keys(r).sort()).toEqual(['a', 'b']);
});
it('spreads enumerable own properties', () => {
const state = ref<Record<string, number>>({ a: 1, b: 2 });
const r = toReactive(state);
expect({ ...r }).toEqual({ a: 1, b: 2 });
});
it('is deeply reactive in an effect', async () => {
const state = ref({ count: 0 });
const r = toReactive(state);
const seen: number[] = [];
const scope = effectScope();
scope.run(() => {
watch(() => r.count, value => seen.push(value));
});
r.count = 1;
await nextTick();
state.value.count = 2;
await nextTick();
expect(seen).toEqual([1, 2]);
scope.stop();
});
it('works as a computed source', () => {
const state = ref({ first: 'John', last: 'Doe' });
const r = toReactive(state);
const full = computed(() => `${r.first} ${r.last}`);
expect(full.value).toBe('John Doe');
r.first = 'Jane';
expect(full.value).toBe('Jane Doe');
});
it('returns reactive(object) for a plain (non-ref) object', () => {
const plain = { count: 0 };
const r = toReactive(plain);
expect(isReactive(r)).toBeTruthy();
r.count = 3;
expect(plain.count).toBe(3);
});
it('handles index and length access on array-backed refs', () => {
const state = ref<number[]>([1, 2, 3]);
const r = toReactive(state);
expect(r).toHaveLength(3);
expect(r[0]).toBe(1);
expect(r[2]).toBe(3);
r[0] = 9;
expect(state.value[0]).toBe(9);
});
});
@@ -0,0 +1,73 @@
import { isRef, reactive, unref } from 'vue';
import type { MaybeRef, UnwrapNestedRefs } from 'vue';
// Shared, frozen descriptor returned by the proxy's `getOwnPropertyDescriptor`
// trap. Every enumerable own key of the underlying ref resolves to the same
// shape, so we allocate it once at module scope instead of per-lookup.
const OWN_PROPERTY_DESCRIPTOR: PropertyDescriptor = {
enumerable: true,
configurable: true,
};
/**
* @name toReactive
* @category Reactivity
* @description Convert a ref of object to a reactive proxy. Property reads and
* writes pass straight through to the ref's current value, so the proxy stays
* in sync even if the ref is reassigned to a whole new object. Writing a plain
* value onto a key that currently holds a ref unwraps into that ref's `.value`.
* Passing a plain object simply returns `reactive(object)`.
*
* @param {MaybeRef<T>} objectRef A ref of object (or a plain object)
* @returns {UnwrapNestedRefs<T>} A reactive proxy backed by the ref
*
* @example
* const state = ref({ count: 0 });
* const reactiveState = toReactive(state);
* reactiveState.count++; // state.value.count === 1
*
* @example
* // survives ref reassignment
* const obj = ref({ name: 'a' });
* const r = toReactive(obj);
* obj.value = { name: 'b' };
* r.name; // 'b'
*
* @since 0.0.15
*/
export function toReactive<T extends object>(
objectRef: MaybeRef<T>,
): UnwrapNestedRefs<T> {
if (!isRef(objectRef))
return reactive(objectRef);
const proxy = new Proxy({} as T, {
get(_, key, receiver) {
return unref(Reflect.get(objectRef.value, key, receiver));
},
set(_, key, value) {
const current = objectRef.value[key as keyof T];
if (isRef(current) && !isRef(value))
current.value = value;
else
(objectRef.value as Record<PropertyKey, unknown>)[key] = value;
return true;
},
deleteProperty(_, key) {
return Reflect.deleteProperty(objectRef.value, key);
},
has(_, key) {
return Reflect.has(objectRef.value, key);
},
ownKeys() {
return Reflect.ownKeys(objectRef.value);
},
getOwnPropertyDescriptor() {
return OWN_PROPERTY_DESCRIPTOR;
},
});
return reactive(proxy) as UnwrapNestedRefs<T>;
}
@@ -1,214 +0,0 @@
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);
});
});
@@ -1,247 +0,0 @@
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);
}
@@ -1,20 +0,0 @@
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]);
});
});
@@ -1,24 +0,0 @@
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));
}
@@ -1,19 +0,0 @@
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();
});
});
@@ -1,24 +0,0 @@
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));
}
@@ -1,20 +0,0 @@
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]);
});
});
@@ -1,24 +0,0 @@
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,151 +0,0 @@
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');
});
});
@@ -1,152 +0,0 @@
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,
};
}
@@ -0,0 +1,264 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useDebounceFn } from '.';
describe(useDebounceFn, () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('delays invocation until ms elapsed', () => {
const fn = vi.fn();
const debounced = useDebounceFn(fn, 100);
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledOnce();
});
it('coalesces rapid calls into one with the latest args', () => {
const fn = vi.fn();
const debounced = useDebounceFn(fn, 100);
debounced('a');
debounced('b');
debounced('c');
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledOnce();
expect(fn).toHaveBeenCalledWith('c');
});
it('resolves the promise with the function result', async () => {
const debounced = useDebounceFn((x: number) => x * 2, 100);
const promise = debounced(21);
vi.advanceTimersByTime(100);
await expect(promise).resolves.toBe(42);
});
it('rejects the promise when the function throws', async () => {
const debounced = useDebounceFn(() => {
throw new Error('boom');
}, 100);
const promise = debounced();
vi.advanceTimersByTime(100);
await expect(promise).rejects.toThrow('boom');
});
it('preserves the `this` context', () => {
const ctx = { value: 7, fn: vi.fn() };
const obj = {
value: 7,
debounced: useDebounceFn(function (this: typeof ctx) {
ctx.fn(this.value);
}, 100),
} as unknown as typeof ctx & { debounced: () => Promise<void> };
obj.debounced();
vi.advanceTimersByTime(100);
expect(ctx.fn).toHaveBeenCalledWith(7);
});
it('runs synchronously when ms <= 0', () => {
const fn = vi.fn();
const debounced = useDebounceFn(fn, 0);
debounced();
expect(fn).toHaveBeenCalledOnce();
});
it('accepts a reactive/getter delay', () => {
const fn = vi.fn();
let ms = 100;
const debounced = useDebounceFn(fn, () => ms);
debounced();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
ms = 300;
debounced();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1); // not yet — delay grew to 300
vi.advanceTimersByTime(200);
expect(fn).toHaveBeenCalledTimes(2);
});
describe('isPending', () => {
it('reflects the pending state', () => {
const debounced = useDebounceFn(vi.fn(), 100);
expect(debounced.isPending.value).toBeFalsy();
debounced();
expect(debounced.isPending.value).toBeTruthy();
vi.advanceTimersByTime(100);
expect(debounced.isPending.value).toBeFalsy();
});
it('is false after ms <= 0 synchronous calls', () => {
const debounced = useDebounceFn(vi.fn(), 0);
debounced();
expect(debounced.isPending.value).toBeFalsy();
});
});
describe('cancel', () => {
it('cancels a pending invocation', () => {
const fn = vi.fn();
const debounced = useDebounceFn(fn, 100);
debounced();
debounced.cancel();
expect(debounced.isPending.value).toBeFalsy();
vi.advanceTimersByTime(100);
expect(fn).not.toHaveBeenCalled();
});
it('resolves the pending promise with undefined by default', async () => {
const debounced = useDebounceFn((x: number) => x, 100);
const promise = debounced(5);
debounced.cancel();
await expect(promise).resolves.toBeUndefined();
});
it('rejects the pending promise when rejectOnCancel is set', async () => {
const debounced = useDebounceFn((x: number) => x, 100, { rejectOnCancel: true });
const promise = debounced(5);
// Attach the rejection expectation synchronously, *then* cancel — cancel is
// what rejects the promise, so awaiting before it would deadlock.
// eslint-disable-next-line vitest/valid-expect -- intentionally deferred; awaited below after cancel()
const assertion = expect(promise).rejects.toBeUndefined();
debounced.cancel();
await assertion;
});
it('is a no-op when nothing is pending', () => {
const debounced = useDebounceFn(vi.fn(), 100);
expect(() => debounced.cancel()).not.toThrow();
});
});
describe('flush', () => {
it('invokes the pending call immediately', () => {
const fn = vi.fn();
const debounced = useDebounceFn(fn, 100);
debounced('x');
debounced.flush();
expect(fn).toHaveBeenCalledOnce();
expect(fn).toHaveBeenCalledWith('x');
expect(debounced.isPending.value).toBeFalsy();
});
it('resolves the pending promise with the result', async () => {
const debounced = useDebounceFn((x: number) => x * 3, 100);
const promise = debounced(4);
debounced.flush();
await expect(promise).resolves.toBe(12);
});
it('does not invoke twice when the timer later fires', () => {
const fn = vi.fn();
const debounced = useDebounceFn(fn, 100);
debounced();
debounced.flush();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledOnce();
});
it('is a no-op when nothing is pending', () => {
const fn = vi.fn();
const debounced = useDebounceFn(fn, 100);
debounced.flush();
expect(fn).not.toHaveBeenCalled();
});
});
describe('maxWait', () => {
it('forces invocation after maxWait under sustained calls', () => {
const fn = vi.fn();
const debounced = useDebounceFn(fn, 100, { maxWait: 250 });
// Keep resetting the 100ms timer every 80ms so it never fires on its own.
debounced();
vi.advanceTimersByTime(80);
debounced();
vi.advanceTimersByTime(80);
debounced();
vi.advanceTimersByTime(80);
expect(fn).not.toHaveBeenCalled(); // 240ms elapsed, under maxWait
vi.advanceTimersByTime(10); // 250ms total — maxWait fires
expect(fn).toHaveBeenCalledOnce();
});
it('resets the maxWait window after firing', () => {
const fn = vi.fn();
const debounced = useDebounceFn(fn, 100, { maxWait: 250 });
debounced();
vi.advanceTimersByTime(250);
expect(fn).toHaveBeenCalledTimes(1);
debounced();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(2);
});
it('runs synchronously when maxWait <= 0', () => {
const fn = vi.fn();
const debounced = useDebounceFn(fn, 100, { maxWait: 0 });
debounced();
expect(fn).toHaveBeenCalledOnce();
});
});
describe('scope disposal', () => {
it('cancels pending timers when the owning scope is disposed', () => {
const fn = vi.fn();
const scope = effectScope();
let debounced!: ReturnType<typeof useDebounceFn>;
scope.run(() => {
debounced = useDebounceFn(fn, 100);
});
debounced();
expect(debounced.isPending.value).toBeTruthy();
scope.stop();
vi.advanceTimersByTime(100);
expect(fn).not.toHaveBeenCalled();
expect(debounced.isPending.value).toBeFalsy();
});
});
it('settles superseded promises with undefined (default)', async () => {
const debounced = useDebounceFn((x: string) => x, 100);
const first = debounced('a');
const second = debounced('b');
vi.advanceTimersByTime(100);
await nextTick();
await expect(first).resolves.toBeUndefined();
await expect(second).resolves.toBe('b');
});
});
@@ -0,0 +1,156 @@
import { shallowReadonly, shallowRef, toValue } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import { debounce } from '@robonen/stdlib';
import type { AnyFunction } from '@robonen/stdlib';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseDebounceFnOptions {
/**
* The maximum time `fn` is allowed to be delayed before it is forcibly
* invoked, even if calls keep arriving. Guarantees progress under sustained
* input. When omitted there is no upper bound.
*
* @default undefined
*/
maxWait?: number;
/**
* Reject the pending promise (instead of silently resolving it) when a call
* is cancelled — either explicitly via `cancel()` or implicitly when a newer
* call supersedes it.
*
* @default false
*/
rejectOnCancel?: boolean;
}
export interface UseDebounceFnReturn<T extends AnyFunction> {
/**
* Invoke the debounced function. Returns a promise that resolves with the
* wrapped function's result once the trailing edge fires. Superseded calls
* resolve with `undefined` (or reject when `rejectOnCancel` is set).
*/
(this: ThisParameterType<T>, ...args: Parameters<T>): Promise<Awaited<ReturnType<T>>>;
/**
* Cancel the pending invocation, if any.
*/
cancel: () => void;
/**
* Immediately invoke the pending call (if any) ahead of its timer.
*/
flush: () => void;
/**
* Whether a debounced invocation is currently scheduled.
*/
readonly isPending: Readonly<Ref<boolean>>;
}
/**
* @name useDebounceFn
* @category Reactivity
* @description Debounce execution of a function — a thin reactive wrapper around
* `@robonen/stdlib`'s `debounce`. Postpones invocation until `ms` have elapsed
* since the last call and resolves with the wrapped function's result. Supports
* a reactive delay, a `maxWait` ceiling, `rejectOnCancel`, and exposes `cancel`,
* `flush`, and `isPending`. Pending timers are cleared on scope dispose.
*
* @param {T} fn The function to debounce
* @param {MaybeRefOrGetter<number>} [ms=200] Delay in milliseconds (can be reactive)
* @param {UseDebounceFnOptions} [options] Debounce options (`maxWait`, `rejectOnCancel`)
* @returns {UseDebounceFnReturn<T>} The debounced function with `cancel`, `flush`, and `isPending`
*
* @example
* const search = useDebounceFn(() => fetchResults(query.value), 300);
* watch(query, search);
*
* @example
* const save = useDebounceFn(persist, 300, { maxWait: 1000 });
* save.cancel();
* save.flush();
* if (save.isPending.value) {}
*
* @since 0.0.15
*/
export function useDebounceFn<T extends AnyFunction>(
fn: T,
ms: MaybeRefOrGetter<number> = 200,
options: UseDebounceFnOptions = {},
): UseDebounceFnReturn<T> {
const { maxWait, rejectOnCancel = false } = options;
const isPending = shallowRef(false);
// The latest unresolved promise settler; superseded ones are settled early.
let settler: { resolve: (value?: unknown) => void; reject: (reason?: unknown) => void } | undefined;
function settleCancelled() {
const pending = settler;
settler = undefined;
if (!pending)
return;
if (rejectOnCancel)
pending.reject();
else
pending.resolve(undefined);
}
// The function stdlib debounces: runs `fn` and settles the latest promise.
function run(this: ThisParameterType<T>, ...args: Parameters<T>) {
isPending.value = false;
const pending = settler;
settler = undefined;
try {
const result = fn.apply(this, args) as Awaited<ReturnType<T>>;
pending?.resolve(result);
return result;
}
catch (error) {
pending?.reject(error);
return undefined;
}
}
const debounced = debounce(run as AnyFunction, () => toValue(ms), { maxWait, leading: false, trailing: true });
const wrapper = function (this: ThisParameterType<T>, ...args: Parameters<T>) {
return new Promise<Awaited<ReturnType<T>>>((resolve, reject) => {
// A new call supersedes the previous pending promise.
settleCancelled();
settler = { resolve: resolve as (value?: unknown) => void, reject };
const duration = toValue(ms);
// Fast path: non-positive delay (or maxWait) runs synchronously.
if (duration <= 0 || (maxWait !== undefined && maxWait <= 0)) {
debounced.cancel();
isPending.value = false;
run.apply(this, args);
return;
}
isPending.value = true;
debounced.apply(this, args);
});
} as UseDebounceFnReturn<T>;
wrapper.cancel = () => {
debounced.cancel();
isPending.value = false;
settleCancelled();
};
wrapper.flush = () => {
// stdlib flush synchronously runs `run`, which settles + clears isPending.
debounced.flush();
};
tryOnScopeDispose(wrapper.cancel);
return Object.assign(wrapper, { isPending: shallowReadonly(isPending) }) as UseDebounceFnReturn<T>;
}
@@ -1,50 +0,0 @@
import { nextTick, ref } from 'vue';
import { describe, expect, it } from 'vitest';
import { useLastChanged } from '.';
import { timestamp } from '@robonen/stdlib';
describe(useLastChanged, () => {
it('initialize with null if no initialValue is provided', () => {
const source = ref(0);
const lastChanged = useLastChanged(source);
expect(lastChanged.value).toBeNull();
});
it('initialize with the provided initialValue', () => {
const source = ref(0);
const initialValue = 123456789;
const lastChanged = useLastChanged(source, { initialValue });
expect(lastChanged.value).toBe(initialValue);
});
it('update the timestamp when the source changes', async () => {
const source = ref(0);
const lastChanged = useLastChanged(source);
const initialTimestamp = lastChanged.value;
source.value = 1;
await nextTick();
expect(lastChanged.value).not.toBe(initialTimestamp);
expect(lastChanged.value).toBeLessThanOrEqual(timestamp());
});
it('update the timestamp immediately if immediate option is true', async () => {
const source = ref(0);
const lastChanged = useLastChanged(source, { immediate: true });
expect(lastChanged.value).toBeLessThanOrEqual(timestamp());
});
it('not update the timestamp if the source does not change', async () => {
const source = ref(0);
const lastChanged = useLastChanged(source);
const initialTimestamp = lastChanged.value;
await nextTick();
expect(lastChanged.value).toBe(initialTimestamp);
});
});
@@ -1,41 +0,0 @@
import { timestamp } from '@robonen/stdlib';
import { ref, watch } from 'vue';
import type { Ref, WatchOptions, WatchSource } from 'vue';
export interface UseLastChangedOptions<
Immediate extends boolean,
InitialValue extends number | null | undefined = undefined,
> extends WatchOptions<Immediate> {
initialValue?: InitialValue;
}
/**
* @name useLastChanged
* @category Reactivity
* @description Records the last time a value changed
*
* @param {WatchSource} source The value to track
* @param {UseLastChangedOptions} [options={}] The options for the last changed tracker
* @returns {Ref<number | null>} The timestamp of the last change
*
* @example
* const value = ref(0);
* const lastChanged = useLastChanged(value);
*
* @example
* const value = ref(0);
* const lastChanged = useLastChanged(value, { immediate: true });
*
* @since 0.0.1
*/
export function useLastChanged(source: WatchSource, options?: UseLastChangedOptions<false>): Ref<number | null>;
export function useLastChanged(source: WatchSource, options: UseLastChangedOptions<true> | UseLastChangedOptions<boolean, number>): Ref<number>;
export function useLastChanged(source: WatchSource, options: UseLastChangedOptions<boolean, any> = {}): Ref<number | null> | Ref<number> {
const lastChanged = ref<number | null>(options.initialValue ?? null);
watch(source, () => {
lastChanged.value = timestamp();
}, options);
return lastChanged;
}
@@ -0,0 +1,214 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { useThrottleFn } from '.';
describe(useThrottleFn, () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('invokes immediately on the leading edge', () => {
const fn = vi.fn();
const throttled = useThrottleFn(fn, 100);
throttled();
expect(fn).toHaveBeenCalledOnce();
});
it('ignores calls within the window by default (no trailing)', () => {
const fn = vi.fn();
const throttled = useThrottleFn(fn, 100);
throttled();
throttled();
throttled();
vi.advanceTimersByTime(200);
expect(fn).toHaveBeenCalledOnce();
});
it('invokes trailing when enabled', () => {
const fn = vi.fn();
const throttled = useThrottleFn(fn, 100, true);
throttled('a');
throttled('b');
expect(fn).toHaveBeenCalledOnce();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenLastCalledWith('b');
});
it('resolves the promise with the function result', async () => {
const throttled = useThrottleFn((x: number) => x + 1, 100);
await expect(throttled(1)).resolves.toBe(2);
});
it('skips the leading edge when leading is false', () => {
const fn = vi.fn();
const throttled = useThrottleFn(fn, 100, true, false);
// first call only opens the window (no leading edge)
throttled('a');
expect(fn).not.toHaveBeenCalled();
// a call inside the same window schedules the trailing edge
throttled('b');
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledOnce();
expect(fn).toHaveBeenLastCalledWith('b');
});
describe('options object', () => {
it('accepts delay/trailing/leading via an options object', () => {
const fn = vi.fn();
const throttled = useThrottleFn(fn, { delay: 100, trailing: true });
throttled('a');
throttled('b');
expect(fn).toHaveBeenCalledOnce();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenLastCalledWith('b');
});
it('defaults delay to 200 when omitted', () => {
const fn = vi.fn();
const throttled = useThrottleFn(fn, {});
throttled(); // leading edge
expect(fn).toHaveBeenCalledOnce();
vi.advanceTimersByTime(199);
throttled(); // still inside the 200ms window → dropped (trailing off)
expect(fn).toHaveBeenCalledOnce();
vi.advanceTimersByTime(1); // 200ms elapsed — window reached
throttled(); // new window opens on the leading edge
expect(fn).toHaveBeenCalledTimes(2);
});
});
describe('reactive delay', () => {
it('reads the current delay on each call', () => {
const fn = vi.fn();
const delay = ref(100);
const throttled = useThrottleFn(fn, delay);
throttled();
expect(fn).toHaveBeenCalledOnce();
vi.advanceTimersByTime(101);
throttled();
expect(fn).toHaveBeenCalledTimes(2);
delay.value = 1000;
vi.advanceTimersByTime(101);
throttled();
// window grew to 1000ms, so this call is throttled
expect(fn).toHaveBeenCalledTimes(2);
});
});
describe('zero delay', () => {
it('invokes synchronously on every call when delay <= 0', () => {
const fn = vi.fn();
const throttled = useThrottleFn(fn, 0);
throttled();
throttled();
throttled();
expect(fn).toHaveBeenCalledTimes(3);
});
});
describe('cancel', () => {
it('drops a pending trailing invocation', () => {
const fn = vi.fn();
const throttled = useThrottleFn(fn, 100, true);
throttled('a');
throttled('b');
expect(fn).toHaveBeenCalledOnce();
throttled.cancel();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledOnce();
});
it('resolves the pending promise with undefined by default', async () => {
const throttled = useThrottleFn((x: number) => x, 100, true);
throttled(1);
const pending = throttled(2);
throttled.cancel();
await expect(pending).resolves.toBeUndefined();
});
});
describe('rejectOnCancel', () => {
it('rejects a superseded trailing promise', async () => {
const throttled = useThrottleFn((x: number) => x, 100, true, true, true);
throttled(1);
const superseded = throttled(2);
// next call within the window supersedes the previous trailing promise
const latest = throttled(3);
await expect(superseded).rejects.toThrow();
vi.advanceTimersByTime(100);
await expect(latest).resolves.toBe(3);
});
it('rejects on explicit cancel', async () => {
const throttled = useThrottleFn((x: number) => x, 100, true, true, true);
throttled(1);
const pending = throttled(2);
throttled.cancel();
await expect(pending).rejects.toThrow();
});
});
describe('flush', () => {
it('invokes the pending trailing call immediately', async () => {
const fn = vi.fn((x: number) => x);
const throttled = useThrottleFn(fn, 100, true);
throttled(1);
const pending = throttled(2);
expect(fn).toHaveBeenCalledOnce();
throttled.flush();
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenLastCalledWith(2);
await expect(pending).resolves.toBe(2);
});
it('is a no-op when nothing is pending', () => {
const fn = vi.fn();
const throttled = useThrottleFn(fn, 100, true);
expect(() => throttled.flush()).not.toThrow();
expect(fn).not.toHaveBeenCalled();
});
});
it('preserves the calling context (this)', () => {
const obj = {
value: 42,
method: vi.fn(function (this: { value: number }) {
return this.value;
}),
};
const throttled = useThrottleFn(obj.method, 100);
throttled.call(obj);
expect(obj.method).toHaveReturnedWith(42);
});
});
@@ -0,0 +1,185 @@
import { isRef, toValue } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import { isFunction, isObject, throttle } from '@robonen/stdlib';
import type { AnyFunction } from '@robonen/stdlib';
export interface UseThrottleFnOptions {
/**
* The window in milliseconds in which `fn` is invoked at most once.
* For event callbacks, values around 100250 (or higher) are most useful.
*
* @default 200
*/
delay?: MaybeRefOrGetter<number>;
/**
* Invoke `fn` again on the trailing edge of the window with the most
* recent arguments.
*
* @default false
*/
trailing?: boolean;
/**
* Invoke `fn` on the leading edge of the window.
*
* @default true
*/
leading?: boolean;
/**
* Reject the promise of a trailing call when it is superseded by a newer
* call or cancelled, instead of silently resolving it.
*
* @default false
*/
rejectOnCancel?: boolean;
}
export type UseThrottleFnReturn<T extends AnyFunction>
= ((this: ThisParameterType<T>, ...args: Parameters<T>) => Promise<Awaited<ReturnType<T>>>) & {
/**
* Cancel a pending trailing invocation. Resolves (or rejects, when
* `rejectOnCancel` is set) the pending promise without calling `fn`.
*/
cancel: () => void;
/**
* Immediately invoke any pending trailing call, resolving its promise.
*/
flush: () => void;
};
function normalizeOptions(
ms: MaybeRefOrGetter<number> | UseThrottleFnOptions,
trailing: boolean,
leading: boolean,
rejectOnCancel: boolean,
): Required<UseThrottleFnOptions> {
// Distinguish an options object from a reactive delay (ref/getter/number).
if (isObject(ms) && !isRef(ms) && !isFunction(ms)) {
const options = ms as UseThrottleFnOptions;
return {
delay: options.delay ?? 200,
trailing: options.trailing ?? false,
leading: options.leading ?? true,
rejectOnCancel: options.rejectOnCancel ?? false,
};
}
return { delay: ms, trailing, leading, rejectOnCancel };
}
/**
* @name useThrottleFn
* @category Reactivity
* @description Throttle execution of a function — a thin reactive wrapper around
* `@robonen/stdlib`'s `throttle`. Invokes `fn` at most once per `delay` window
* and resolves with the wrapped function's result. Especially useful for
* rate-limiting handlers on high-frequency events like `scroll` and `resize`.
*
* Accepts either positional arguments or a single options object, and exposes
* `cancel`/`flush` controls on the returned function.
*
* @param {T} fn The function to throttle
* @param {MaybeRefOrGetter<number> | UseThrottleFnOptions} [ms=200] Window in milliseconds (can be reactive) or an options object
* @param {boolean} [trailing=false] Invoke on the trailing edge of the window
* @param {boolean} [leading=true] Invoke on the leading edge of the window
* @param {boolean} [rejectOnCancel=false] Reject a superseded/cancelled trailing promise instead of resolving it
* @returns {UseThrottleFnReturn<T>} The throttled function with `cancel` and `flush`
*
* @example
* const onScroll = useThrottleFn(() => updatePosition(), 100);
* useEventListener('scroll', onScroll);
*
* @example
* const save = useThrottleFn(persist, { delay: 1000, trailing: true });
* save.cancel();
*
* @since 0.0.15
*/
export function useThrottleFn<T extends AnyFunction>(
fn: T,
options: UseThrottleFnOptions,
): UseThrottleFnReturn<T>;
export function useThrottleFn<T extends AnyFunction>(
fn: T,
ms?: MaybeRefOrGetter<number>,
trailing?: boolean,
leading?: boolean,
rejectOnCancel?: boolean,
): UseThrottleFnReturn<T>;
export function useThrottleFn<T extends AnyFunction>(
fn: T,
ms: MaybeRefOrGetter<number> | UseThrottleFnOptions = 200,
trailing = false,
leading = true,
rejectOnCancel = false,
): UseThrottleFnReturn<T> {
const { delay, trailing: useTrailing, leading: useLeading, rejectOnCancel: useReject }
= normalizeOptions(ms, trailing, leading, rejectOnCancel);
// The latest unsettled promise; superseded/dropped ones are settled early.
let settler: { resolve: (value?: unknown) => void; reject: (reason?: unknown) => void } | undefined;
let invokedSync = false;
function settleCancelled() {
const pending = settler;
settler = undefined;
if (!pending)
return;
if (useReject)
pending.reject(new Error('throttled call cancelled'));
else
pending.resolve(undefined);
}
// The function stdlib throttles: runs `fn` and settles the latest promise.
function run(this: ThisParameterType<T>, ...args: Parameters<T>) {
invokedSync = true;
const pending = settler;
settler = undefined;
try {
const result = fn.apply(this, args) as Awaited<ReturnType<T>>;
pending?.resolve(result);
return result;
}
catch (error) {
pending?.reject(error);
return undefined;
}
}
const throttled = throttle(run as AnyFunction, () => toValue(delay), { leading: useLeading, trailing: useTrailing });
const wrapper = function (this: ThisParameterType<T>, ...args: Parameters<T>) {
return new Promise<Awaited<ReturnType<T>>>((resolve, reject) => {
// A new call supersedes the previous pending (trailing) promise.
settleCancelled();
settler = { resolve: resolve as (value?: unknown) => void, reject };
invokedSync = false;
throttled.apply(this, args);
// If this call neither fired synchronously (leading) nor scheduled a
// trailing edge, it was dropped — settle its promise now to avoid a leak.
if (!invokedSync && !throttled.pending())
settleCancelled();
});
} as UseThrottleFnReturn<T>;
wrapper.cancel = () => {
throttled.cancel();
settleCancelled();
};
wrapper.flush = () => {
throttled.flush();
};
return wrapper;
}
@@ -1,270 +0,0 @@
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();
});
});
@@ -1,111 +0,0 @@
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;
@@ -1,203 +0,0 @@
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);
});
});
@@ -1,170 +0,0 @@
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 };
}
@@ -1,147 +0,0 @@
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');
});
});
@@ -1,65 +0,0 @@
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 });
}
@@ -1,265 +0,0 @@
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();
});
});
@@ -1,126 +0,0 @@
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;
@@ -1,214 +0,0 @@
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();
});
});
@@ -1,96 +0,0 @@
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,
);
}
@@ -1,167 +0,0 @@
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));
});
});
@@ -1,60 +0,0 @@
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;
}