Files
tools/vue/toolkit/src/composables/reactivity/computedAsync/index.ts
T
robonen 59e995d0b5 feat(vue): expand @robonen/vue composable collection
Composables, tests, category barrels, and README for @robonen/vue.
2026-06-08 15:51:16 +07:00

184 lines
5.5 KiB
TypeScript

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;