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
2 changes: 1 addition & 1 deletion src/components/workspace-canvas/WorkspaceHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ export default function WorkspaceHeader({
className="h-4 w-4 shrink-0"
style={{ color: workspaceColor || undefined }}
/>
<span className="truncate text-sidebar-foreground font-medium max-w-[300px]" title={workspaceName}>
<span className="truncate text-sidebar-foreground max-w-[300px]" title={workspaceName}>
{workspaceName || "Untitled"}
</span>
</button>
Expand Down
201 changes: 118 additions & 83 deletions src/contexts/WorkspaceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo } from "react";
import { useRouter, usePathname } from "next/navigation";
import { toast } from "sonner";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/contexts/WorkspaceContext.tsx | head -150

Repository: ThinkEx-OSS/thinkex

Length of output: 6474


🏁 Script executed:

cat -n src/contexts/WorkspaceContext.tsx | sed -n '150,250p'

Repository: ThinkEx-OSS/thinkex

Length of output: 3909


🏁 Script executed:

fd "auth-client" --type f

Repository: ThinkEx-OSS/thinkex

Length of output: 85


🏁 Script executed:

cat -n src/lib/auth-client.ts

Repository: ThinkEx-OSS/thinkex

Length of output: 693


🏁 Script executed:

rg "useSession" --type tsx --type ts -A 3 -B 1 | head -100

Repository: ThinkEx-OSS/thinkex

Length of output: 90


🏁 Script executed:

rg "useSession" -A 3 -B 1 | head -100

Repository: ThinkEx-OSS/thinkex

Length of output: 6713


🏁 Script executed:

cat -n src/components/layout/SessionHandler.tsx

Repository: ThinkEx-OSS/thinkex

Length of output: 1545


🏁 Script executed:

rg "invalidateQueries|setQueryData" --context 5 | head -150

Repository: ThinkEx-OSS/thinkex

Length of output: 10936


🏁 Script executed:

rg "signOut|signIn" -A 5 -B 2 | head -150

Repository: ThinkEx-OSS/thinkex

Length of output: 6759


🏁 Script executed:

rg "queryClient.clear|removeQueries" -B 3 -A 3

Repository: ThinkEx-OSS/thinkex

Length of output: 45


🏁 Script executed:

fd "query" --type f | grep -i "config\|client\|setup"

Repository: ThinkEx-OSS/thinkex

Length of output: 45


🏁 Script executed:

rg "QueryClientProvider\|QueryClient" -B 3 -A 3

Repository: ThinkEx-OSS/thinkex

Length of output: 45


🏁 Script executed:

fd "provider" --type f -e tsx -e ts | head -20

Repository: ThinkEx-OSS/thinkex

Length of output: 566


🏁 Script executed:

cat -n src/components/query-provider.tsx

Repository: ThinkEx-OSS/thinkex

Length of output: 1524


🏁 Script executed:

rg "session.*invalidate\|invalidate.*session\|onSignOut\|onAuthChange" -B 3 -A 3

Repository: ThinkEx-OSS/thinkex

Length of output: 45


🏁 Script executed:

cat -n src/components/layout/SessionHandler.tsx && echo -e "\n---\n" && cat -n src/components/providers.tsx | head -50

Repository: ThinkEx-OSS/thinkex

Length of output: 3730


Scope the workspaces query by user/session and fix async contract violations.

The static queryKey: ['workspaces'] on line 61 creates a cross-user cache leak risk—if the session changes without a full reload, React Query will briefly surface cached workspaces from a previous user. Additionally, the query lacks an enabled condition to prevent requests during auth transitions.

More critically, the wrapper functions deleteWorkspace (line 158) and reorderWorkspaces (line 224) violate their async contract: both have Promise<void> return types but call mutate() without awaiting, causing callers to receive resolved promises before mutations complete. Use mutateAsync() instead.

✅ Suggested adjustments
-  const { data: workspacesData, isLoading: loadingWorkspaces } = useQuery({
-    queryKey: ['workspaces'],
+  const userKey = session?.user?.id ?? "anonymous";
+  const { data: workspacesData, isLoading: loadingWorkspaces } = useQuery({
+    queryKey: ['workspaces', userKey],
+    enabled: session !== undefined,
     queryFn: async () => {

For deleteWorkspace:

   const deleteWorkspace = useCallback(
     async (workspaceId: string) => {
-      deleteWorkspaceMutation.mutate(workspaceId);
+      return deleteWorkspaceMutation.mutateAsync(workspaceId);
     [deleteWorkspaceMutation]
   );

For reorderWorkspaces:

   const reorderWorkspaces = useCallback(
     async (workspaceIds: string[]) => {
-      reorderWorkspacesMutation.mutate(workspaceIds);
+      return reorderWorkspacesMutation.mutateAsync(workspaceIds);
     [reorderWorkspacesMutation]
   );
🤖 Prompt for AI Agents
In `@src/contexts/WorkspaceContext.tsx` at line 6, The current workspaces query
uses a static queryKey ['workspaces'] and no enabled guard, and the wrapper
functions deleteWorkspace and reorderWorkspaces call mutate() while declared as
Promise<void>, causing cache bleed across sessions and async contract
violations; fix by scoping the useQuery queryKey to include a stable
session/user identifier (e.g., ['workspaces', session?.user?.id or sessionId])
and add an enabled: Boolean(session) (or equivalent auth-ready flag) to the
useQuery options, and update deleteWorkspace and reorderWorkspaces to call the
mutation's mutateAsync() and await its result (or return the mutateAsync
Promise) so their Promise<void> resolves only after the mutation completes
(refer to the deleteWorkspace and reorderWorkspaces wrappers and the useQuery
call that currently uses queryKey: ['workspaces']).

import type { WorkspaceWithState } from "@/lib/workspace-state/types";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { useSession } from "@/lib/auth-client";
Expand Down Expand Up @@ -39,10 +40,8 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const currentWorkspaceId = useWorkspaceStore((state) => state.currentWorkspaceId);
const { data: session } = useSession();
const queryClient = useQueryClient();

// Workspace list state
const [workspaces, setWorkspaces] = useState<WorkspaceWithState[]>([]);
const [loadingWorkspaces, setLoadingWorkspaces] = useState(false);
const [isCreatingWelcomeWorkspace, setIsCreatingWelcomeWorkspace] = useState(false);

// Derive current slug synchronously from pathname (no useEffect delay)
Expand All @@ -57,21 +56,28 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
return null;
}, [pathname]);

// Load workspaces from API
const loadWorkspaces = useCallback(async () => {
setLoadingWorkspaces(true);
try {
// Fetch workspaces with TanStack Query
const { data: workspacesData, isLoading: loadingWorkspaces } = useQuery({
queryKey: ['workspaces'],
queryFn: async () => {
const response = await fetch("/api/workspaces");
if (response.ok) {
const data = await response.json();
setWorkspaces(data.workspaces || []);
if (!response.ok) {
throw new Error('Failed to fetch workspaces');
}
} catch (error) {
console.error("[WORKSPACE CONTEXT] Error loading workspaces:", error);
} finally {
setLoadingWorkspaces(false);
}
}, []);
const data = await response.json();
return data.workspaces || [];
},
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
retry: 3,
});

const workspaces = workspacesData || [];

// Load workspaces function for compatibility
const loadWorkspaces = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: ['workspaces'] });
}, [queryClient]);

// Create welcome workspace for anonymous users
const createWelcomeWorkspace = useCallback(async () => {
Expand Down Expand Up @@ -99,10 +105,7 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
}
}, [isCreatingWelcomeWorkspace, loadWorkspaces, router]);

// Load workspaces on mount
useEffect(() => {
loadWorkspaces();
}, [loadWorkspaces]);
// TanStack Query automatically fetches on mount, no need for manual trigger

// Note: Welcome workspace creation is now handled lazily in WorkspaceGrid
// This prevents jarring dashboard flash and provides better UX
Expand All @@ -115,82 +118,114 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
[router]
);

