From ddbf05862ea8d7cb59bbc6873bf812a0aa819cee Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Thu, 18 Sep 2025 14:21:58 +0300 Subject: [PATCH] feat, standardize abort signal --- .../knowledge/hooks/useKnowledgeQueries.ts | 52 ++++++++------ .../inspector/hooks/useInspectorPagination.ts | 4 +- .../knowledge/services/knowledgeService.ts | 38 +++++++---- .../hooks/tests/useProgressQueries.test.ts | 2 +- .../progress/hooks/useProgressQueries.ts | 10 +-- .../progress/services/progressService.ts | 8 +-- .../documents/hooks/useDocumentQueries.ts | 4 +- .../projects/hooks/useProjectQueries.ts | 5 +- .../projects/services/projectService.ts | 51 +++++++++++--- .../tasks/hooks/tests/useTaskQueries.test.ts | 6 +- .../projects/tasks/hooks/useTaskQueries.ts | 67 +++++++++++-------- .../projects/tasks/services/taskService.ts | 65 ++++++++++++++---- .../tasks/services/tests/taskService.test.ts | 2 +- 13 files changed, 212 insertions(+), 102 deletions(-) diff --git a/archon-ui-main/src/features/knowledge/hooks/useKnowledgeQueries.ts b/archon-ui-main/src/features/knowledge/hooks/useKnowledgeQueries.ts index 1d51ff56..c0ed1907 100644 --- a/archon-ui-main/src/features/knowledge/hooks/useKnowledgeQueries.ts +++ b/archon-ui-main/src/features/knowledge/hooks/useKnowledgeQueries.ts @@ -52,7 +52,8 @@ export const knowledgeKeys = { export function useKnowledgeItem(sourceId: string | null) { return useQuery({ 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({ 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, diff --git a/archon-ui-main/src/features/knowledge/inspector/hooks/useInspectorPagination.ts b/archon-ui-main/src/features/knowledge/inspector/hooks/useInspectorPagination.ts index 91230eff..46bb8c74 100644 --- a/archon-ui-main/src/features/knowledge/inspector/hooks/useInspectorPagination.ts +++ b/archon-ui-main/src/features/knowledge/inspector/hooks/useInspectorPagination.ts @@ -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; diff --git a/archon-ui-main/src/features/knowledge/services/knowledgeService.ts b/archon-ui-main/src/features/knowledge/services/knowledgeService.ts index b9d6af06..6884b143 100644 --- a/archon-ui-main/src/features/knowledge/services/knowledgeService.ts +++ b/archon-ui-main/src/features/knowledge/services/knowledgeService.ts @@ -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 { + async getKnowledgeSummaries(filter?: KnowledgeItemsFilter, signal?: AbortSignal): Promise { 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(endpoint); + return callAPIWithETag(endpoint, { signal }); }, /** * Get a specific knowledge item */ - async getKnowledgeItem(sourceId: string): Promise { - return callAPIWithETag(`/api/knowledge-items/${sourceId}`); + async getKnowledgeItem(sourceId: string, signal?: AbortSignal): Promise { + return callAPIWithETag(`/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 & { tags?: string[] }, + signal?: AbortSignal, ): Promise { const response = await callAPIWithETag(`/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 { + async crawlUrl(request: CrawlRequest, signal?: AbortSignal): Promise { const response = await callAPIWithETag("/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 { + async refreshKnowledgeItem(sourceId: string, signal?: AbortSignal): Promise { const response = await callAPIWithETag(`/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 { 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(endpoint); + return callAPIWithETag(endpoint, { signal }); }, /** @@ -186,6 +194,7 @@ export const knowledgeService = { limit?: number; offset?: number; }, + signal?: AbortSignal, ): Promise { 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(endpoint); + return callAPIWithETag(endpoint, { signal }); }, /** * Search the knowledge base */ - async searchKnowledgeBase(options: SearchOptions): Promise { + async searchKnowledgeBase(options: SearchOptions, signal?: AbortSignal): Promise { return callAPIWithETag("/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 { - return callAPIWithETag("/api/knowledge-items/sources"); + async getKnowledgeSources(signal?: AbortSignal): Promise { + return callAPIWithETag("/api/knowledge-items/sources", { signal }); }, }; diff --git a/archon-ui-main/src/features/progress/hooks/tests/useProgressQueries.test.ts b/archon-ui-main/src/features/progress/hooks/tests/useProgressQueries.test.ts index 565919aa..4089e80b 100644 --- a/archon-ui-main/src/features/progress/hooks/tests/useProgressQueries.test.ts +++ b/archon-ui-main/src/features/progress/hooks/tests/useProgressQueries.test.ts @@ -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)); }); }); diff --git a/archon-ui-main/src/features/progress/hooks/useProgressQueries.ts b/archon-ui-main/src/features/progress/hooks/useProgressQueries.ts index 19c8e401..d9b704cd 100644 --- a/archon-ui-main/src/features/progress/hooks/useProgressQueries.ts +++ b/archon-ui-main/src/features/progress/hooks/useProgressQueries.ts @@ -49,11 +49,11 @@ export function useOperationProgress( const query = useQuery({ 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({ 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 => { + queryFn: async ({ signal }): Promise => { 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) { diff --git a/archon-ui-main/src/features/progress/services/progressService.ts b/archon-ui-main/src/features/progress/services/progressService.ts index d3f6e61e..7f2d2c4f 100644 --- a/archon-ui-main/src/features/progress/services/progressService.ts +++ b/archon-ui-main/src/features/progress/services/progressService.ts @@ -10,15 +10,15 @@ export const progressService = { /** * Get progress for an operation */ - async getProgress(progressId: string): Promise { - return callAPIWithETag(`/api/progress/${progressId}`); + async getProgress(progressId: string, signal?: AbortSignal): Promise { + return callAPIWithETag(`/api/progress/${progressId}`, { signal }); }, /** * List all active operations */ - async listActiveOperations(): Promise { + async listActiveOperations(signal?: AbortSignal): Promise { // IMPORTANT: Use trailing slash to avoid FastAPI redirect that breaks in Docker - return callAPIWithETag("/api/progress/"); + return callAPIWithETag("/api/progress/", { signal }); }, }; diff --git a/archon-ui-main/src/features/projects/documents/hooks/useDocumentQueries.ts b/archon-ui-main/src/features/projects/documents/hooks/useDocumentQueries.ts index 0a7d23ee..4fad656c 100644 --- a/archon-ui-main/src/features/projects/documents/hooks/useDocumentQueries.ts +++ b/archon-ui-main/src/features/projects/documents/hooks/useDocumentQueries.ts @@ -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, diff --git a/archon-ui-main/src/features/projects/hooks/useProjectQueries.ts b/archon-ui-main/src/features/projects/hooks/useProjectQueries.ts index eaa85e66..572721dd 100644 --- a/archon-ui-main/src/features/projects/hooks/useProjectQueries.ts +++ b/archon-ui-main/src/features/projects/hooks/useProjectQueries.ts @@ -27,7 +27,7 @@ export function useProjects() { return useQuery({ 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, }); diff --git a/archon-ui-main/src/features/projects/services/projectService.ts b/archon-ui-main/src/features/projects/services/projectService.ts index f74675ca..c963b3f5 100644 --- a/archon-ui-main/src/features/projects/services/projectService.ts +++ b/archon-ui-main/src/features/projects/services/projectService.ts @@ -13,10 +13,10 @@ export const projectService = { /** * Get all projects */ - async listProjects(): Promise { + async listProjects(signal?: AbortSignal): Promise { 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 { + async getProject(projectId: string, signal?: AbortSignal): Promise { try { - const project = await callAPIWithETag(`/api/projects/${projectId}`); + const project = await callAPIWithETag(`/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 { + async updateProject(projectId: string, updates: UpdateProjectRequest, signal?: AbortSignal): Promise { // 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(`/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 { + async deleteProject(projectId: string, signal?: AbortSignal): Promise { 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; } diff --git a/archon-ui-main/src/features/projects/tasks/hooks/tests/useTaskQueries.test.ts b/archon-ui-main/src/features/projects/tasks/hooks/tests/useTaskQueries.test.ts index ed1c6089..d2730552 100644 --- a/archon-ui-main/src/features/projects/tasks/hooks/tests/useTaskQueries.test.ts +++ b/archon-ui-main/src/features/projects/tasks/hooks/tests/useTaskQueries.test.ts @@ -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 () => { diff --git a/archon-ui-main/src/features/projects/tasks/hooks/useTaskQueries.ts b/archon-ui-main/src/features/projects/tasks/hooks/useTaskQueries.ts index 64e8cbfe..45327a4e 100644 --- a/archon-ui-main/src/features/projects/tasks/hooks/useTaskQueries.ts +++ b/archon-ui-main/src/features/projects/tasks/hooks/useTaskQueries.ts @@ -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({ 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>>({ 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(taskKeys.byProject(newTaskData.project_id)); // Create optimistic task with stable ID - const optimisticTask = createOptimisticEntity( - { - 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({ + 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)[] = []) => { 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({ - 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(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({ - 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) }); diff --git a/archon-ui-main/src/features/projects/tasks/services/taskService.ts b/archon-ui-main/src/features/projects/tasks/services/taskService.ts index 223bdb73..2769dd33 100644 --- a/archon-ui-main/src/features/projects/tasks/services/taskService.ts +++ b/archon-ui-main/src/features/projects/tasks/services/taskService.ts @@ -13,13 +13,17 @@ export const taskService = { /** * Get all tasks for a project */ - async getTasksByProject(projectId: string): Promise { + async getTasksByProject(projectId: string, signal?: AbortSignal): Promise { try { - const tasks = await callAPIWithETag(`/api/projects/${projectId}/tasks`); + const tasks = await callAPIWithETag(`/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 { + async getTask(taskId: string, signal?: AbortSignal): Promise { try { - const task = await callAPIWithETag(`/api/tasks/${taskId}`); + const task = await callAPIWithETag(`/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 { + async createTask(taskData: CreateTaskRequest, signal?: AbortSignal): Promise { // 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 { + async updateTask(taskId: string, updates: UpdateTaskRequest, signal?: AbortSignal): Promise { // 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 { + async updateTaskStatus(taskId: string, status: DatabaseTaskStatus, signal?: AbortSignal): Promise { // 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 { + async deleteTask(taskId: string, signal?: AbortSignal): Promise { try { await callAPIWithETag(`/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 { + async updateTaskOrder( + taskId: string, + newOrder: number, + newStatus?: DatabaseTaskStatus, + signal?: AbortSignal, + ): Promise { 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> { + async getTaskCountsForAllProjects(signal?: AbortSignal): Promise> { try { - const response = await callAPIWithETag>("/api/projects/task-counts"); + const response = await callAPIWithETag>("/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; } diff --git a/archon-ui-main/src/features/projects/tasks/services/tests/taskService.test.ts b/archon-ui-main/src/features/projects/tasks/services/tests/taskService.test.ts index d86cc94d..0df7fc9b 100644 --- a/archon-ui-main/src/features/projects/tasks/services/tests/taskService.test.ts +++ b/archon-ui-main/src/features/projects/tasks/services/tests/taskService.test.ts @@ -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); });