Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 44 additions & 15 deletions src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
};

Expand All @@ -462,7 +476,7 @@ export function createApiClient<
});
onBeforeUnmount(() => {
if (stopWatcher) stopWatcher();
abortController?.abort();
cancel();
});
}

Expand All @@ -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
Expand Down Expand Up @@ -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?: {
Expand All @@ -524,6 +545,8 @@ export function createApiClient<
}
) => {
if (isLoading.value) return;

abortController = new AbortController();
isLoading.value = true;
errorMessage.value = undefined;
uploadProgress.value = 0;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ export interface QueryResult<TResult> {
isLoading: Ref<boolean>;
isDone: Ref<boolean>;
refetch: () => Promise<void>;
cancel: () => void;
}

/**
Expand All @@ -269,4 +270,5 @@ export interface MutationResult<TResult, TData = any, TParams = any> {
isDone: Ref<boolean>;
uploadProgress: Ref<number>;
mutate: (rgs?: { data?: TData; params?: TParams }) => Promise<void>;
cancel: () => void;
}
92 changes: 92 additions & 0 deletions tests/client-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

});
Loading