// Delete workspace mutation
const deleteWorkspaceMutation = useMutation({
mutationFn: async (workspaceId: string) => {
const response = await fetch(`/api/workspaces/${workspaceId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error('Failed to delete workspace');
}
return workspaceId;
},
onSuccess: (deletedWorkspaceId) => {
// Update cache immediately
queryClient.setQueryData(['workspaces'], (old: WorkspaceWithState[] | undefined) => {
if (!old) return [];
const remainingWorkspaces = old.filter((w) => w.id !== deletedWorkspaceId);

// If we deleted the current workspace, switch to first available
if (deletedWorkspaceId === currentWorkspaceId) {
if (remainingWorkspaces.length > 0) {
switchWorkspace(remainingWorkspaces[0].slug || remainingWorkspaces[0].id);
} else {
router.push("/home");
}
}

return remainingWorkspaces;
});

toast.success("Workspace deleted successfully");
},
onError: () => {
toast.error("Failed to delete workspace");
},
});

// Delete workspace
const deleteWorkspace = useCallback(
async (workspaceId: string) => {
try {
const response = await fetch(`/api/workspaces/${workspaceId}`, {
method: "DELETE",
});

if (response.ok) {
// Update local state immediately
const remainingWorkspaces = workspaces.filter((w) => w.id !== workspaceId);
setWorkspaces(remainingWorkspaces);

// If we deleted the current workspace, switch to first available
if (workspaceId === currentWorkspaceId) {
if (remainingWorkspaces.length > 0) {
switchWorkspace(remainingWorkspaces[0].slug || remainingWorkspaces[0].id);
} else {
router.push("/home");
}
}

toast.success("Workspace deleted successfully");
} else {
toast.error("Failed to delete workspace");
}
} catch (error) {
console.error("[WORKSPACE CONTEXT] Error deleting workspace:", error);
toast.error("Failed to delete workspace");
}
await deleteWorkspaceMutation.mutateAsync(workspaceId);
},
[workspaces, switchWorkspace, router, currentWorkspaceId]
[deleteWorkspaceMutation]
);

// Optimistically update a single workspace locally without refetching
const updateWorkspaceLocal = useCallback((workspaceId: string, updates: Partial<WorkspaceWithState>) => {
setWorkspaces((prev) => prev.map((w) => (w.id === workspaceId ? { ...w, ...updates } : w)));
}, []);
queryClient.setQueryData(['workspaces'], (old: WorkspaceWithState[] | undefined) => {
if (!old) return [];
return old.map((w) => (w.id === workspaceId ? { ...w, ...updates } : w));
});
}, [queryClient]);

// Reorder workspaces mutation
const reorderWorkspacesMutation = useMutation({
mutationFn: async (workspaceIds: string[]) => {
const response = await fetch("/api/workspaces/reorder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ workspaceIds }),
});
if (!response.ok) {
throw new Error('Failed to reorder workspaces');
}
return workspaceIds;
},
onMutate: async (workspaceIds) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['workspaces'] });

// Snapshot the previous value
const previousWorkspaces = queryClient.getQueryData(['workspaces']) as WorkspaceWithState[] | undefined;

// Optimistically update
queryClient.setQueryData(['workspaces'], (old: WorkspaceWithState[] | undefined) => {
if (!old) return [];
// Reorder workspaces based on new order
const reordered = workspaceIds
.map((id) => old.find((w) => w.id === id))
.filter((w): w is WorkspaceWithState => w !== undefined);

// Add any workspaces not in the reorder list (shouldn't happen, but safety)
const missing = old.filter((w) => !workspaceIds.includes(w.id));

return [...reordered, ...missing];
});

return { previousWorkspaces };
},
onError: (err, workspaceIds, context) => {
// Revert on error
if (context?.previousWorkspaces) {
queryClient.setQueryData(['workspaces'], context.previousWorkspaces);
}
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: ['workspaces'] });
},
});

// Reorder workspaces
const reorderWorkspaces = useCallback(
async (workspaceIds: string[]) => {
try {
// Optimistically update local state
setWorkspaces((prev) => {
// Reorder workspaces based on new order
const reordered = workspaceIds
.map((id) => prev.find((w) => w.id === id))
.filter((w): w is WorkspaceWithState => w !== undefined);

// Add any workspaces not in the reorder list (shouldn't happen, but safety)
const missing = prev.filter((w) => !workspaceIds.includes(w.id));

return [...reordered, ...missing];
});

// Persist to backend
const response = await fetch("/api/workspaces/reorder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ workspaceIds }),
});

if (!response.ok) {
// Revert on error by reloading
loadWorkspaces();
}
} catch (error) {
console.error("[WORKSPACE CONTEXT] Error reordering workspaces:", error);
// Revert on error by reloading
loadWorkspaces();
}
reorderWorkspacesMutation.mutate(workspaceIds);
},
[loadWorkspaces, workspaces]
[reorderWorkspacesMutation]
);

const value: WorkspaceContextType = {
Expand Down