1
0
mirror of https://github.com/robonen/tools.git synced 2026-03-20 10:54:44 +00:00

Merge pull request #85 from robonen/web/vue/useAsyncState

feat(web/vue): add useAsyncState
This commit is contained in:
2025-07-14 00:51:53 +07:00
committed by GitHub
2 changed files with 296 additions and 22 deletions

View File

@@ -0,0 +1,209 @@
import { isShallow, nextTick, ref } from 'vue';
import { it, expect, describe, vi, beforeEach, afterEach } from 'vitest';
import { useAsyncState } from '.';
describe('useAsyncState', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('works with a promise', async () => {
const { state, isReady, isLoading, error } = useAsyncState(
Promise.resolve('data'),
'initial',
);
expect(state.value).toBe('initial');
expect(isReady.value).toBe(false);
expect(isLoading.value).toBe(true);
expect(error.value).toBe(null);
await nextTick();
expect(state.value).toBe('data');
expect(isReady.value).toBe(true);
expect(isLoading.value).toBe(false);
expect(error.value).toBe(null);
});
it('works with a function returning a promise', async () => {
const { state, isReady, isLoading, error } = useAsyncState(
() => Promise.resolve('data'),
'initial',
);
expect(state.value).toBe('initial');
expect(isReady.value).toBe(false);
expect(isLoading.value).toBe(true);
expect(error.value).toBe(null);
await nextTick();
expect(state.value).toBe('data');
expect(isReady.value).toBe(true);
expect(isLoading.value).toBe(false);
expect(error.value).toBe(null);
});
it('handles errors', async () => {
const { state, isReady, isLoading, error } = useAsyncState(
Promise.reject(new Error('test-error')),
'initial',
);
expect(state.value).toBe('initial');
expect(isReady.value).toBe(false);
expect(isLoading.value).toBe(true);
expect(error.value).toBe(null);
await nextTick();
expect(state.value).toBe('initial');
expect(isReady.value).toBe(false);
expect(isLoading.value).toBe(false);
expect(error.value).toEqual(new Error('test-error'));
});
it('calls onSuccess callback', async () => {
const onSuccess = vi.fn();
useAsyncState(
Promise.resolve('data'),
'initial',
{ onSuccess },
);
await nextTick();
expect(onSuccess).toHaveBeenCalledWith('data');
});
it('calls onError callback', async () => {
const onError = vi.fn();
const error = new Error('test-error');
useAsyncState(
Promise.reject(error),
'initial',
{ onError },
);
await nextTick();
expect(onError).toHaveBeenCalledWith(error);
});
it('throws error if throwError is true', async () => {
const error = new Error('test-error');
const { executeImmediately } = useAsyncState(
Promise.reject(error),
'initial',
{ immediate: false, throwError: true },
);
await expect(() => executeImmediately()).rejects.toThrow(error);
});
it('resets state on execute if resetOnExecute is true', async () => {
const { state, executeImmediately } = useAsyncState(
(data: string) => Promise.resolve(data),
'initial',
{ immediate: false, resetOnExecute: true },
);
await executeImmediately('new data');
expect(state.value).toBe('new data');
executeImmediately('another data');
expect(state.value).toBe('initial');
});
it('delays execution with default delay', async () => {
const { isLoading, execute } = useAsyncState(
() => Promise.resolve('data'),
'initial',
{ delay: 100, immediate: false },
);
const promise = execute();
expect(isLoading.value).toBe(true);
await vi.advanceTimersByTimeAsync(50);
expect(isLoading.value).toBe(true);
await vi.advanceTimersByTimeAsync(50);
await promise;
expect(isLoading.value).toBe(false);
});
it('is awaitable', async () => {
const { state } = await useAsyncState(
Promise.resolve('data'),
'initial',
);
expect(state.value).toBe('data');
});
it('works with executeImmediately', async () => {
const { state, isReady, isLoading, error, executeImmediately } = useAsyncState(
() => Promise.resolve('data'),
'initial',
{ immediate: false },
);
executeImmediately();
expect(state.value).toBe('initial');
expect(isLoading.value).toBe(true);
expect(isReady.value).toBe(false);
expect(error.value).toBe(null);
await nextTick();
expect(state.value).toBe('data');
expect(isReady.value).toBe(true);
expect(isLoading.value).toBe(false);
expect(error.value).toBe(null);
});
it('passes params to the function', async () => {
const promiseFn = vi.fn((...args: any[]) => Promise.resolve(args.join(' ')));
const { executeImmediately } = useAsyncState(
promiseFn,
'initial',
{ immediate: false },
);
await executeImmediately('hello', 'world');
expect(promiseFn).toHaveBeenCalledWith('hello', 'world');
});
it('uses shallowRef by default', async () => {
const { state } = await useAsyncState(
Promise.resolve({ a: 1 }),
{ a: 0 },
);
expect(state.value.a).toBe(1);
expect(isShallow(state)).toBe(true);
});
it('uses ref when shallow is false', async () => {
const { state } = await useAsyncState(
Promise.resolve({ a: ref(1) }),
{ a: ref(0) },
{ shallow: false },
);
expect(state.value.a).toBe(1);
expect(isShallow(state)).toBe(false);
});
});

