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

5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
4d9bd6fea1 feat(vue): use cancellablePromise in useAsyncState for promise cancellation
Co-authored-by: robonen <26167508+robonen@users.noreply.github.com>
2026-02-26 15:34:49 +00:00
copilot-swe-agent[bot]
6b2707e24a fix(stdlib): improve cancellablePromise test to actually verify then callback is not called
Co-authored-by: robonen <26167508+robonen@users.noreply.github.com>
2026-02-26 15:01:56 +00:00
copilot-swe-agent[bot]
da17d2d068 feat(stdlib): add cancellablePromise utility for promise cancellation
Co-authored-by: robonen <26167508+robonen@users.noreply.github.com>
2026-02-26 15:01:02 +00:00
copilot-swe-agent[bot]
d9e9ee4e7f feat(useAsyncState): add abort method for force cancelling pending promises
Co-authored-by: robonen <26167508+robonen@users.noreply.github.com>
2026-02-26 14:44:02 +00:00
copilot-swe-agent[bot]
0b64e91eba Initial plan 2026-02-26 14:39:59 +00:00
5 changed files with 269 additions and 5 deletions

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, vi } from 'vitest';
import { cancellablePromise, CancelledError } from '.';
describe('cancellablePromise', () => {
it('resolve the promise normally when not cancelled', async () => {
const { promise } = cancellablePromise(Promise.resolve('data'));
await expect(promise).resolves.toBe('data');
});
it('reject the promise normally when not cancelled', async () => {
const error = new Error('test-error');
const { promise } = cancellablePromise(Promise.reject(error));
await expect(promise).rejects.toThrow(error);
});
it('reject with CancelledError when cancelled before resolve', async () => {
const { promise, cancel } = cancellablePromise(
new Promise<string>((resolve) => setTimeout(() => resolve('data'), 100)),
);
cancel();
await expect(promise).rejects.toBeInstanceOf(CancelledError);
await expect(promise).rejects.toThrow('Promise was cancelled');
});
it('reject with CancelledError with custom reason', async () => {
const { promise, cancel } = cancellablePromise(
new Promise<string>((resolve) => setTimeout(() => resolve('data'), 100)),
);
cancel('Request aborted');
await expect(promise).rejects.toBeInstanceOf(CancelledError);
await expect(promise).rejects.toThrow('Request aborted');
});
it('cancel prevents then callback from being called', async () => {
const onFulfilled = vi.fn();
const { promise, cancel } = cancellablePromise(
new Promise<string>((resolve) => setTimeout(() => resolve('data'), 100)),
);
const chained = promise.then(onFulfilled).catch(() => {});
cancel();
await chained;
expect(onFulfilled).not.toHaveBeenCalled();
});
it('CancelledError has correct name property', () => {
const error = new CancelledError();
expect(error.name).toBe('CancelledError');
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('Promise was cancelled');
});
it('CancelledError accepts custom message', () => {
const error = new CancelledError('Custom reason');
expect(error.message).toBe('Custom reason');
});
});

View File

@@ -0,0 +1,49 @@
export class CancelledError extends Error {
constructor(reason?: string) {
super(reason ?? 'Promise was cancelled');
this.name = 'CancelledError';
}
}
export interface CancellablePromise<T> {
promise: Promise<T>;
cancel: (reason?: string) => void;
}
/**
* @name cancellablePromise
* @category Async
* @description Wraps a promise with a cancel capability, allowing the promise to be rejected with a CancelledError
*
* @param {Promise<T>} promise - The promise to make cancellable
* @returns {CancellablePromise<T>} - An object with the wrapped promise and a cancel function
*
* @example
* const { promise, cancel } = cancellablePromise(fetch('/api/data'));
* cancel(); // Rejects with CancelledError
*
* @example
* const { promise, cancel } = cancellablePromise(longRunningTask());
* setTimeout(() => cancel('Timeout'), 5000);
* const [error] = await tryIt(() => promise)();
*
* @since 0.0.10
*/
export function cancellablePromise<T>(promise: Promise<T>): CancellablePromise<T> {
let rejectPromise: (reason: CancelledError) => void;
const wrappedPromise = new Promise<T>((resolve, reject) => {
rejectPromise = reject;
promise.then(resolve, reject);
});
const cancel = (reason?: string) => {
rejectPromise(new CancelledError(reason));
};
return {
promise: wrappedPromise,
cancel,
};
}

