feat(vue): expand @robonen/vue composable collection
Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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 100–250 (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;
|
||||
}
|
||||
Reference in New Issue
Block a user