From 229f2e9004dfb76b0309ce92758d47cfa5d229f5 Mon Sep 17 00:00:00 2001 From: MelvishNiz <48862920+MelvishNiz@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:07:06 +0700 Subject: [PATCH] feat: add manual request cancellation --- src/core/client.ts | 59 +++++++++++++++++------ src/core/types.ts | 2 + tests/client-runtime.test.ts | 92 ++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 15 deletions(-) diff --git a/src/core/client.ts b/src/core/client.ts index 7843268..674ebb2 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -23,6 +23,10 @@ function isApiMutation(obj: any): obj is ApiMutation { return obj && typeof obj === 'object' && obj !== null && typeof obj.path === 'string' && typeof obj.method === 'string'; } +function isCanceledError(error: any): boolean { + return error?.code === "ERR_CANCELED" || error?.name === "CanceledError"; +} + /** * Recursively flatten nested objects for FormData with bracket notation * @param formData - The FormData instance to append to @@ -328,17 +332,22 @@ export function createApiClient< const isLoading = ref(false); const isDone = ref(false); const isFirstLoad = ref(true); - let abortController = new AbortController(); + let abortController: AbortController | null = null; + let activeRequestId = 0; const cancel = () => { abortController?.abort(); - abortController = new AbortController(); + abortController = null; + isLoading.value = false; }; const refetch = async () => { if (isLoading.value) { cancel(); } + + const requestId = ++activeRequestId; + abortController = new AbortController(); isLoading.value = true; errorMessage.value = undefined; @@ -399,17 +408,19 @@ export function createApiClient< data.value = parsedData; queryOptions?.onResult?.(parsedData); } catch (err: any) { + if (isCanceledError(err)) { + return; + } + if (err instanceof AxiosError) { - if (err.code !== "ERR_CANCELED") { - const message = err.response?.data?.message || err.message || "An error occurred"; - errorMessage.value = message; + const message = err.response?.data?.message || err.message || "An error occurred"; + errorMessage.value = message; - // Call local error handler - queryOptions?.onError?.(err); + // Call local error handler + queryOptions?.onError?.(err); - // Call global error handler - options.onError?.({ err, message }); - } + // Call global error handler + options.onError?.({ err, message }); } else if (err instanceof ZodError) { console.warn("Zod validation error at:", q.method, q.path); // Handle Zod validation errors @@ -438,8 +449,11 @@ export function createApiClient< options.onError?.({ err, message }); } } finally { - isLoading.value = false; - isDone.value = true; + if (requestId === activeRequestId) { + isLoading.value = false; + isDone.value = true; + abortController = null; + } } }; @@ -462,7 +476,7 @@ export function createApiClient< }); onBeforeUnmount(() => { if (stopWatcher) stopWatcher(); - abortController?.abort(); + cancel(); }); } @@ -475,7 +489,7 @@ export function createApiClient< } } - return { result: data, errorMessage, zodError, isLoading, isDone, refetch }; + return { result: data, errorMessage, zodError, isLoading, isDone, refetch, cancel }; }; } else if (typeof value === 'object') { // It's a nested structure, recurse @@ -516,6 +530,13 @@ export function createApiClient< const isLoading = ref(false); const isDone = ref(false); const uploadProgress = ref(0); + let abortController: AbortController | null = null; + + const cancel = () => { + abortController?.abort(); + abortController = null; + isLoading.value = false; + }; const mutate = async ( args?: { @@ -524,6 +545,8 @@ export function createApiClient< } ) => { if (isLoading.value) return; + + abortController = new AbortController(); isLoading.value = true; errorMessage.value = undefined; uploadProgress.value = 0; @@ -563,6 +586,7 @@ export function createApiClient< data: requestData, params: params, headers, + signal: abortController.signal, onUploadProgress: (progressEvent: AxiosProgressEvent) => { if (progressEvent.total) { const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total); @@ -603,6 +627,10 @@ export function createApiClient< data.value = parsedData; mutationOptions?.onResult?.(parsedData); } catch (err: any) { + if (isCanceledError(err)) { + return; + } + if (err instanceof AxiosError) { const message = err.response?.data?.message || err.message || "An error occurred"; errorMessage.value = message; @@ -641,10 +669,11 @@ export function createApiClient< } finally { isLoading.value = false; isDone.value = true; + abortController = null; } }; - return { result: data, errorMessage, zodError, isLoading, isDone, uploadProgress, mutate }; + return { result: data, errorMessage, zodError, isLoading, isDone, uploadProgress, mutate, cancel }; }; } else if (typeof value === 'object') { // It's a nested structure, recurse diff --git a/src/core/types.ts b/src/core/types.ts index d90d7c5..225a7e3 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -247,6 +247,7 @@ export interface QueryResult { isLoading: Ref; isDone: Ref; refetch: () => Promise; + cancel: () => void; } /** @@ -269,4 +270,5 @@ export interface MutationResult { isDone: Ref; uploadProgress: Ref; mutate: (rgs?: { data?: TData; params?: TParams }) => Promise; + cancel: () => void; } diff --git a/tests/client-runtime.test.ts b/tests/client-runtime.test.ts index c431e78..cc35794 100644 --- a/tests/client-runtime.test.ts +++ b/tests/client-runtime.test.ts @@ -527,4 +527,96 @@ describe("createApiClient - Runtime Behavior", () => { await expect(errorInterceptor.error(error)).rejects.toEqual(error); }); }); + describe("Manual cancellation", () => { + it("should expose cancel for queries and abort the active query request", async () => { + let requestConfig: any; + mockAxiosInstance.request.mockImplementation((config: any) => { + requestConfig = config; + return new Promise(() => {}); + }); + + const api = createApiClient({ + baseURL: "https://api.example.com", + queries: { + getUser: { + path: "/users/{id}", + params: z.object({ id: z.number() }), + }, + }, + }); + + const { refetch, cancel, isLoading } = api.query.getUser({ params: { id: 1 }, loadOnMount: false }); + const pending = refetch(); + await Promise.resolve(); + + expect(isLoading.value).toBe(true); + expect(requestConfig.signal).toBeInstanceOf(AbortSignal); + expect(requestConfig.signal.aborted).toBe(false); + + cancel(); + + expect(requestConfig.signal.aborted).toBe(true); + expect(isLoading.value).toBe(false); + void pending; + }); + + it("should expose cancel for mutations and abort the active mutation request", async () => { + let requestConfig: any; + mockAxiosInstance.request.mockImplementation((config: any) => { + requestConfig = config; + return new Promise(() => {}); + }); + + const api = createApiClient({ + baseURL: "https://api.example.com", + mutations: { + createUser: { + method: "POST", + path: "/users", + data: z.object({ name: z.string() }), + }, + }, + }); + + const { mutate, cancel, isLoading } = api.mutation.createUser(); + const pending = mutate({ data: { name: "John" } }); + await Promise.resolve(); + + expect(isLoading.value).toBe(true); + expect(requestConfig.signal).toBeInstanceOf(AbortSignal); + expect(requestConfig.signal.aborted).toBe(false); + + cancel(); + + expect(requestConfig.signal.aborted).toBe(true); + expect(isLoading.value).toBe(false); + void pending; + }); + + it("should not call mutation error handlers for canceled requests", async () => { + const onError = vi.fn(); + const globalOnError = vi.fn(); + mockAxiosInstance.request.mockRejectedValue({ code: "ERR_CANCELED", message: "canceled" }); + + const api = createApiClient({ + baseURL: "https://api.example.com", + onError: globalOnError, + mutations: { + createUser: { + method: "POST", + path: "/users", + data: z.object({ name: z.string() }), + }, + }, + }); + + const { mutate, errorMessage } = api.mutation.createUser({ onError }); + await mutate({ data: { name: "John" } }); + + expect(errorMessage.value).toBeUndefined(); + expect(onError).not.toHaveBeenCalled(); + expect(globalOnError).not.toHaveBeenCalled(); + }); + }); + });