View File

@@ -1,2 +1,3 @@
export * from './cancellablePromise';
export * from './sleep';
export * from './tryIt';

View File

@@ -22,6 +22,7 @@ describe(useAsyncState, () => {
expect(isLoading.value).toBeTruthy();
expect(error.value).toBe(null);
await nextTick();
await nextTick();
expect(state.value).toBe('data');
@@ -41,6 +42,7 @@ describe(useAsyncState, () => {
expect(isLoading.value).toBeTruthy();
expect(error.value).toBe(null);
await nextTick();
await nextTick();
expect(state.value).toBe('data');
@@ -60,6 +62,7 @@ describe(useAsyncState, () => {
expect(isLoading.value).toBeTruthy();
expect(error.value).toBe(null);
await nextTick();
await nextTick();
expect(state.value).toBe('initial');
@@ -77,6 +80,7 @@ describe(useAsyncState, () => {
{ onSuccess },
);
await nextTick();
await nextTick();
expect(onSuccess).toHaveBeenCalledWith('data');
@@ -92,6 +96,7 @@ describe(useAsyncState, () => {
{ onError },
);
await nextTick();
await nextTick();
expect(onError).toHaveBeenCalledWith(error);
@@ -164,6 +169,7 @@ describe(useAsyncState, () => {
expect(isReady.value).toBeFalsy();
expect(error.value).toBe(null);
await nextTick();
await nextTick();
expect(state.value).toBe('data');
@@ -206,4 +212,114 @@ describe(useAsyncState, () => {
expect(state.value.a).toBe(1);
expect(isShallow(state)).toBeFalsy();
});
it('aborts pending execution', async () => {
let resolvePromise: (value: string) => void;
const promiseFn = () => new Promise<string>(resolve => { resolvePromise = resolve; });
const { state, isLoading, abort, executeImmediately } = useAsyncState(
promiseFn,
'initial',
{ immediate: false },
);
executeImmediately();
expect(isLoading.value).toBeTruthy();
abort();
expect(isLoading.value).toBeFalsy();
resolvePromise!('data');
await nextTick();
expect(state.value).toBe('initial');
});
it('abort prevents state update from resolved promise', async () => {
let resolvePromise: (value: string) => void;
const promiseFn = () => new Promise<string>(resolve => { resolvePromise = resolve; });
const { state, isLoading, isReady, abort, executeImmediately } = useAsyncState(
promiseFn,
'initial',
{ immediate: false },
);
executeImmediately();
expect(isLoading.value).toBeTruthy();
abort();
expect(isLoading.value).toBeFalsy();
resolvePromise!('data');
await nextTick();
expect(state.value).toBe('initial');
expect(isReady.value).toBeFalsy();
});
it('abort prevents error handling from rejected promise', async () => {
let rejectPromise: (error: Error) => void;
const promiseFn = () => new Promise<string>((_, reject) => { rejectPromise = reject; });
const onError = vi.fn();
const { error, abort, executeImmediately } = useAsyncState(
promiseFn,
'initial',
{ immediate: false, onError },
);
executeImmediately();
abort();
rejectPromise!(new Error('test-error'));
await nextTick();
expect(error.value).toBe(null);
expect(onError).not.toHaveBeenCalled();
});
it('new execute after abort works correctly', async () => {
let resolvePromise: (value: string) => void;
const promiseFn = () => new Promise<string>(resolve => { resolvePromise = resolve; });
const { state, isReady, abort, executeImmediately } = useAsyncState(
promiseFn,
'initial',
{ immediate: false },
);
executeImmediately();
abort();
executeImmediately();
resolvePromise!('new data');
await nextTick();
await nextTick();
expect(state.value).toBe('new data');
expect(isReady.value).toBeTruthy();
});
it('re-execute cancels previous pending execution', async () => {
let callCount = 0;
const promiseFn = (value: string) => new Promise<string>(resolve => {
callCount++;
setTimeout(() => resolve(value), 100);
});
const { state, executeImmediately } = useAsyncState(
promiseFn,
'initial',
{ immediate: false },
);
executeImmediately('first');
executeImmediately('second');
await vi.advanceTimersByTimeAsync(100);
expect(state.value).toBe('second');
expect(callCount).toBe(2);
});
});

View File

@@ -1,6 +1,6 @@
import { ref, shallowRef, watch } from 'vue';
import type { Ref, ShallowRef, UnwrapRef } from 'vue';
import { isFunction, sleep } from '@robonen/stdlib';
import { isFunction, sleep, cancellablePromise as makeCancellable, CancelledError } from '@robonen/stdlib';
export interface UseAsyncStateOptions<Shallow extends boolean, Data = any> {
delay?: number;
@@ -19,6 +19,7 @@ export interface UseAsyncStateReturnBase<Data, Params extends any[], Shallow ext
error: Ref<unknown | null>;
execute: (delay?: number, ...params: Params) => Promise<Data>;
executeImmediately: (...params: Params) => Promise<Data>;
abort: () => void;
}
export type UseAsyncStateReturn<Data, Params extends any[], Shallow extends boolean> =
@@ -50,7 +51,14 @@ export function useAsyncState<Data, Params extends any[] = [], Shallow extends b
const isLoading = ref(false);
const isReady = ref(false);
let cancelPending: ((reason?: string) => void) | undefined;
const execute = async (actualDelay = delay, ...params: any[]) => {
cancelPending?.();
let active = true;
cancelPending = () => { active = false; };
if (resetOnExecute)
state.value = initialState;
@@ -61,15 +69,27 @@ export function useAsyncState<Data, Params extends any[] = [], Shallow extends b
if (actualDelay > 0)
await sleep(actualDelay);
const promise = isFunction(maybePromise) ? maybePromise(...params as Params) : maybePromise;
if (!active)
return state.value as Data;
const rawPromise = isFunction(maybePromise) ? maybePromise(...params as Params) : maybePromise;
const { promise, cancel } = makeCancellable(rawPromise);
cancelPending = (reason?: string) => {
active = false;
cancel(reason);
};
try {
const data = await promise;
state.value = data;
isReady.value = true;
onSuccess?.(data);
}
catch (e: unknown) {
if (e instanceof CancelledError)
return state.value as Data;
error.value = e;
onError?.(e);
@@ -77,7 +97,8 @@ export function useAsyncState<Data, Params extends any[] = [], Shallow extends b
throw e;
}
finally {
isLoading.value = false;
if (active)
isLoading.value = false;
}
return state.value as Data;
@@ -87,6 +108,12 @@ export function useAsyncState<Data, Params extends any[] = [], Shallow extends b
return execute(0, ...params);
};
const abort = () => {
cancelPending?.();
cancelPending = undefined;
isLoading.value = false;
};
if (immediate)
execute();
@@ -97,14 +124,17 @@ export function useAsyncState<Data, Params extends any[] = [], Shallow extends b
error,
execute,
executeImmediately,
abort,
};
function waitResolve() {
return new Promise<UseAsyncStateReturnBase<Data, Params, Shallow>>((resolve, reject) => {
watch(
const unwatch = watch(
isLoading,
(loading) => {
if (loading === false) {
unwatch();
if (error.value)
reject(error.value);
else
@@ -113,7 +143,6 @@ export function useAsyncState<Data, Params extends any[] = [], Shallow extends b
},
{
immediate: true,
once: true,
flush: 'sync',
},
);