View File

@@ -1,13 +1,8 @@
import { ref, shallowRef } from 'vue'; import { ref, shallowRef, watch, type Ref, type ShallowRef, type UnwrapRef } from 'vue';
import { isFunction } from '@robonen/stdlib'; import { isFunction, sleep } from '@robonen/stdlib';
export enum AsyncStateStatus {
PENDING,
FULFILLED,
REJECTED,
}
export interface UseAsyncStateOptions<Shallow extends boolean, Data = any> { export interface UseAsyncStateOptions<Shallow extends boolean, Data = any> {
delay?: number;
shallow?: Shallow; shallow?: Shallow;
immediate?: boolean; immediate?: boolean;
resetOnExecute?: boolean; resetOnExecute?: boolean;
@@ -16,6 +11,19 @@ export interface UseAsyncStateOptions<Shallow extends boolean, Data = any> {
onSuccess?: (data: Data) => void; onSuccess?: (data: Data) => void;
} }
export interface UseAsyncStateReturnBase<Data, Params extends any[], Shallow extends boolean> {
state: Shallow extends true ? ShallowRef<Data> : Ref<UnwrapRef<Data>>;
isLoading: Ref<boolean>;
isReady: Ref<boolean>;
error: Ref<unknown | null>;
execute: (delay?: number, ...params: Params) => Promise<Data>;
executeImmediately: (...params: Params) => Promise<Data>;
}
export type UseAsyncStateReturn<Data, Params extends any[], Shallow extends boolean> =
& UseAsyncStateReturnBase<Data, Params, Shallow>
& PromiseLike<UseAsyncStateReturnBase<Data, Params, Shallow>>;
/** /**
* @name useAsyncState * @name useAsyncState
* @category State * @category State
@@ -25,35 +33,92 @@ export function useAsyncState<Data, Params extends any[] = [], Shallow extends b
maybePromise: Promise<Data> | ((...args: Params) => Promise<Data>), maybePromise: Promise<Data> | ((...args: Params) => Promise<Data>),
initialState: Data, initialState: Data,
options?: UseAsyncStateOptions<Shallow, Data>, options?: UseAsyncStateOptions<Shallow, Data>,
) { ): UseAsyncStateReturn<Data, Params, Shallow> {
const state = options?.shallow ? shallowRef(initialState) : ref(initialState); const {
const status = ref<AsyncStateStatus | null>(null); delay = 0,
shallow = true,
immediate = true,
resetOnExecute = false,
throwError = false,
onError,
onSuccess,
} = options ?? {};
const execute = async (...params: any[]) => { const state = shallow ? shallowRef(initialState) : ref(initialState);
if (options?.resetOnExecute) const error = ref<unknown | null>(null);
const isLoading = ref(false);
const isReady = ref(false);
const execute = async (actualDelay = delay, ...params: any[]) => {
if (resetOnExecute)
state.value = initialState; state.value = initialState;
status.value = AsyncStateStatus.PENDING; isLoading.value = true;
isReady.value = false;
error.value = null;
if (actualDelay > 0)
await sleep(actualDelay);
const promise = isFunction(maybePromise) ? maybePromise(...params as Params) : maybePromise; const promise = isFunction(maybePromise) ? maybePromise(...params as Params) : maybePromise;
try { try {
const data = await promise; const data = await promise;
state.value = data; state.value = data;
status.value = AsyncStateStatus.FULFILLED; isReady.value = true;
options?.onSuccess?.(data); onSuccess?.(data);
} }
catch (error) { catch (e: unknown) {
status.value = AsyncStateStatus.REJECTED; error.value = e;
options?.onError?.(error); onError?.(e);
if (options?.throwError) if (throwError)
throw error; throw e;
}
finally {
isLoading.value = false;
} }
return state.value as Data; return state.value as Data;
}; };
if (options?.immediate) const executeImmediately = (...params: Params) => {
return execute(0, ...params);
};
if (immediate)
execute(); execute();
const shell = {
state: state as Shallow extends true ? ShallowRef<Data> : Ref<UnwrapRef<Data>>,
isLoading,
isReady,
error,
execute,
executeImmediately,
};
function waitResolve() {
return new Promise<UseAsyncStateReturnBase<Data, Params, Shallow>>((resolve, reject) => {
watch(
isLoading,
(loading) => {
if (loading === false)
error.value ? reject(error.value) : resolve(shell);
},
{
immediate: true,
once: true,
flush: 'sync',
},
);
});
}
return {
...shell,
then(onFulfilled, onRejected) {
return waitResolve().then(onFulfilled, onRejected);
},
}
} }