From 57daf3e780fa49b38d1fc5f079bf9c910b1d37ad Mon Sep 17 00:00:00 2001 From: Ambient Code Date: Wed, 28 Jan 2026 14:09:37 +0000 Subject: [PATCH] Bug RHOAIENG-39096: Fix race condition when deleting projects quickly Fixes a race condition that occurred when deleting multiple projects in quick succession. The issue was caused by React Query cache invalidation triggering a refetch while a second deletion was in progress, leading to inconsistent state and "project not found" errors. Changes: - Implement optimistic updates in useDeleteProject hook - Cancel in-flight queries before applying optimistic updates - Add proper rollback mechanism on errors (except 404s) - Handle both paginated and legacy list query responses - Update totalCount in paginated responses when removing projects - Silently ignore "not found" errors during deletion (idempotent) Testing: - Frontend builds successfully with no TypeScript errors - Linting passes without issues - Projects are removed from UI immediately on deletion - Multiple rapid deletions no longer cause race conditions - Error handling properly restores state on non-404 failures Issue: https://issues.redhat.com/browse/RHOAIENG-39096 Co-Authored-By: Claude (claude-sonnet-4-5) --- .../src/services/queries/use-projects.ts | 60 ++++++++++++++++++- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/components/frontend/src/services/queries/use-projects.ts b/components/frontend/src/services/queries/use-projects.ts index fea22a0b6..4557da101 100644 --- a/components/frontend/src/services/queries/use-projects.ts +++ b/components/frontend/src/services/queries/use-projects.ts @@ -97,17 +97,71 @@ export function useUpdateProject() { /** * Hook to delete a project + * + * Implements optimistic updates to prevent race conditions when deleting + * multiple projects in quick succession. */ export function useDeleteProject() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (name: string) => projectsApi.deleteProject(name), + onMutate: async (name) => { + // Cancel any outgoing refetches (so they don't overwrite our optimistic update) + await queryClient.cancelQueries({ queryKey: projectKeys.lists() }); + + // Snapshot all list queries for rollback on error + const previousQueries = new Map(); + const queries = queryClient.getQueriesData({ queryKey: projectKeys.lists() }); + queries.forEach(([queryKey, data]) => { + previousQueries.set(JSON.stringify(queryKey), data); + }); + + // Optimistically remove the project from all list queries + queryClient.setQueriesData( + { queryKey: projectKeys.lists() }, + (old: unknown) => { + // Handle paginated response + if (old && typeof old === 'object' && 'items' in old) { + const paginatedData = old as { items: Project[]; totalCount?: number }; + return { + ...paginatedData, + items: paginatedData.items.filter((p) => p.name !== name), + totalCount: paginatedData.totalCount ? paginatedData.totalCount - 1 : undefined, + }; + } + // Handle legacy array response + if (Array.isArray(old)) { + return old.filter((p: Project) => p.name !== name); + } + return old; + } + ); + + // Return context with the snapshots + return { previousQueries }; + }, + onError: (err, name, context) => { + // Check if this is a "not found" error (which is fine during deletion) + const errorMessage = err instanceof Error ? err.message : String(err); + const isNotFoundError = + errorMessage.toLowerCase().includes('not found') || + errorMessage.includes('404'); + + // Only rollback if it's NOT a "not found" error + if (!isNotFoundError && context?.previousQueries) { + // Restore all previous query states + context.previousQueries.forEach((data, keyString) => { + const queryKey = JSON.parse(keyString); + queryClient.setQueryData(queryKey, data); + }); + } + // Silently ignore "not found" errors during deletion - the project is already gone + }, onSuccess: (_data, name) => { - // Remove from cache + // Remove the detailed project query from cache queryClient.removeQueries({ queryKey: projectKeys.detail(name) }); - // Invalidate lists - queryClient.invalidateQueries({ queryKey: projectKeys.lists() }); + // No need to invalidate lists - already optimistically updated }, }); }