fix: TanStack Query improvements from CodeRabbit review

- Fixed concurrent project creation bug by tracking specific temp IDs
- Unified task counts query keys to fix cache invalidation
- Added TypeScript generics to getQueryData calls for type safety
- Added return type to useTaskCounts hook
- Prevented double refetch with refetchOnWindowFocus: false
- Improved cache cleanup with exact: false on removeQueries

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Rasmus Widing
2025-09-04 20:33:07 +03:00
parent d927a87428
commit 4ba691f9e6
5 changed files with 26 additions and 29 deletions

View File

@@ -15,5 +15,6 @@ export {
useDeleteProject,
useProjectFeatures,
useProjects,
useTaskCounts,
useUpdateProject,
} from "./useProjectQueries";

View File

@@ -25,13 +25,14 @@ export function useProjects() {
queryKey: projectKeys.lists(),
queryFn: () => projectService.listProjects(),
refetchInterval, // Smart interval based on page visibility/focus
refetchOnWindowFocus: false, // Avoid double refetch with polling
staleTime: 15000, // Consider data stale after 15 seconds
});
}
// Fetch task counts for all projects
export function useTaskCounts() {
return useQuery({
return useQuery<Awaited<ReturnType<typeof taskService.getTaskCountsForAllProjects>>>({
queryKey: projectKeys.taskCounts(),
queryFn: () => taskService.getTaskCountsForAllProjects(),
refetchInterval: false, // Don't poll, only refetch manually
@@ -61,11 +62,12 @@ export function useCreateProject() {
await queryClient.cancelQueries({ queryKey: projectKeys.lists() });
// Snapshot the previous value
const previousProjects = queryClient.getQueryData(projectKeys.lists());
const previousProjects = queryClient.getQueryData<Project[]>(projectKeys.lists());
// Create optimistic project with temporary ID
const tempId = `temp-${Date.now()}`;
const optimisticProject: Project = {
id: `temp-${Date.now()}`, // Temporary ID until real one comes back
id: tempId, // Temporary ID until real one comes back
title: newProjectData.title,
description: newProjectData.description,
github_repo: newProjectData.github_repo,
@@ -85,7 +87,7 @@ export function useCreateProject() {
return [optimisticProject, ...old];
});
return { previousProjects };
return { previousProjects, tempId };
},
onError: (error, variables, context) => {
const errorMessage = error instanceof Error ? error.message : String(error);
@@ -98,16 +100,16 @@ export function useCreateProject() {
showToast(`Failed to create project: ${errorMessage}`, "error");
},
onSuccess: (response) => {
onSuccess: (response, _variables, context) => {
// Extract the actual project from the response
const newProject = response.project;
// Replace optimistic project with real one from server
queryClient.setQueryData(projectKeys.lists(), (old: Project[] | undefined) => {
if (!old) return [newProject];
// Replace temp project with real one
// Replace only the specific temp project with real one
return old
.map((project) => (project.id.startsWith("temp-") ? newProject : project))
.map((project) => (project.id === context?.tempId ? newProject : project))
.filter(
(project, index, self) =>
// Remove any duplicates just in case
@@ -137,7 +139,7 @@ export function useUpdateProject() {
await queryClient.cancelQueries({ queryKey: projectKeys.lists() });
// Snapshot the previous value
const previousProjects = queryClient.getQueryData(projectKeys.lists());
const previousProjects = queryClient.getQueryData<Project[]>(projectKeys.lists());
// Optimistically update
queryClient.setQueryData(projectKeys.lists(), (old: Project[] | undefined) => {
@@ -189,7 +191,7 @@ export function useDeleteProject() {
await queryClient.cancelQueries({ queryKey: projectKeys.lists() });
// Snapshot the previous value
const previousProjects = queryClient.getQueryData(projectKeys.lists());
const previousProjects = queryClient.getQueryData<Project[]>(projectKeys.lists());
// Optimistically remove the project
queryClient.setQueryData(projectKeys.lists(), (old: Project[] | undefined) => {
@@ -212,8 +214,8 @@ export function useDeleteProject() {
},
onSuccess: (_, projectId) => {
// Don't refetch on success - trust optimistic update
// Only remove the specific project's detail data
queryClient.removeQueries({ queryKey: projectKeys.detail(projectId) });
// Only remove the specific project's detail data (including nested keys)
queryClient.removeQueries({ queryKey: projectKeys.detail(projectId), exact: false });
showToast("Project deleted successfully", "success");
},
});

View File

@@ -15,6 +15,5 @@ export {
useCreateTask,
useDeleteTask,
useProjectTasks,
useTaskCounts,
useUpdateTask,
} from "./useTaskQueries";

View File

@@ -1,13 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSmartPolling } from "../../../ui/hooks";
import { useToast } from "../../../ui/hooks/useToast";
import { projectKeys } from "../../hooks/useProjectQueries";
import { taskService } from "../services";
import type { CreateTaskRequest, Task, UpdateTaskRequest } from "../types";
// Query keys factory for tasks
export const taskKeys = {
all: (projectId: string) => ["projects", projectId, "tasks"] as const,
counts: () => ["taskCounts"] as const,
};
// Fetch tasks for a specific project
@@ -81,7 +81,7 @@ export function useCreateTask() {
index === self.findIndex((t) => t.id === task.id),
);
});
queryClient.invalidateQueries({ queryKey: taskKeys.counts() });
queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() });
showToast("Task created successfully", "success");
},
onSettled: (_data, _error, variables) => {
@@ -124,12 +124,12 @@ export function useUpdateTask(projectId: string) {
showToast(`Failed to update task: ${errorMessage}`, "error");
// Refetch on error to ensure consistency
queryClient.invalidateQueries({ queryKey: taskKeys.all(projectId) });
queryClient.invalidateQueries({ queryKey: taskKeys.counts() });
queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() });
},
onSuccess: (_, { updates }) => {
// Only invalidate counts if status changed (which affects counts)
if (updates.status) {
queryClient.invalidateQueries({ queryKey: taskKeys.counts() });
queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() });
// Show toast for significant status changes
showToast(`Task moved to ${updates.status}`, "success");
}
@@ -174,17 +174,7 @@ export function useDeleteTask(projectId: string) {
},
onSettled: () => {
// Always refetch counts after deletion
queryClient.invalidateQueries({ queryKey: taskKeys.counts() });
queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() });
},
});
}
// Fetch task counts for all projects
export function useTaskCounts() {
return useQuery({
queryKey: taskKeys.counts(),
queryFn: () => taskService.getTaskCountsForAllProjects(),
refetchInterval: false, // Don't poll, only refetch manually
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
}

View File

@@ -9,8 +9,13 @@ import { NewProjectModal } from "../components/NewProjectModal";
import { ProjectHeader } from "../components/ProjectHeader";
import { ProjectList } from "../components/ProjectList";
import { DocsTab } from "../documents/DocsTab";
import { projectKeys, useDeleteProject, useProjects, useUpdateProject } from "../hooks/useProjectQueries";
import { useTaskCounts } from "../tasks/hooks";
import {
projectKeys,
useDeleteProject,
useProjects,
useTaskCounts,
useUpdateProject,
} from "../hooks/useProjectQueries";
import { TasksTab } from "../tasks/TasksTab";
import type { Project } from "../types";