feat, standardize abort signal

This commit is contained in:
Rasmus Widing
2025-09-18 14:21:58 +03:00
parent 31cf56a685
commit ddbf05862e
13 changed files with 212 additions and 102 deletions

View File

@@ -52,7 +52,8 @@ export const knowledgeKeys = {
export function useKnowledgeItem(sourceId: string | null) {
return useQuery<KnowledgeItem>({
queryKey: sourceId ? knowledgeKeys.detail(sourceId) : DISABLED_QUERY_KEY,
queryFn: () => (sourceId ? knowledgeService.getKnowledgeItem(sourceId) : Promise.reject("No source ID")),
queryFn: ({ signal }) =>
sourceId ? knowledgeService.getKnowledgeItem(sourceId, signal) : Promise.reject("No source ID"),
enabled: !!sourceId,
staleTime: STALE_TIMES.normal,
});
@@ -69,13 +70,17 @@ export function useKnowledgeItemChunks(
// See PRPs/local/frontend-state-management-refactor.md Phase 4: Configure Request Deduplication
return useQuery({
queryKey: sourceId ? knowledgeKeys.chunks(sourceId, opts) : DISABLED_QUERY_KEY,
queryFn: () =>
queryFn: ({ signal }) =>
sourceId
? knowledgeService.getKnowledgeItemChunks(sourceId, {
domainFilter: opts?.domain,
limit: opts?.limit,
offset: opts?.offset,
})
? knowledgeService.getKnowledgeItemChunks(
sourceId,
{
domainFilter: opts?.domain,
limit: opts?.limit,
offset: opts?.offset,
},
signal,
)
: Promise.reject("No source ID"),
enabled: !!sourceId,
staleTime: STALE_TIMES.normal,
@@ -88,7 +93,8 @@ export function useKnowledgeItemChunks(
export function useCodeExamples(sourceId: string | null) {
return useQuery({
queryKey: sourceId ? knowledgeKeys.codeExamples(sourceId) : DISABLED_QUERY_KEY,
queryFn: () => (sourceId ? knowledgeService.getCodeExamples(sourceId) : Promise.reject("No source ID")),
queryFn: ({ signal }) =>
sourceId ? knowledgeService.getCodeExamples(sourceId, undefined, signal) : Promise.reject("No source ID"),
enabled: !!sourceId,
staleTime: STALE_TIMES.normal,
});
@@ -776,7 +782,7 @@ export function useKnowledgeSummaries(filter?: KnowledgeItemsFilter) {
const summaryQuery = useQuery<KnowledgeItemsResponse>({
queryKey: knowledgeKeys.summaries(filter),
queryFn: () => knowledgeService.getKnowledgeSummaries(filter),
queryFn: ({ signal }) => knowledgeService.getKnowledgeSummaries(filter, signal),
refetchInterval: hasActiveOperations ? refetchInterval : false, // Poll when ANY operations are active
refetchOnWindowFocus: true,
staleTime: STALE_TIMES.normal, // Consider data stale after 30 seconds
@@ -834,12 +840,16 @@ export function useKnowledgeChunks(
queryKey: sourceId
? knowledgeKeys.chunks(sourceId, { limit: options?.limit, offset: options?.offset })
: DISABLED_QUERY_KEY,
queryFn: () =>
queryFn: ({ signal }) =>
sourceId
? knowledgeService.getKnowledgeItemChunks(sourceId, {
limit: options?.limit,
offset: options?.offset,
})
? knowledgeService.getKnowledgeItemChunks(
sourceId,
{
limit: options?.limit,
offset: options?.offset,
},
signal,
)
: Promise.reject("No source ID"),
enabled: options?.enabled !== false && !!sourceId,
staleTime: STALE_TIMES.normal,
@@ -857,12 +867,16 @@ export function useKnowledgeCodeExamples(
queryKey: sourceId
? knowledgeKeys.codeExamples(sourceId, { limit: options?.limit, offset: options?.offset })
: DISABLED_QUERY_KEY,
queryFn: () =>
queryFn: ({ signal }) =>
sourceId
? knowledgeService.getCodeExamples(sourceId, {
limit: options?.limit,
offset: options?.offset,
})
? knowledgeService.getCodeExamples(
sourceId,
{
limit: options?.limit,
offset: options?.offset,
},
signal,
)
: Promise.reject("No source ID"),
enabled: options?.enabled !== false && !!sourceId,
staleTime: STALE_TIMES.normal,

View File

@@ -41,7 +41,7 @@ export function useInspectorPagination({
...knowledgeKeys.detail(sourceId),
viewMode === "documents" ? "chunks-infinite" : "code-examples-infinite",
],
queryFn: ({ pageParam }: { pageParam: unknown }) => {
queryFn: ({ pageParam, signal }: { pageParam: unknown; signal?: AbortSignal }) => {
const page = Number(pageParam) || 0;
const service =
viewMode === "documents" ? knowledgeService.getKnowledgeItemChunks : knowledgeService.getCodeExamples;
@@ -49,7 +49,7 @@ export function useInspectorPagination({
return service(sourceId, {
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
});
}, signal);
},
getNextPageParam: (lastPage, allPages) => {
const hasMore = (lastPage as ChunksResponse | CodeExamplesResponse)?.has_more;

View File

@@ -25,7 +25,7 @@ export const knowledgeService = {
* Get lightweight summaries of knowledge items
* Use this for card displays and frequent updates
*/
async getKnowledgeSummaries(filter?: KnowledgeItemsFilter): Promise<KnowledgeItemsResponse> {
async getKnowledgeSummaries(filter?: KnowledgeItemsFilter, signal?: AbortSignal): Promise<KnowledgeItemsResponse> {
const params = new URLSearchParams();
if (filter?.page) params.append("page", filter.page.toString());
@@ -41,21 +41,22 @@ export const knowledgeService = {
const queryString = params.toString();
const endpoint = `/api/knowledge-items/summary${queryString ? `?${queryString}` : ""}`;
return callAPIWithETag<KnowledgeItemsResponse>(endpoint);
return callAPIWithETag<KnowledgeItemsResponse>(endpoint, { signal });
},
/**
* Get a specific knowledge item
*/
async getKnowledgeItem(sourceId: string): Promise<KnowledgeItem> {
return callAPIWithETag<KnowledgeItem>(`/api/knowledge-items/${sourceId}`);
async getKnowledgeItem(sourceId: string, signal?: AbortSignal): Promise<KnowledgeItem> {
return callAPIWithETag<KnowledgeItem>(`/api/knowledge-items/${sourceId}`, { signal });
},
/**
* Delete a knowledge item
*/
async deleteKnowledgeItem(sourceId: string): Promise<{ success: boolean; message: string }> {
async deleteKnowledgeItem(sourceId: string, signal?: AbortSignal): Promise<{ success: boolean; message: string }> {
const response = await callAPIWithETag<{ success: boolean; message: string }>(`/api/knowledge-items/${sourceId}`, {
signal,
method: "DELETE",
});
@@ -68,8 +69,10 @@ export const knowledgeService = {
async updateKnowledgeItem(
sourceId: string,
updates: Partial<KnowledgeItem> & { tags?: string[] },
signal?: AbortSignal,
): Promise<KnowledgeItem> {
const response = await callAPIWithETag<KnowledgeItem>(`/api/knowledge-items/${sourceId}`, {
signal,
method: "PUT",
body: JSON.stringify(updates),
});
@@ -80,8 +83,9 @@ export const knowledgeService = {
/**
* Start crawling a URL
*/
async crawlUrl(request: CrawlRequest): Promise<CrawlStartResponse> {
async crawlUrl(request: CrawlRequest, signal?: AbortSignal): Promise<CrawlStartResponse> {
const response = await callAPIWithETag<CrawlStartResponse>("/api/knowledge-items/crawl", {
signal,
method: "POST",
body: JSON.stringify(request),
});
@@ -92,8 +96,9 @@ export const knowledgeService = {
/**
* Refresh an existing knowledge item
*/
async refreshKnowledgeItem(sourceId: string): Promise<RefreshResponse> {
async refreshKnowledgeItem(sourceId: string, signal?: AbortSignal): Promise<RefreshResponse> {
const response = await callAPIWithETag<RefreshResponse>(`/api/knowledge-items/${sourceId}/refresh`, {
signal,
method: "POST",
});
@@ -106,6 +111,7 @@ export const knowledgeService = {
async uploadDocument(
file: File,
metadata: UploadMetadata,
signal?: AbortSignal,
): Promise<{ success: boolean; progressId: string; message: string; filename: string }> {
const formData = new FormData();
formData.append("file", file);
@@ -129,7 +135,7 @@ export const knowledgeService = {
const response = await fetch(uploadUrl, {
method: "POST",
body: formData,
signal: AbortSignal.timeout(30000), // 30 second timeout for file uploads
signal: signal ?? AbortSignal.timeout(30000), // 30 second timeout for file uploads
});
if (!response.ok) {
@@ -143,8 +149,9 @@ export const knowledgeService = {
/**
* Stop a running crawl
*/
async stopCrawl(progressId: string): Promise<{ success: boolean; message: string }> {
async stopCrawl(progressId: string, signal?: AbortSignal): Promise<{ success: boolean; message: string }> {
return callAPIWithETag<{ success: boolean; message: string }>(`/api/knowledge-items/stop/${progressId}`, {
signal,
method: "POST",
});
},
@@ -159,6 +166,7 @@ export const knowledgeService = {
limit?: number;
offset?: number;
},
signal?: AbortSignal,
): Promise<ChunksResponse> {
const params = new URLSearchParams();
if (options?.domainFilter) {
@@ -174,7 +182,7 @@ export const knowledgeService = {
const queryString = params.toString();
const endpoint = `/api/knowledge-items/${sourceId}/chunks${queryString ? `?${queryString}` : ""}`;
return callAPIWithETag<ChunksResponse>(endpoint);
return callAPIWithETag<ChunksResponse>(endpoint, { signal });
},
/**
@@ -186,6 +194,7 @@ export const knowledgeService = {
limit?: number;
offset?: number;
},
signal?: AbortSignal,
): Promise<CodeExamplesResponse> {
const params = new URLSearchParams();
if (options?.limit !== undefined) {
@@ -198,14 +207,15 @@ export const knowledgeService = {
const queryString = params.toString();
const endpoint = `/api/knowledge-items/${sourceId}/code-examples${queryString ? `?${queryString}` : ""}`;
return callAPIWithETag<CodeExamplesResponse>(endpoint);
return callAPIWithETag<CodeExamplesResponse>(endpoint, { signal });
},
/**
* Search the knowledge base
*/
async searchKnowledgeBase(options: SearchOptions): Promise<SearchResultsResponse> {
async searchKnowledgeBase(options: SearchOptions, signal?: AbortSignal): Promise<SearchResultsResponse> {
return callAPIWithETag<SearchResultsResponse>("/api/knowledge-items/search", {
signal,
method: "POST",
body: JSON.stringify(options),
});
@@ -214,7 +224,7 @@ export const knowledgeService = {
/**
* Get available knowledge sources
*/
async getKnowledgeSources(): Promise<KnowledgeSource[]> {
return callAPIWithETag<KnowledgeSource[]>("/api/knowledge-items/sources");
async getKnowledgeSources(signal?: AbortSignal): Promise<KnowledgeSource[]> {
return callAPIWithETag<KnowledgeSource[]>("/api/knowledge-items/sources", { signal });
},
};

View File

@@ -77,7 +77,7 @@ describe("useProgressQueries", () => {
await waitFor(() => {
expect(result.current.data).toEqual(mockProgress);
expect(progressService.getProgress).toHaveBeenCalledWith("progress-123");
expect(progressService.getProgress).toHaveBeenCalledWith("progress-123", expect.any(AbortSignal));
});
});

View File

@@ -49,11 +49,11 @@ export function useOperationProgress(
const query = useQuery<ProgressResponse | null>({
queryKey: progressId ? progressKeys.detail(progressId) : DISABLED_QUERY_KEY,
queryFn: async () => {
queryFn: async ({ signal }) => {
if (!progressId) throw new Error("No progress ID");
try {
const data = await progressService.getProgress(progressId);
const data = await progressService.getProgress(progressId, signal);
consecutiveNotFound.current = 0; // Reset counter on success
return data;
} catch (error: unknown) {
@@ -198,7 +198,7 @@ export function useActiveOperations(enabled = false) {
return useQuery<ActiveOperationsResponse>({
queryKey: progressKeys.active(),
queryFn: () => progressService.listActiveOperations(),
queryFn: ({ signal }) => progressService.listActiveOperations(signal),
enabled,
refetchInterval: enabled ? refetchInterval : false, // Only poll when explicitly enabled, pause when hidden
staleTime: STALE_TIMES.realtime, // Near real-time for active operations
@@ -250,9 +250,9 @@ export function useMultipleOperations(
const queries = useQueries({
queries: progressIds.map((progressId) => ({
queryKey: progressKeys.detail(progressId),
queryFn: async (): Promise<ProgressResponse | null> => {
queryFn: async ({ signal }): Promise<ProgressResponse | null> => {
try {
const data = await progressService.getProgress(progressId);
const data = await progressService.getProgress(progressId, signal);
notFoundCounts.current.set(progressId, 0); // Reset counter on success
return data;
} catch (error: unknown) {

View File

@@ -10,15 +10,15 @@ export const progressService = {
/**
* Get progress for an operation
*/
async getProgress(progressId: string): Promise<ProgressResponse> {
return callAPIWithETag<ProgressResponse>(`/api/progress/${progressId}`);
async getProgress(progressId: string, signal?: AbortSignal): Promise<ProgressResponse> {
return callAPIWithETag<ProgressResponse>(`/api/progress/${progressId}`, { signal });
},
/**
* List all active operations
*/
async listActiveOperations(): Promise<ActiveOperationsResponse> {
async listActiveOperations(signal?: AbortSignal): Promise<ActiveOperationsResponse> {
// IMPORTANT: Use trailing slash to avoid FastAPI redirect that breaks in Docker
return callAPIWithETag<ActiveOperationsResponse>("/api/progress/");
return callAPIWithETag<ActiveOperationsResponse>("/api/progress/", { signal });
},
};

View File

@@ -20,9 +20,9 @@ export const documentKeys = {
export function useProjectDocuments(projectId: string | undefined) {
return useQuery({
queryKey: projectId ? documentKeys.byProject(projectId) : DISABLED_QUERY_KEY,
queryFn: async () => {
queryFn: async ({ signal }) => {
if (!projectId) return [];
const project = await projectService.getProject(projectId);
const project = await projectService.getProject(projectId, signal);
return (project.docs || []) as ProjectDocument[];
},
enabled: !!projectId,

View File

@@ -27,7 +27,7 @@ export function useProjects() {
return useQuery<Project[]>({
queryKey: projectKeys.lists(),
queryFn: () => projectService.listProjects(),
queryFn: ({ signal }) => projectService.listProjects(signal),
refetchInterval, // Smart interval based on page visibility/focus
refetchOnWindowFocus: true, // Refetch immediately when tab gains focus (ETag makes this cheap)
staleTime: STALE_TIMES.normal,
@@ -40,7 +40,8 @@ export function useProjectFeatures(projectId: string | undefined) {
// See PRPs/local/frontend-state-management-refactor.md Phase 4: Configure Request Deduplication
return useQuery({
queryKey: projectId ? projectKeys.features(projectId) : DISABLED_QUERY_KEY,
queryFn: () => (projectId ? projectService.getProjectFeatures(projectId) : Promise.reject("No project ID")),
queryFn: ({ signal }) =>
projectId ? projectService.getProjectFeatures(projectId, signal) : Promise.reject("No project ID"),
enabled: !!projectId,
staleTime: STALE_TIMES.normal,
});

View File

@@ -13,10 +13,10 @@ export const projectService = {
/**
* Get all projects
*/
async listProjects(): Promise<Project[]> {
async listProjects(signal?: AbortSignal): Promise<Project[]> {
try {
// Fetching projects from API
const response = await callAPIWithETag<{ projects: Project[] }>("/api/projects");
const response = await callAPIWithETag<{ projects: Project[] }>("/api/projects", { signal });
// API response received
const projects = response.projects || [];
@@ -41,6 +41,10 @@ export const projectService = {
// All projects processed
return processedProjects;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.debug(`Request cancelled: list projects`);
throw error;
}
console.error("Failed to list projects:", error);
throw error;
}
@@ -49,9 +53,9 @@ export const projectService = {
/**
* Get a specific project by ID
*/
async getProject(projectId: string): Promise<Project> {
async getProject(projectId: string, signal?: AbortSignal): Promise<Project> {
try {
const project = await callAPIWithETag<Project>(`/api/projects/${projectId}`);
const project = await callAPIWithETag<Project>(`/api/projects/${projectId}`, { signal });
return {
...project,
@@ -59,6 +63,10 @@ export const projectService = {
updated: project.updated || formatRelativeTime(project.updated_at),
};
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.debug(`Request cancelled: get project ${projectId}`);
throw error;
}
console.error(`Failed to get project ${projectId}:`, error);
throw error;
}
@@ -67,7 +75,10 @@ export const projectService = {
/**
* Create a new project
*/
async createProject(projectData: CreateProjectRequest): Promise<{
async createProject(
projectData: CreateProjectRequest,
signal?: AbortSignal,
): Promise<{
project_id: string;
project: Project;
status: string;
@@ -90,6 +101,7 @@ export const projectService = {
status: string;
message: string;
}>("/api/projects", {
signal,
method: "POST",
body: JSON.stringify(validation.data),
});
@@ -97,6 +109,10 @@ export const projectService = {
// Project creation response received
return response;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.debug(`Request cancelled: create project`);
throw error;
}
console.error("[PROJECT SERVICE] Failed to initiate project creation:", error);
if (error instanceof Error) {
console.error("[PROJECT SERVICE] Error details:", {
@@ -111,7 +127,7 @@ export const projectService = {
/**
* Update an existing project
*/
async updateProject(projectId: string, updates: UpdateProjectRequest): Promise<Project> {
async updateProject(projectId: string, updates: UpdateProjectRequest, signal?: AbortSignal): Promise<Project> {
// Validate input
// Updating project with provided data
const validation = validateUpdateProject(updates);
@@ -123,6 +139,7 @@ export const projectService = {
try {
// Sending update request to API
const project = await callAPIWithETag<Project>(`/api/projects/${projectId}`, {
signal,
method: "PUT",
body: JSON.stringify(validation.data),
});
@@ -141,6 +158,10 @@ export const projectService = {
return processedProject;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.debug(`Request cancelled: update project ${projectId}`);
throw error;
}
console.error(`Failed to update project ${projectId}:`, error);
throw error;
}
@@ -149,12 +170,17 @@ export const projectService = {
/**
* Delete a project
*/
async deleteProject(projectId: string): Promise<void> {
async deleteProject(projectId: string, signal?: AbortSignal): Promise<void> {
try {
await callAPIWithETag(`/api/projects/${projectId}`, {
signal,
method: "DELETE",
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.debug(`Request cancelled: delete project ${projectId}`);
throw error;
}
console.error(`Failed to delete project ${projectId}:`, error);
throw error;
}
@@ -163,14 +189,21 @@ export const projectService = {
/**
* Get features from a project's features JSONB field
*/
async getProjectFeatures(projectId: string): Promise<{ features: ProjectFeatures; count: number }> {
async getProjectFeatures(
projectId: string,
signal?: AbortSignal,
): Promise<{ features: ProjectFeatures; count: number }> {
try {
const response = await callAPIWithETag<{
features: ProjectFeatures;
count: number;
}>(`/api/projects/${projectId}/features`);
}>(`/api/projects/${projectId}/features`, { signal });
return response;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.debug(`Request cancelled: get project features ${projectId}`);
throw error;
}
console.error(`Failed to get features for project ${projectId}:`, error);
throw error;
}

View File

@@ -92,7 +92,7 @@ describe("useTaskQueries", () => {
expect(result.current.data).toEqual(mockTasks);
});
expect(taskService.getTasksByProject).toHaveBeenCalledWith("project-123");
expect(taskService.getTasksByProject).toHaveBeenCalledWith("project-123", expect.any(AbortSignal));
});
it("should not fetch tasks when projectId is undefined", () => {
@@ -153,7 +153,7 @@ describe("useTaskQueries", () => {
description: "New Description",
status: "todo",
assignee: "User",
});
}, undefined);
});
});
@@ -193,7 +193,7 @@ describe("useTaskQueries", () => {
project_id: "project-123",
title: "Minimal Task",
description: "",
});
}, undefined);
});
it("should rollback on error", async () => {

View File

@@ -1,5 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createOptimisticEntity, replaceOptimisticEntity, removeDuplicateEntities, type OptimisticEntity } from "@/features/shared/optimistic";
import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createOptimisticEntity,
type OptimisticEntity,
removeDuplicateEntities,
replaceOptimisticEntity,
} from "@/features/shared/optimistic";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../../shared/queryPatterns";
import { useSmartPolling } from "../../../ui/hooks";
import { useToast } from "../../../ui/hooks/useToast";
@@ -19,15 +24,19 @@ export const taskKeys = {
export function useProjectTasks(projectId: string | undefined, enabled = true) {
const { refetchInterval } = useSmartPolling(2000); // 2s active per guideline for real-time task updates
// Check if there's an update mutation in progress for this project
const isMutating = useIsMutating({ mutationKey: ['updateTask', projectId] });
return useQuery<Task[]>({
queryKey: projectId ? taskKeys.byProject(projectId) : DISABLED_QUERY_KEY,
queryFn: async () => {
queryFn: async ({ signal }) => {
if (!projectId) throw new Error("No project ID");
return taskService.getTasksByProject(projectId);
return taskService.getTasksByProject(projectId, signal);
},
enabled: !!projectId && enabled,
refetchInterval, // Smart interval based on page visibility/focus
refetchOnWindowFocus: true, // Refetch immediately when tab gains focus (ETag makes this cheap)
// Pause polling while mutation is in progress to avoid overwriting optimistic updates
refetchInterval: isMutating ? false : refetchInterval,
refetchOnWindowFocus: !isMutating, // Don't refetch on focus during mutations
staleTime: STALE_TIMES.frequent,
});
}
@@ -36,7 +45,7 @@ export function useProjectTasks(projectId: string | undefined, enabled = true) {
export function useTaskCounts() {
return useQuery<Awaited<ReturnType<typeof taskService.getTaskCountsForAllProjects>>>({
queryKey: taskKeys.counts(),
queryFn: () => taskService.getTaskCountsForAllProjects(),
queryFn: ({ signal }) => taskService.getTaskCountsForAllProjects(signal),
refetchInterval: false, // Don't poll, only refetch manually
staleTime: STALE_TIMES.rare,
});
@@ -48,7 +57,8 @@ export function useCreateTask() {
const { showToast } = useToast();
return useMutation({
mutationFn: (taskData: CreateTaskRequest) => taskService.createTask(taskData),
mutationFn: (taskData: CreateTaskRequest, context?: { signal?: AbortSignal }) =>
taskService.createTask(taskData, context?.signal),
onMutate: async (newTaskData) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: taskKeys.byProject(newTaskData.project_id) });
@@ -57,20 +67,18 @@ export function useCreateTask() {
const previousTasks = queryClient.getQueryData<Task[]>(taskKeys.byProject(newTaskData.project_id));
// Create optimistic task with stable ID
const optimisticTask = createOptimisticEntity<Task>(
{
project_id: newTaskData.project_id,
title: newTaskData.title,
description: newTaskData.description || "",
status: newTaskData.status ?? "todo",
assignee: newTaskData.assignee ?? "User",
feature: newTaskData.feature,
task_order: newTaskData.task_order ?? 100,
priority: newTaskData.priority ?? "medium",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
);
const optimisticTask = createOptimisticEntity<Task>({
project_id: newTaskData.project_id,
title: newTaskData.title,
description: newTaskData.description || "",
status: newTaskData.status ?? "todo",
assignee: newTaskData.assignee ?? "User",
feature: newTaskData.feature,
task_order: newTaskData.task_order ?? 100,
priority: newTaskData.priority ?? "medium",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
// Optimistically add the new task
queryClient.setQueryData(taskKeys.byProject(newTaskData.project_id), (old: Task[] | undefined) => {
@@ -96,7 +104,7 @@ export function useCreateTask() {
(tasks: (Task & Partial<OptimisticEntity>)[] = []) => {
const replaced = replaceOptimisticEntity(tasks, context?.optimisticId || "", serverTask);
return removeDuplicateEntities(replaced);
}
},
);
// Invalidate counts since we have a new task
@@ -119,10 +127,13 @@ export function useUpdateTask(projectId: string) {
const { showToast } = useToast();
return useMutation<Task, Error, { taskId: string; updates: UpdateTaskRequest }, { previousTasks?: Task[] }>({
mutationFn: ({ taskId, updates }: { taskId: string; updates: UpdateTaskRequest }) =>
taskService.updateTask(taskId, updates),
mutationKey: ['updateTask', projectId],
mutationFn: (
{ taskId, updates }: { taskId: string; updates: UpdateTaskRequest },
context?: { signal?: AbortSignal },
) => taskService.updateTask(taskId, updates, context?.signal),
onMutate: async ({ taskId, updates }) => {
// Cancel any outgoing refetches
// Cancel any outgoing refetches to prevent race conditions
await queryClient.cancelQueries({ queryKey: taskKeys.byProject(projectId) });
// Snapshot the previous value
@@ -131,7 +142,7 @@ export function useUpdateTask(projectId: string) {
// Optimistically update
queryClient.setQueryData<Task[]>(taskKeys.byProject(projectId), (old) => {
if (!old) return old;
return old.map((task) => (task.id === taskId ? { ...task, ...updates } : task));
return old.map((task) => (task.id === taskId ? { ...task, ...updates, updated_at: new Date().toISOString() } : task));
});
return { previousTasks };
@@ -172,7 +183,7 @@ export function useDeleteTask(projectId: string) {
const { showToast } = useToast();
return useMutation<void, Error, string, { previousTasks?: Task[] }>({
mutationFn: (taskId: string) => taskService.deleteTask(taskId),
mutationFn: (taskId: string, context?: { signal?: AbortSignal }) => taskService.deleteTask(taskId, context?.signal),
onMutate: async (taskId) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: taskKeys.byProject(projectId) });

View File

@@ -13,13 +13,17 @@ export const taskService = {
/**
* Get all tasks for a project
*/
async getTasksByProject(projectId: string): Promise<Task[]> {
async getTasksByProject(projectId: string, signal?: AbortSignal): Promise<Task[]> {
try {
const tasks = await callAPIWithETag<Task[]>(`/api/projects/${projectId}/tasks`);
const tasks = await callAPIWithETag<Task[]>(`/api/projects/${projectId}/tasks`, { signal });
// Return tasks as-is; UI uses DB status values (todo/doing/review/done)
return tasks;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.debug(`Request cancelled: get tasks for project ${projectId}`);
throw error; // Let TanStack Query handle the cancellation
}
console.error(`Failed to get tasks for project ${projectId}:`, error);
throw error;
}
@@ -28,11 +32,15 @@ export const taskService = {
/**
* Get a specific task by ID
*/
async getTask(taskId: string): Promise<Task> {
async getTask(taskId: string, signal?: AbortSignal): Promise<Task> {
try {
const task = await callAPIWithETag<Task>(`/api/tasks/${taskId}`);
const task = await callAPIWithETag<Task>(`/api/tasks/${taskId}`, { signal });
return task;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.debug(`Request cancelled: get task ${taskId}`);
throw error;
}
console.error(`Failed to get task ${taskId}:`, error);
throw error;
}
@@ -41,7 +49,7 @@ export const taskService = {
/**
* Create a new task
*/
async createTask(taskData: CreateTaskRequest): Promise<Task> {
async createTask(taskData: CreateTaskRequest, signal?: AbortSignal): Promise<Task> {
// Validate input
const validation = validateCreateTask(taskData);
if (!validation.success) {
@@ -54,12 +62,17 @@ export const taskService = {
// Backend returns { message: string, task: Task } for mutations
const response = await callAPIWithETag<{ message: string; task: Task }>("/api/tasks", {
signal,
method: "POST",
body: JSON.stringify(requestData),
});
return response.task;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.debug(`Request cancelled: create task`);
throw error;
}
console.error("Failed to create task:", error);
throw error;
}
@@ -68,7 +81,7 @@ export const taskService = {
/**
* Update an existing task
*/
async updateTask(taskId: string, updates: UpdateTaskRequest): Promise<Task> {
async updateTask(taskId: string, updates: UpdateTaskRequest, signal?: AbortSignal): Promise<Task> {
// Validate input
const validation = validateUpdateTask(updates);
if (!validation.success) {
@@ -78,12 +91,17 @@ export const taskService = {
try {
// Backend returns { message: string, task: Task } for mutations
const response = await callAPIWithETag<{ message: string; task: Task }>(`/api/tasks/${taskId}`, {
signal,
method: "PUT",
body: JSON.stringify(validation.data),
});
return response.task;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.debug(`Request cancelled: update task ${taskId}`);
throw error;
}
console.error(`Failed to update task ${taskId}:`, error);
throw error;
}
@@ -92,7 +110,7 @@ export const taskService = {
/**
* Update task status (for drag & drop operations)
*/
async updateTaskStatus(taskId: string, status: DatabaseTaskStatus): Promise<Task> {
async updateTaskStatus(taskId: string, status: DatabaseTaskStatus, signal?: AbortSignal): Promise<Task> {
// Validate input
const validation = validateUpdateTaskStatus({
task_id: taskId,
@@ -106,12 +124,17 @@ export const taskService = {
// Use the standard update task endpoint with JSON body
// Backend returns { message: string, task: Task } for mutations
const response = await callAPIWithETag<{ message: string; task: Task }>(`/api/tasks/${taskId}`, {
signal,
method: "PUT",
body: JSON.stringify({ status }),
});
return response.task;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.debug(`Request cancelled: update task status ${taskId}`);
throw error;
}
console.error(`Failed to update task status ${taskId}:`, error);
throw error;
}
@@ -120,12 +143,17 @@ export const taskService = {
/**
* Delete a task
*/
async deleteTask(taskId: string): Promise<void> {
async deleteTask(taskId: string, signal?: AbortSignal): Promise<void> {
try {
await callAPIWithETag<void>(`/api/tasks/${taskId}`, {
signal,
method: "DELETE",
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.debug(`Request cancelled: delete task ${taskId}`);
throw error;
}
console.error(`Failed to delete task ${taskId}:`, error);
throw error;
}
@@ -134,7 +162,12 @@ export const taskService = {
/**
* Update task order for better drag-and-drop support
*/
async updateTaskOrder(taskId: string, newOrder: number, newStatus?: DatabaseTaskStatus): Promise<Task> {
async updateTaskOrder(
taskId: string,
newOrder: number,
newStatus?: DatabaseTaskStatus,
signal?: AbortSignal,
): Promise<Task> {
try {
const updates: UpdateTaskRequest = {
task_order: newOrder,
@@ -144,10 +177,14 @@ export const taskService = {
updates.status = newStatus;
}
const task = await this.updateTask(taskId, updates);
const task = await this.updateTask(taskId, updates, signal);
return task;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.debug(`Request cancelled: update task order for ${taskId}`);
throw error;
}
console.error(`Failed to update task order for ${taskId}:`, error);
throw error;
}
@@ -171,11 +208,15 @@ export const taskService = {
* Get task counts for all projects in a single batch request
* Optimized endpoint to avoid N+1 query problem
*/
async getTaskCountsForAllProjects(): Promise<Record<string, TaskCounts>> {
async getTaskCountsForAllProjects(signal?: AbortSignal): Promise<Record<string, TaskCounts>> {
try {
const response = await callAPIWithETag<Record<string, TaskCounts>>("/api/projects/task-counts");
const response = await callAPIWithETag<Record<string, TaskCounts>>("/api/projects/task-counts", { signal });
return response || {};
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.debug(`Request cancelled: get task counts for all projects`);
throw error;
}
console.error("Failed to get task counts for all projects:", error);
throw error;
}

View File

@@ -251,7 +251,7 @@ describe("taskService", () => {
const result = await taskService.getTasksByProject(projectId);
expect(callAPIWithETag).toHaveBeenCalledWith(`/api/projects/${projectId}/tasks`);
expect(callAPIWithETag).toHaveBeenCalledWith(`/api/projects/${projectId}/tasks`, { signal: undefined });
expect(result).toEqual(mockTasks);
expect(result).toHaveLength(2);
});