From d9e9ee4e7f8b44c428cfcf7c0351e9503f4e48c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:44:02 +0000 Subject: [PATCH] feat(useAsyncState): add abort method for force cancelling pending promises Co-authored-by: robonen <26167508+robonen@users.noreply.github.com> --- .../state/useAsyncState/index.test.ts | 109 ++++++++++++++++++ .../composables/state/useAsyncState/index.ts | 21 +++- 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/web/vue/src/composables/state/useAsyncState/index.test.ts b/web/vue/src/composables/state/useAsyncState/index.test.ts index 5fcd9f2..c0e55c6 100644 --- a/web/vue/src/composables/state/useAsyncState/index.test.ts +++ b/web/vue/src/composables/state/useAsyncState/index.test.ts @@ -206,4 +206,113 @@ 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(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(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((_, 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(resolve => { resolvePromise = resolve; }); + + const { state, isReady, abort, executeImmediately } = useAsyncState( + promiseFn, + 'initial', + { immediate: false }, + ); + + executeImmediately(); + abort(); + + executeImmediately(); + resolvePromise!('new data'); + 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(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); + }); }); diff --git a/web/vue/src/composables/state/useAsyncState/index.ts b/web/vue/src/composables/state/useAsyncState/index.ts index bd380a6..a488fd9 100644 --- a/web/vue/src/composables/state/useAsyncState/index.ts +++ b/web/vue/src/composables/state/useAsyncState/index.ts @@ -19,6 +19,7 @@ export interface UseAsyncStateReturnBase; execute: (delay?: number, ...params: Params) => Promise; executeImmediately: (...params: Params) => Promise; + abort: () => void; } export type UseAsyncStateReturn = @@ -50,7 +51,11 @@ export function useAsyncState { + const currentVersion = ++version; + if (resetOnExecute) state.value = initialState; @@ -65,11 +70,18 @@ export function useAsyncState { + version++; + isLoading.value = false; + }; + if (immediate) execute(); @@ -97,6 +115,7 @@ export function useAsyncState