From 2fc4037ba8b1e1c3df7b0930ba457541321610be Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Thu, 4 Dec 2025 13:26:41 -0800 Subject: [PATCH 1/7] improvement: loading, optimistic operations --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 7 +- .../usage-indicator/rotating-digit.tsx | 92 ------------------- .../usage-indicator/usage-indicator.tsx | 13 +-- .../components/folder-item/folder-item.tsx | 17 +++- .../sidebar/hooks/use-folder-operations.ts | 17 ++-- .../sidebar/hooks/use-workflow-operations.ts | 32 ++++--- apps/sim/hooks/queries/folders.ts | 62 ++++++++++++- apps/sim/hooks/queries/workflows.ts | 86 +++++++++++++---- 8 files changed, 180 insertions(+), 146 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/rotating-digit.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index ca2d8a1d656..7efda7e872c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -11,6 +11,7 @@ import ReactFlow, { useReactFlow, } from 'reactflow' import 'reactflow/dist/style.css' +import { Loader2 } from 'lucide-react' import { createLogger } from '@/lib/logs/console/logger' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -2187,7 +2188,11 @@ const WorkflowContent = React.memo(() => { return (
-
+
+
+ +
+
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/rotating-digit.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/rotating-digit.tsx deleted file mode 100644 index 48eb7458859..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/rotating-digit.tsx +++ /dev/null @@ -1,92 +0,0 @@ -'use client' - -import { cn } from '@/lib/core/utils/cn' - -export interface RotatingDigitProps { - value: number | string - height?: number - width?: number - className?: string - textClassName?: string -} - -/** - * RotatingDigit component for displaying numbers with a rolling animation effect. - * Useful for live-updating metrics like usage, pricing, or counters. - * - * @example - * ```tsx - * - * ``` - */ -export function RotatingDigit({ - value, - height = 14, // Default to match text size - width = 8, - className, - textClassName, -}: RotatingDigitProps) { - const parts = - typeof value === 'number' ? value.toFixed(2).split('') : (value as string).toString().split('') - - return ( -
- {parts.map((part: string, index: number) => { - if (/[0-9]/.test(part)) { - return ( - - ) - } - return ( -
- {part} -
- ) - })} -
- ) -} - -function SingleDigit({ - digit, - height, - width, - className, -}: { - digit: number - height: number - width: number - className?: string -}) { - return ( -
-
- {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => ( -
- {num} -
- ))} -
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx index d0c624c05da..eb3d0411e2e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx @@ -12,7 +12,6 @@ import { getUsage, } from '@/lib/billing/client/utils' import { createLogger } from '@/lib/logs/console/logger' -import { RotatingDigit } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/rotating-digit' import { useSocket } from '@/app/workspace/providers/socket-provider' import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription' import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store' @@ -272,15 +271,9 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { ) : ( <> -
- $ - -
+ + ${usage.current} + / ${usage.limit} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx index 755bab5c39d..55ccc8b065b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx @@ -17,6 +17,10 @@ import { useDeleteFolder, useDuplicateFolder } from '@/app/workspace/[workspaceI import { useUpdateFolder } from '@/hooks/queries/folders' import { useCreateWorkflow } from '@/hooks/queries/workflows' import type { FolderTreeNode } from '@/stores/folders/store' +import { + generateCreativeWorkflowName, + getNextWorkflowColor, +} from '@/stores/workflows/registry/utils' interface FolderItemProps { folder: FolderTreeNode @@ -60,12 +64,23 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { }) /** - * Handle create workflow in folder using React Query mutation + * Handle create workflow in folder using React Query mutation. + * Generates name and color upfront for optimistic UI updates. */ const handleCreateWorkflowInFolder = useCallback(async () => { + if (createWorkflowMutation.isPending) { + return + } + + // Generate name and color upfront for optimistic updates + const name = generateCreativeWorkflowName() + const color = getNextWorkflowColor() + const result = await createWorkflowMutation.mutateAsync({ workspaceId, folderId: folder.id, + name, + color, }) if (result.id) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-operations.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-operations.ts index 3a3436ee319..b53ee0002c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-operations.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-operations.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback } from 'react' import { createLogger } from '@/lib/logs/console/logger' import { generateFolderName } from '@/lib/workspaces/naming' import { useCreateFolder } from '@/hooks/queries/folders' @@ -12,25 +12,26 @@ interface UseFolderOperationsProps { /** * Custom hook to manage folder operations including creating folders. * Handles folder name generation and state management. + * Uses React Query mutation's isPending state for immediate loading feedback. * * @param props - Configuration object containing workspaceId * @returns Folder operations state and handlers */ export function useFolderOperations({ workspaceId }: UseFolderOperationsProps) { const createFolderMutation = useCreateFolder() - const [isCreatingFolder, setIsCreatingFolder] = useState(false) /** - * Create folder handler - creates folder with auto-generated name + * Create folder handler - creates folder with auto-generated name. + * Generates name upfront to enable optimistic UI updates. */ const handleCreateFolder = useCallback(async (): Promise => { - if (isCreatingFolder || !workspaceId) { + if (createFolderMutation.isPending || !workspaceId) { logger.info('Folder creation already in progress or no workspaceId available') return null } try { - setIsCreatingFolder(true) + // Generate folder name upfront for optimistic updates const folderName = await generateFolderName(workspaceId) const folder = await createFolderMutation.mutateAsync({ name: folderName, workspaceId }) logger.info(`Created folder: ${folderName}`) @@ -38,14 +39,12 @@ export function useFolderOperations({ workspaceId }: UseFolderOperationsProps) { } catch (error) { logger.error('Failed to create folder:', { error }) return null - } finally { - setIsCreatingFolder(false) } - }, [createFolderMutation, workspaceId, isCreatingFolder]) + }, [createFolderMutation, workspaceId]) return { // State - isCreatingFolder, + isCreatingFolder: createFolderMutation.isPending, // Operations handleCreateFolder, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts index f785272ae6b..455932a585b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts @@ -1,9 +1,13 @@ -import { useCallback, useState } from 'react' +import { useCallback } from 'react' import { useRouter } from 'next/navigation' import { createLogger } from '@/lib/logs/console/logger' import { useCreateWorkflow, useWorkflows } from '@/hooks/queries/workflows' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { + generateCreativeWorkflowName, + getNextWorkflowColor, +} from '@/stores/workflows/registry/utils' const logger = createLogger('useWorkflowOperations') @@ -29,7 +33,6 @@ export function useWorkflowOperations({ const { workflows } = useWorkflowRegistry() const workflowsQuery = useWorkflows(workspaceId) const createWorkflowMutation = useCreateWorkflow() - const [isCreatingWorkflow, setIsCreatingWorkflow] = useState(false) /** * Filter and sort workflows for the current workspace @@ -42,25 +45,30 @@ export function useWorkflowOperations({ }) /** - * Create workflow handler - creates workflow and navigates to it - * Now uses React Query mutation for better performance and caching + * Create workflow handler - creates workflow and navigates to it. + * Uses React Query mutation's isPending state for immediate loading feedback. + * Generates name and color upfront to enable optimistic UI updates. */ const handleCreateWorkflow = useCallback(async (): Promise => { - if (isCreatingWorkflow) { + if (createWorkflowMutation.isPending) { logger.info('Workflow creation already in progress, ignoring request') return null } try { - setIsCreatingWorkflow(true) - // Clear workflow diff store when creating a new workflow const { clearDiff } = useWorkflowDiffStore.getState() clearDiff() - // Use React Query mutation for creation + // Generate name and color upfront for optimistic updates + const name = generateCreativeWorkflowName() + const color = getNextWorkflowColor() + + // Use React Query mutation for creation - isPending updates immediately const result = await createWorkflowMutation.mutateAsync({ - workspaceId: workspaceId, + workspaceId, + name, + color, }) // Navigate to the newly created workflow @@ -72,17 +80,15 @@ export function useWorkflowOperations({ } catch (error) { logger.error('Error creating workflow:', error) return null - } finally { - setIsCreatingWorkflow(false) } - }, [isCreatingWorkflow, createWorkflowMutation, workspaceId, router]) + }, [createWorkflowMutation, workspaceId, router]) return { // State workflows, regularWorkflows, workflowsLoading: workflowsQuery.isLoading, - isCreatingWorkflow, + isCreatingWorkflow: createWorkflowMutation.isPending, // Operations handleCreateWorkflow, diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index 87b22d0a6f8..97983fd050e 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -65,6 +65,11 @@ interface CreateFolderVariables { color?: string } +interface CreateFolderContext { + tempId: string + previousFolders: Record +} + interface UpdateFolderVariables { workspaceId: string id: string @@ -103,7 +108,62 @@ export function useCreateFolder() { const { folder } = await response.json() return mapFolder(folder) }, - onSuccess: (_data, variables) => { + onMutate: async (variables): Promise => { + // Cancel any outgoing refetches to prevent race conditions + await queryClient.cancelQueries({ queryKey: folderKeys.list(variables.workspaceId) }) + + // Snapshot previous state for rollback + const previousFolders = { ...useFolderStore.getState().folders } + + const tempId = `temp-folder-${Date.now()}` + + // Optimistically add folder entry immediately + useFolderStore.setState((state) => ({ + folders: { + ...state.folders, + [tempId]: { + id: tempId, + name: variables.name, + userId: '', + workspaceId: variables.workspaceId, + parentId: variables.parentId || null, + color: variables.color || '#808080', + isExpanded: false, + sortOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + })) + + logger.info(`Added optimistic folder entry: ${tempId}`) + return { tempId, previousFolders } + }, + onSuccess: (data, _variables, context) => { + logger.info(`Folder ${data.id} created successfully, replacing temp entry ${context.tempId}`) + + // Replace optimistic entry with real folder data + useFolderStore.setState((state) => { + const { [context.tempId]: _, ...remainingFolders } = state.folders + return { + folders: { + ...remainingFolders, + [data.id]: data, + }, + } + }) + }, + onError: (error: Error, _variables, context) => { + logger.error('Failed to create folder:', error) + + // Rollback to previous state snapshot + if (context?.previousFolders) { + useFolderStore.setState({ folders: context.previousFolders }) + logger.info(`Rolled back to previous folders state`) + } + }, + onSettled: (_data, _error, variables) => { + // Always invalidate to sync with server state queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) }) }, }) diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index 03c84f18cce..f25472b743a 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -87,6 +87,11 @@ interface CreateWorkflowVariables { folderId?: string | null } +interface CreateWorkflowContext { + tempId: string + previousWorkflows: Record +} + export function useCreateWorkflow() { const queryClient = useQueryClient() @@ -144,38 +149,81 @@ export function useCreateWorkflow() { folderId: createdWorkflow.folderId, } }, - onSuccess: (data, variables) => { - logger.info(`Workflow ${data.id} created successfully`) + onMutate: async (variables): Promise => { + // Cancel any outgoing refetches to prevent race conditions + await queryClient.cancelQueries({ queryKey: workflowKeys.list(variables.workspaceId) }) - const { subBlockValues } = buildDefaultWorkflowArtifacts() - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [data.id]: subBlockValues, - }, - })) + // Snapshot previous state for rollback + const previousWorkflows = { ...useWorkflowRegistry.getState().workflows } + const tempId = `temp-${Date.now()}` + + // Optimistically add workflow entry immediately useWorkflowRegistry.setState((state) => ({ workflows: { ...state.workflows, - [data.id]: { - id: data.id, - name: data.name, + [tempId]: { + id: tempId, + name: variables.name || 'New Workflow', lastModified: new Date(), createdAt: new Date(), - description: data.description, - color: data.color, - workspaceId: data.workspaceId, - folderId: data.folderId, + description: variables.description || 'New workflow', + color: variables.color || '#808080', + workspaceId: variables.workspaceId, + folderId: variables.folderId || null, }, }, - error: null, })) - queryClient.invalidateQueries({ queryKey: workflowKeys.list(variables.workspaceId) }) + logger.info(`Added optimistic workflow entry: ${tempId}`) + return { tempId, previousWorkflows } }, - onError: (error: Error) => { + onSuccess: (data, _variables, context) => { + logger.info( + `Workflow ${data.id} created successfully, replacing temp entry ${context.tempId}` + ) + + const { subBlockValues } = buildDefaultWorkflowArtifacts() + useSubBlockStore.setState((state) => ({ + workflowValues: { + ...state.workflowValues, + [data.id]: subBlockValues, + }, + })) + + // Replace optimistic entry with real workflow data + useWorkflowRegistry.setState((state) => { + const { [context.tempId]: _, ...remainingWorkflows } = state.workflows + return { + workflows: { + ...remainingWorkflows, + [data.id]: { + id: data.id, + name: data.name, + lastModified: new Date(), + createdAt: new Date(), + description: data.description, + color: data.color, + workspaceId: data.workspaceId, + folderId: data.folderId, + }, + }, + error: null, + } + }) + }, + onError: (error: Error, _variables, context) => { logger.error('Failed to create workflow:', error) + + // Rollback to previous state snapshot + if (context?.previousWorkflows) { + useWorkflowRegistry.setState({ workflows: context.previousWorkflows }) + logger.info(`Rolled back to previous workflows state`) + } + }, + onSettled: (_data, _error, variables) => { + // Always invalidate to sync with server state + queryClient.invalidateQueries({ queryKey: workflowKeys.list(variables.workspaceId) }) }, }) } From 2b18c6b21f0bd084796d7771e0db365cb4cbdac9 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Thu, 4 Dec 2025 13:33:47 -0800 Subject: [PATCH 2/7] improvement: folders update --- apps/sim/hooks/queries/folders.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index 97983fd050e..e9d7aab969c 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -115,9 +115,16 @@ export function useCreateFolder() { // Snapshot previous state for rollback const previousFolders = { ...useFolderStore.getState().folders } + // Calculate max sortOrder to place new folder at the bottom + const workspaceFolders = Object.values(previousFolders).filter( + (f) => + f.workspaceId === variables.workspaceId && f.parentId === (variables.parentId || null) + ) + const maxSortOrder = workspaceFolders.reduce((max, f) => Math.max(max, f.sortOrder), -1) + const tempId = `temp-folder-${Date.now()}` - // Optimistically add folder entry immediately + // Optimistically add folder entry immediately at the bottom useFolderStore.setState((state) => ({ folders: { ...state.folders, @@ -129,7 +136,7 @@ export function useCreateFolder() { parentId: variables.parentId || null, color: variables.color || '#808080', isExpanded: false, - sortOrder: 0, + sortOrder: maxSortOrder + 1, createdAt: new Date(), updatedAt: new Date(), }, From 42536fdfadca203ef58c5adddb54d21cf395e3fe Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 5 Dec 2025 10:17:39 -0800 Subject: [PATCH 3/7] fix usage indicator rounding + new tsconfig --- .../components-new/usage-indicator/usage-indicator.tsx | 4 ++-- apps/sim/tsconfig.json | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx index eb3d0411e2e..45f1d556d2a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx @@ -272,11 +272,11 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { ) : ( <> - ${usage.current} + ${usage.current.toFixed(2)} / - ${usage.limit} + ${usage.limit.toFixed(2)} )} diff --git a/apps/sim/tsconfig.json b/apps/sim/tsconfig.json index 0ac3794dc83..f48d70e63d9 100644 --- a/apps/sim/tsconfig.json +++ b/apps/sim/tsconfig.json @@ -35,7 +35,7 @@ "resolveJsonModule": true, "isolatedModules": true, "allowImportingTsExtensions": true, - "jsx": "preserve", + "jsx": "react-jsx", "plugins": [ { "name": "next" @@ -48,7 +48,8 @@ ".next/types/**/*.ts", "../next-env.d.ts", "telemetry.config.js", - "trigger.config.ts" + "trigger.config.ts", + ".next/dev/types/**/*.ts" ], "exclude": ["node_modules"] } From 91147471eefd44e70877214c674f0514c882e08e Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 5 Dec 2025 10:58:03 -0800 Subject: [PATCH 4/7] remove redundant checks --- .../components/context-menu/context-menu.tsx | 31 ++++++++-------- .../components/folder-item/folder-item.tsx | 36 +++++++++++-------- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu.tsx index 606dddd67d0..c53890e0efa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu.tsx @@ -81,6 +81,11 @@ interface ContextMenuProps { * Set to true when user lacks permissions */ disableDelete?: boolean + /** + * Whether the create option is disabled (default: false) + * Set to true when creation is in progress or user lacks permissions + */ + disableCreate?: boolean } /** @@ -108,6 +113,7 @@ export function ContextMenu({ disableRename = false, disableDuplicate = false, disableDelete = false, + disableCreate = false, }: ContextMenuProps) { return ( @@ -125,10 +131,8 @@ export function ContextMenu({ { - if (!disableRename) { - onRename() - onClose() - } + onRename() + onClose() }} > @@ -137,6 +141,7 @@ export function ContextMenu({ )} {showCreate && onCreate && ( { onCreate() onClose() @@ -150,10 +155,8 @@ export function ContextMenu({ { - if (!disableDuplicate) { - onDuplicate() - onClose() - } + onDuplicate() + onClose() }} > @@ -164,10 +167,8 @@ export function ContextMenu({ { - if (!disableExport) { - onExport() - onClose() - } + onExport() + onClose() }} > @@ -177,10 +178,8 @@ export function ContextMenu({ { - if (!disableDelete) { - onDelete() - onClose() - } + onDelete() + onClose() }} > diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx index 55ccc8b065b..361aa0c1f41 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx @@ -4,6 +4,7 @@ import { useCallback, useState } from 'react' import clsx from 'clsx' import { ChevronRight, Folder, FolderOpen } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' +import { createLogger } from '@/lib/logs/console/logger' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu' import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/delete-modal/delete-modal' @@ -22,6 +23,8 @@ import { getNextWorkflowColor, } from '@/stores/workflows/registry/utils' +const logger = createLogger('FolderItem') + interface FolderItemProps { folder: FolderTreeNode level: number @@ -66,25 +69,27 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { /** * Handle create workflow in folder using React Query mutation. * Generates name and color upfront for optimistic UI updates. + * The UI disables the trigger when isPending, so no guard needed here. */ const handleCreateWorkflowInFolder = useCallback(async () => { - if (createWorkflowMutation.isPending) { - return - } - - // Generate name and color upfront for optimistic updates - const name = generateCreativeWorkflowName() - const color = getNextWorkflowColor() + try { + // Generate name and color upfront for optimistic updates + const name = generateCreativeWorkflowName() + const color = getNextWorkflowColor() - const result = await createWorkflowMutation.mutateAsync({ - workspaceId, - folderId: folder.id, - name, - color, - }) + const result = await createWorkflowMutation.mutateAsync({ + workspaceId, + folderId: folder.id, + name, + color, + }) - if (result.id) { - router.push(`/workspace/${workspaceId}/w/${result.id}`) + if (result.id) { + router.push(`/workspace/${workspaceId}/w/${result.id}`) + } + } catch (error) { + // Error already handled by mutation's onError callback + logger.error('Failed to create workflow in folder:', error) } }, [createWorkflowMutation, workspaceId, folder.id, router]) @@ -278,6 +283,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { onDelete={() => setIsDeleteModalOpen(true)} showCreate={true} disableRename={!userPermissions.canEdit} + disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending} disableDuplicate={!userPermissions.canEdit} disableDelete={!userPermissions.canEdit} /> From 094fad6501239de169c1a86e3512aa4fe5aaf3b3 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 5 Dec 2025 11:49:41 -0800 Subject: [PATCH 5/7] fix hmr case for missing workflow loads --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 14 +++++++-- .../sidebar/hooks/use-folder-operations.ts | 8 +---- .../sidebar/hooks/use-workflow-operations.ts | 29 +------------------ .../w/components/sidebar/sidebar-new.tsx | 6 +--- apps/sim/stores/workflows/registry/store.ts | 14 +++++++-- 5 files changed, 26 insertions(+), 45 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index a68197f18cd..896cbffef4e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -1277,7 +1277,7 @@ const WorkflowContent = React.memo(() => { [screenToFlowPosition, isPointInLoopNode, getNodes] ) - // Initialize workflow when it exists in registry and isn't active + // Initialize workflow when it exists in registry and isn't active or needs hydration useEffect(() => { let cancelled = false const currentId = params.workflowId as string @@ -1295,8 +1295,16 @@ const WorkflowContent = React.memo(() => { return } - if (activeWorkflowId !== currentId) { - // Clear diff and set as active + // Check if we need to load the workflow state: + // 1. Different workflow than currently active + // 2. Same workflow but hydration phase is not 'ready' (e.g., after a quick refresh) + const needsWorkflowLoad = + activeWorkflowId !== currentId || + (activeWorkflowId === currentId && + hydration.phase !== 'ready' && + hydration.phase !== 'state-loading') + + if (needsWorkflowLoad) { const { clearDiff } = useWorkflowDiffStore.getState() clearDiff() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-operations.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-operations.ts index b53ee0002c2..47800c67b2a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-operations.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-operations.ts @@ -20,18 +20,12 @@ interface UseFolderOperationsProps { export function useFolderOperations({ workspaceId }: UseFolderOperationsProps) { const createFolderMutation = useCreateFolder() - /** - * Create folder handler - creates folder with auto-generated name. - * Generates name upfront to enable optimistic UI updates. - */ const handleCreateFolder = useCallback(async (): Promise => { - if (createFolderMutation.isPending || !workspaceId) { - logger.info('Folder creation already in progress or no workspaceId available') + if (!workspaceId) { return null } try { - // Generate folder name upfront for optimistic updates const folderName = await generateFolderName(workspaceId) const folder = await createFolderMutation.mutateAsync({ name: folderName, workspaceId }) logger.info(`Created folder: ${folderName}`) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts index 455932a585b..5a36ebd4e13 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts @@ -13,22 +13,9 @@ const logger = createLogger('useWorkflowOperations') interface UseWorkflowOperationsProps { workspaceId: string - isWorkspaceValid: (workspaceId: string) => Promise - onWorkspaceInvalid: () => void } -/** - * Custom hook to manage workflow operations including creating and loading workflows. - * Handles workflow state management and navigation. - * - * @param props - Configuration object containing workspaceId and validation handlers - * @returns Workflow operations state and handlers - */ -export function useWorkflowOperations({ - workspaceId, - isWorkspaceValid, - onWorkspaceInvalid, -}: UseWorkflowOperationsProps) { +export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProps) { const router = useRouter() const { workflows } = useWorkflowRegistry() const workflowsQuery = useWorkflows(workspaceId) @@ -44,34 +31,20 @@ export function useWorkflowOperations({ return b.createdAt.getTime() - a.createdAt.getTime() }) - /** - * Create workflow handler - creates workflow and navigates to it. - * Uses React Query mutation's isPending state for immediate loading feedback. - * Generates name and color upfront to enable optimistic UI updates. - */ const handleCreateWorkflow = useCallback(async (): Promise => { - if (createWorkflowMutation.isPending) { - logger.info('Workflow creation already in progress, ignoring request') - return null - } - try { - // Clear workflow diff store when creating a new workflow const { clearDiff } = useWorkflowDiffStore.getState() clearDiff() - // Generate name and color upfront for optimistic updates const name = generateCreativeWorkflowName() const color = getNextWorkflowColor() - // Use React Query mutation for creation - isPending updates immediately const result = await createWorkflowMutation.mutateAsync({ workspaceId, name, color, }) - // Navigate to the newly created workflow if (result.id) { router.push(`/workspace/${workspaceId}/w/${result.id}`) return result.id diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx index fe16e229860..cc03c815b4e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx @@ -118,11 +118,7 @@ export function SidebarNew() { workflowsLoading, isCreatingWorkflow, handleCreateWorkflow: createWorkflow, - } = useWorkflowOperations({ - workspaceId, - isWorkspaceValid, - onWorkspaceInvalid: fetchWorkspaces, - }) + } = useWorkflowOperations({ workspaceId }) // Folder operations hook const { isCreatingFolder, handleCreateFolder: createFolder } = useFolderOperations({ diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index ab871a22d8c..042a36350ba 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -426,12 +426,22 @@ export const useWorkflowRegistry = create()( // Modified setActiveWorkflow to work with clean DB-only architecture setActiveWorkflow: async (id: string) => { - const { activeWorkflowId } = get() + const { activeWorkflowId, hydration } = get() const workflowStoreState = useWorkflowStore.getState() const hasWorkflowData = Object.keys(workflowStoreState.blocks).length > 0 - if (activeWorkflowId === id && hasWorkflowData) { + // Skip loading only if: + // - Same workflow is already active + // - Workflow data exists + // - Hydration is complete (phase is 'ready') + const isFullyHydrated = + activeWorkflowId === id && + hasWorkflowData && + hydration.phase === 'ready' && + hydration.workflowId === id + + if (isFullyHydrated) { logger.info(`Already active workflow ${id} with data loaded, skipping switch`) return } From a706452b5147b1a2f4e44b3db148c355ab836b22 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 5 Dec 2025 12:02:10 -0800 Subject: [PATCH 6/7] add abstraction for zustand/react hybrid optimism --- .../w/hooks/use-duplicate-workflow.ts | 64 +++-- apps/sim/hooks/queries/folders.ts | 185 +++++++------ apps/sim/hooks/queries/utils/index.ts | 6 + .../queries/utils/optimistic-mutation.ts | 118 +++++++++ apps/sim/hooks/queries/workflows.ts | 244 +++++++++++++----- 5 files changed, 452 insertions(+), 165 deletions(-) create mode 100644 apps/sim/hooks/queries/utils/index.ts create mode 100644 apps/sim/hooks/queries/utils/optimistic-mutation.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts index 85f17ade2b2..fc12aef6f72 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts @@ -1,8 +1,10 @@ -import { useCallback, useState } from 'react' +import { useCallback } from 'react' import { useRouter } from 'next/navigation' import { createLogger } from '@/lib/logs/console/logger' +import { useDuplicateWorkflowMutation } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { getNextWorkflowColor } from '@/stores/workflows/registry/utils' const logger = createLogger('useDuplicateWorkflow') @@ -23,11 +25,12 @@ interface UseDuplicateWorkflowProps { } /** - * Hook for managing workflow duplication. + * Hook for managing workflow duplication with optimistic updates. * * Handles: * - Single or bulk workflow duplication - * - Calling duplicate API for each workflow + * - Optimistic UI updates (shows new workflow immediately) + * - Automatic rollback on failure * - Loading state management * - Error handling and logging * - Clearing selection after duplication @@ -42,38 +45,49 @@ export function useDuplicateWorkflow({ onSuccess, }: UseDuplicateWorkflowProps) { const router = useRouter() - const { duplicateWorkflow } = useWorkflowRegistry() - const [isDuplicating, setIsDuplicating] = useState(false) + const { workflows } = useWorkflowRegistry() + const duplicateMutation = useDuplicateWorkflowMutation() /** * Duplicate the workflow(s) */ const handleDuplicateWorkflow = useCallback(async () => { - if (isDuplicating) { + if (duplicateMutation.isPending) { return } - setIsDuplicating(true) - try { - // Get fresh workflow IDs at duplication time - const workflowIdsOrId = getWorkflowIds() - if (!workflowIdsOrId) { - return - } + // Get fresh workflow IDs at duplication time + const workflowIdsOrId = getWorkflowIds() + if (!workflowIdsOrId) { + return + } - // Normalize to array for consistent handling - const workflowIdsToDuplicate = Array.isArray(workflowIdsOrId) - ? workflowIdsOrId - : [workflowIdsOrId] + // Normalize to array for consistent handling + const workflowIdsToDuplicate = Array.isArray(workflowIdsOrId) + ? workflowIdsOrId + : [workflowIdsOrId] - const duplicatedIds: string[] = [] + const duplicatedIds: string[] = [] + try { // Duplicate each workflow sequentially - for (const workflowId of workflowIdsToDuplicate) { - const newWorkflowId = await duplicateWorkflow(workflowId) - if (newWorkflowId) { - duplicatedIds.push(newWorkflowId) + for (const sourceId of workflowIdsToDuplicate) { + const sourceWorkflow = workflows[sourceId] + if (!sourceWorkflow) { + logger.warn(`Workflow ${sourceId} not found, skipping`) + continue } + + const result = await duplicateMutation.mutateAsync({ + workspaceId, + sourceId, + name: `${sourceWorkflow.name} (Copy)`, + description: sourceWorkflow.description, + color: getNextWorkflowColor(), + folderId: sourceWorkflow.folderId, + }) + + duplicatedIds.push(result.id) } // Clear selection after successful duplication @@ -94,13 +108,11 @@ export function useDuplicateWorkflow({ } catch (error) { logger.error('Error duplicating workflow(s):', { error }) throw error - } finally { - setIsDuplicating(false) } - }, [getWorkflowIds, isDuplicating, duplicateWorkflow, workspaceId, router, onSuccess]) + }, [getWorkflowIds, duplicateMutation, workflows, workspaceId, router, onSuccess]) return { - isDuplicating, + isDuplicating: duplicateMutation.isPending, handleDuplicateWorkflow, } } diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index e9d7aab969c..4a787b1adf0 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -1,6 +1,10 @@ import { useEffect } from 'react' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { createLogger } from '@/lib/logs/console/logger' +import { + createOptimisticMutationHandlers, + generateTempId, +} from '@/hooks/queries/utils/optimistic-mutation' import { workflowKeys } from '@/hooks/queries/workflows' import { useFolderStore, type WorkflowFolder } from '@/stores/folders/store' @@ -65,11 +69,6 @@ interface CreateFolderVariables { color?: string } -interface CreateFolderContext { - tempId: string - previousFolders: Record -} - interface UpdateFolderVariables { workspaceId: string id: string @@ -89,9 +88,83 @@ interface DuplicateFolderVariables { color?: string } +/** + * Creates optimistic mutation handlers for folder operations + */ +function createFolderMutationHandlers( + queryClient: ReturnType, + name: string, + createOptimisticFolder: ( + variables: TVariables, + tempId: string, + previousFolders: Record + ) => WorkflowFolder +) { + return createOptimisticMutationHandlers(queryClient, { + name, + getQueryKey: (variables) => folderKeys.list(variables.workspaceId), + getSnapshot: () => ({ ...useFolderStore.getState().folders }), + generateTempId: () => generateTempId('temp-folder'), + createOptimisticItem: (variables, tempId) => { + const previousFolders = useFolderStore.getState().folders + return createOptimisticFolder(variables, tempId, previousFolders) + }, + applyOptimisticUpdate: (tempId, item) => { + useFolderStore.setState((state) => ({ + folders: { ...state.folders, [tempId]: item }, + })) + }, + replaceOptimisticEntry: (tempId, data) => { + useFolderStore.setState((state) => { + const { [tempId]: _, ...remainingFolders } = state.folders + return { + folders: { + ...remainingFolders, + [data.id]: data, + }, + } + }) + }, + rollback: (snapshot) => { + useFolderStore.setState({ folders: snapshot }) + }, + }) +} + +/** + * Calculates the next sort order for a folder in a given parent + */ +function getNextSortOrder( + folders: Record, + workspaceId: string, + parentId: string | null | undefined +): number { + const siblingFolders = Object.values(folders).filter( + (f) => f.workspaceId === workspaceId && f.parentId === (parentId || null) + ) + return siblingFolders.reduce((max, f) => Math.max(max, f.sortOrder), -1) + 1 +} + export function useCreateFolder() { const queryClient = useQueryClient() + const handlers = createFolderMutationHandlers( + queryClient, + 'CreateFolder', + (variables, tempId, previousFolders) => ({ + id: tempId, + name: variables.name, + userId: '', + workspaceId: variables.workspaceId, + parentId: variables.parentId || null, + color: variables.color || '#808080', + isExpanded: false, + sortOrder: getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId), + createdAt: new Date(), + updatedAt: new Date(), + }) + ) + return useMutation({ mutationFn: async ({ workspaceId, ...payload }: CreateFolderVariables) => { const response = await fetch('/api/folders', { @@ -108,71 +181,7 @@ export function useCreateFolder() { const { folder } = await response.json() return mapFolder(folder) }, - onMutate: async (variables): Promise => { - // Cancel any outgoing refetches to prevent race conditions - await queryClient.cancelQueries({ queryKey: folderKeys.list(variables.workspaceId) }) - - // Snapshot previous state for rollback - const previousFolders = { ...useFolderStore.getState().folders } - - // Calculate max sortOrder to place new folder at the bottom - const workspaceFolders = Object.values(previousFolders).filter( - (f) => - f.workspaceId === variables.workspaceId && f.parentId === (variables.parentId || null) - ) - const maxSortOrder = workspaceFolders.reduce((max, f) => Math.max(max, f.sortOrder), -1) - - const tempId = `temp-folder-${Date.now()}` - - // Optimistically add folder entry immediately at the bottom - useFolderStore.setState((state) => ({ - folders: { - ...state.folders, - [tempId]: { - id: tempId, - name: variables.name, - userId: '', - workspaceId: variables.workspaceId, - parentId: variables.parentId || null, - color: variables.color || '#808080', - isExpanded: false, - sortOrder: maxSortOrder + 1, - createdAt: new Date(), - updatedAt: new Date(), - }, - }, - })) - - logger.info(`Added optimistic folder entry: ${tempId}`) - return { tempId, previousFolders } - }, - onSuccess: (data, _variables, context) => { - logger.info(`Folder ${data.id} created successfully, replacing temp entry ${context.tempId}`) - - // Replace optimistic entry with real folder data - useFolderStore.setState((state) => { - const { [context.tempId]: _, ...remainingFolders } = state.folders - return { - folders: { - ...remainingFolders, - [data.id]: data, - }, - } - }) - }, - onError: (error: Error, _variables, context) => { - logger.error('Failed to create folder:', error) - - // Rollback to previous state snapshot - if (context?.previousFolders) { - useFolderStore.setState({ folders: context.previousFolders }) - logger.info(`Rolled back to previous folders state`) - } - }, - onSettled: (_data, _error, variables) => { - // Always invalidate to sync with server state - queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) }) - }, + ...handlers, }) } @@ -225,8 +234,35 @@ export function useDeleteFolderMutation() { export function useDuplicateFolderMutation() { const queryClient = useQueryClient() + const handlers = createFolderMutationHandlers( + queryClient, + 'DuplicateFolder', + (variables, tempId, previousFolders) => { + // Get source folder info if available + const sourceFolder = previousFolders[variables.id] + return { + id: tempId, + name: variables.name, + userId: sourceFolder?.userId || '', + workspaceId: variables.workspaceId, + parentId: variables.parentId ?? sourceFolder?.parentId ?? null, + color: variables.color || sourceFolder?.color || '#808080', + isExpanded: false, + sortOrder: getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId), + createdAt: new Date(), + updatedAt: new Date(), + } + } + ) + return useMutation({ - mutationFn: async ({ id, workspaceId, name, parentId, color }: DuplicateFolderVariables) => { + mutationFn: async ({ + id, + workspaceId, + name, + parentId, + color, + }: DuplicateFolderVariables): Promise => { const response = await fetch(`/api/folders/${id}/duplicate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -243,9 +279,12 @@ export function useDuplicateFolderMutation() { throw new Error(error.error || 'Failed to duplicate folder') } - return response.json() + const data = await response.json() + return mapFolder(data.folder || data) }, - onSuccess: async (_data, variables) => { + ...handlers, + onSettled: (_data, _error, variables) => { + // Invalidate both folders and workflows (duplicated folder may contain workflows) queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) }) queryClient.invalidateQueries({ queryKey: workflowKeys.list(variables.workspaceId) }) }, diff --git a/apps/sim/hooks/queries/utils/index.ts b/apps/sim/hooks/queries/utils/index.ts new file mode 100644 index 00000000000..85165424e19 --- /dev/null +++ b/apps/sim/hooks/queries/utils/index.ts @@ -0,0 +1,6 @@ +export { + createOptimisticMutationHandlers, + generateTempId, + type OptimisticMutationConfig, + type OptimisticMutationContext, +} from './optimistic-mutation' diff --git a/apps/sim/hooks/queries/utils/optimistic-mutation.ts b/apps/sim/hooks/queries/utils/optimistic-mutation.ts new file mode 100644 index 00000000000..73a125ac984 --- /dev/null +++ b/apps/sim/hooks/queries/utils/optimistic-mutation.ts @@ -0,0 +1,118 @@ +import type { QueryClient } from '@tanstack/react-query' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('OptimisticMutation') + +/** + * Configuration for creating an optimistic mutation + */ +export interface OptimisticMutationConfig { + /** Name for logging purposes */ + name: string + /** Query keys to cancel and invalidate */ + getQueryKey: (variables: TVariables) => readonly unknown[] + /** Get current state snapshot for rollback */ + getSnapshot: () => Record + /** Generate a temporary ID for the optimistic entry */ + generateTempId: () => string + /** Create the optimistic item to insert */ + createOptimisticItem: (variables: TVariables, tempId: string) => TItem + /** Apply optimistic update to state */ + applyOptimisticUpdate: (tempId: string, item: TItem) => void + /** Replace temp entry with real data on success */ + replaceOptimisticEntry: (tempId: string, data: TData) => void + /** Rollback state on error */ + rollback: (snapshot: Record) => void + /** Optional additional success handler */ + onSuccessExtra?: (data: TData, variables: TVariables) => void +} + +/** + * Context returned by onMutate for use in onSuccess/onError + */ +export interface OptimisticMutationContext { + tempId: string + previousState: Record +} + +/** + * Creates mutation lifecycle handlers for optimistic updates + * + * @param queryClient - React Query client + * @param config - Configuration for the optimistic mutation + * @returns Object with onMutate, onSuccess, onError, and onSettled handlers + */ +export function createOptimisticMutationHandlers( + queryClient: QueryClient, + config: OptimisticMutationConfig> +) { + const { + name, + getQueryKey, + getSnapshot, + generateTempId, + createOptimisticItem, + applyOptimisticUpdate, + replaceOptimisticEntry, + rollback, + onSuccessExtra, + } = config + + return { + onMutate: async (variables: TVariables): Promise> => { + const queryKey = getQueryKey(variables) + + // Cancel any outgoing refetches to prevent race conditions + await queryClient.cancelQueries({ queryKey }) + + // Snapshot previous state for rollback + const previousState = getSnapshot() + + // Generate temp ID and create optimistic item + const tempId = generateTempId() + const optimisticItem = createOptimisticItem(variables, tempId) + + // Apply optimistic update + applyOptimisticUpdate(tempId, optimisticItem) + + logger.info(`[${name}] Added optimistic entry: ${tempId}`) + return { tempId, previousState } + }, + + onSuccess: (data: TData, variables: TVariables, context: OptimisticMutationContext) => { + logger.info(`[${name}] Success, replacing temp entry ${context.tempId}`) + + // Replace optimistic entry with real data + replaceOptimisticEntry(context.tempId, data) + + // Call extra success handler if provided + onSuccessExtra?.(data, variables) + }, + + onError: ( + error: Error, + _variables: TVariables, + context: OptimisticMutationContext | undefined + ) => { + logger.error(`[${name}] Failed:`, error) + + // Rollback to previous state + if (context?.previousState) { + rollback(context.previousState) + logger.info(`[${name}] Rolled back to previous state`) + } + }, + + onSettled: (_data: TData | undefined, _error: Error | null, variables: TVariables) => { + // Always invalidate to sync with server state + queryClient.invalidateQueries({ queryKey: getQueryKey(variables) }) + }, + } +} + +/** + * Generates a temporary ID with a given prefix + */ +export function generateTempId(prefix: string): string { + return `${prefix}-${Date.now()}` +} diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index f25472b743a..6c1267aa8e2 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -2,6 +2,10 @@ import { useEffect } from 'react' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { createLogger } from '@/lib/logs/console/logger' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' +import { + createOptimisticMutationHandlers, + generateTempId, +} from '@/hooks/queries/utils/optimistic-mutation' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' import { @@ -87,16 +91,106 @@ interface CreateWorkflowVariables { folderId?: string | null } -interface CreateWorkflowContext { - tempId: string - previousWorkflows: Record +interface CreateWorkflowResult { + id: string + name: string + description?: string + color: string + workspaceId: string + folderId?: string | null +} + +interface DuplicateWorkflowVariables { + workspaceId: string + sourceId: string + name: string + description?: string + color: string + folderId?: string | null +} + +interface DuplicateWorkflowResult { + id: string + name: string + description?: string + color: string + workspaceId: string + folderId?: string | null + blocksCount: number + edgesCount: number + subflowsCount: number +} + +/** + * Creates optimistic mutation handlers for workflow operations + */ +function createWorkflowMutationHandlers( + queryClient: ReturnType, + name: string, + createOptimisticWorkflow: (variables: TVariables, tempId: string) => WorkflowMetadata +) { + return createOptimisticMutationHandlers< + CreateWorkflowResult | DuplicateWorkflowResult, + TVariables, + WorkflowMetadata + >(queryClient, { + name, + getQueryKey: (variables) => workflowKeys.list(variables.workspaceId), + getSnapshot: () => ({ ...useWorkflowRegistry.getState().workflows }), + generateTempId: () => generateTempId('temp-workflow'), + createOptimisticItem: createOptimisticWorkflow, + applyOptimisticUpdate: (tempId, item) => { + useWorkflowRegistry.setState((state) => ({ + workflows: { ...state.workflows, [tempId]: item }, + })) + }, + replaceOptimisticEntry: (tempId, data) => { + useWorkflowRegistry.setState((state) => { + const { [tempId]: _, ...remainingWorkflows } = state.workflows + return { + workflows: { + ...remainingWorkflows, + [data.id]: { + id: data.id, + name: data.name, + lastModified: new Date(), + createdAt: new Date(), + description: data.description, + color: data.color, + workspaceId: data.workspaceId, + folderId: data.folderId, + }, + }, + error: null, + } + }) + }, + rollback: (snapshot) => { + useWorkflowRegistry.setState({ workflows: snapshot }) + }, + }) } export function useCreateWorkflow() { const queryClient = useQueryClient() + const handlers = createWorkflowMutationHandlers( + queryClient, + 'CreateWorkflow', + (variables, tempId) => ({ + id: tempId, + name: variables.name || generateCreativeWorkflowName(), + lastModified: new Date(), + createdAt: new Date(), + description: variables.description || 'New workflow', + color: variables.color || getNextWorkflowColor(), + workspaceId: variables.workspaceId, + folderId: variables.folderId || null, + }) + ) + return useMutation({ - mutationFn: async (variables: CreateWorkflowVariables) => { + mutationFn: async (variables: CreateWorkflowVariables): Promise => { const { workspaceId, name, description, color, folderId } = variables logger.info(`Creating new workflow in workspace: ${workspaceId}`) @@ -149,40 +243,11 @@ export function useCreateWorkflow() { folderId: createdWorkflow.folderId, } }, - onMutate: async (variables): Promise => { - // Cancel any outgoing refetches to prevent race conditions - await queryClient.cancelQueries({ queryKey: workflowKeys.list(variables.workspaceId) }) - - // Snapshot previous state for rollback - const previousWorkflows = { ...useWorkflowRegistry.getState().workflows } - - const tempId = `temp-${Date.now()}` - - // Optimistically add workflow entry immediately - useWorkflowRegistry.setState((state) => ({ - workflows: { - ...state.workflows, - [tempId]: { - id: tempId, - name: variables.name || 'New Workflow', - lastModified: new Date(), - createdAt: new Date(), - description: variables.description || 'New workflow', - color: variables.color || '#808080', - workspaceId: variables.workspaceId, - folderId: variables.folderId || null, - }, - }, - })) - - logger.info(`Added optimistic workflow entry: ${tempId}`) - return { tempId, previousWorkflows } - }, - onSuccess: (data, _variables, context) => { - logger.info( - `Workflow ${data.id} created successfully, replacing temp entry ${context.tempId}` - ) + ...handlers, + onSuccess: (data, variables, context) => { + handlers.onSuccess(data, variables, context) + // Initialize subblock values for new workflow const { subBlockValues } = buildDefaultWorkflowArtifacts() useSubBlockStore.setState((state) => ({ workflowValues: { @@ -190,40 +255,87 @@ export function useCreateWorkflow() { [data.id]: subBlockValues, }, })) + }, + }) +} - // Replace optimistic entry with real workflow data - useWorkflowRegistry.setState((state) => { - const { [context.tempId]: _, ...remainingWorkflows } = state.workflows - return { - workflows: { - ...remainingWorkflows, - [data.id]: { - id: data.id, - name: data.name, - lastModified: new Date(), - createdAt: new Date(), - description: data.description, - color: data.color, - workspaceId: data.workspaceId, - folderId: data.folderId, - }, - }, - error: null, - } +export function useDuplicateWorkflowMutation() { + const queryClient = useQueryClient() + + const handlers = createWorkflowMutationHandlers( + queryClient, + 'DuplicateWorkflow', + (variables, tempId) => ({ + id: tempId, + name: variables.name, + lastModified: new Date(), + createdAt: new Date(), + description: variables.description, + color: variables.color, + workspaceId: variables.workspaceId, + folderId: variables.folderId || null, + }) + ) + + return useMutation({ + mutationFn: async (variables: DuplicateWorkflowVariables): Promise => { + const { workspaceId, sourceId, name, description, color, folderId } = variables + + logger.info(`Duplicating workflow ${sourceId} in workspace: ${workspaceId}`) + + const response = await fetch(`/api/workflows/${sourceId}/duplicate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + description, + color, + workspaceId, + folderId: folderId ?? null, + }), }) - }, - onError: (error: Error, _variables, context) => { - logger.error('Failed to create workflow:', error) - // Rollback to previous state snapshot - if (context?.previousWorkflows) { - useWorkflowRegistry.setState({ workflows: context.previousWorkflows }) - logger.info(`Rolled back to previous workflows state`) + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(`Failed to duplicate workflow: ${errorData.error || response.statusText}`) + } + + const duplicatedWorkflow = await response.json() + + logger.info(`Successfully duplicated workflow ${sourceId} to ${duplicatedWorkflow.id}`, { + blocksCount: duplicatedWorkflow.blocksCount, + edgesCount: duplicatedWorkflow.edgesCount, + subflowsCount: duplicatedWorkflow.subflowsCount, + }) + + return { + id: duplicatedWorkflow.id, + name: duplicatedWorkflow.name || name, + description: duplicatedWorkflow.description || description, + color: duplicatedWorkflow.color || color, + workspaceId, + folderId: duplicatedWorkflow.folderId ?? folderId, + blocksCount: duplicatedWorkflow.blocksCount || 0, + edgesCount: duplicatedWorkflow.edgesCount || 0, + subflowsCount: duplicatedWorkflow.subflowsCount || 0, } }, - onSettled: (_data, _error, variables) => { - // Always invalidate to sync with server state - queryClient.invalidateQueries({ queryKey: workflowKeys.list(variables.workspaceId) }) + ...handlers, + onSuccess: (data, variables, context) => { + handlers.onSuccess(data, variables, context) + + // Copy subblock values from source if it's the active workflow + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + if (variables.sourceId === activeWorkflowId) { + const sourceSubblockValues = + useSubBlockStore.getState().workflowValues[variables.sourceId] || {} + useSubBlockStore.setState((state) => ({ + workflowValues: { + ...state.workflowValues, + [data.id]: { ...sourceSubblockValues }, + }, + })) + } }, }) } From 6e3d8a7b1a9afd2dfe7675887777601ed241cbfa Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 5 Dec 2025 12:03:38 -0800 Subject: [PATCH 7/7] remove comments --- .../queries/utils/optimistic-mutation.ts | 41 ------------------- 1 file changed, 41 deletions(-) diff --git a/apps/sim/hooks/queries/utils/optimistic-mutation.ts b/apps/sim/hooks/queries/utils/optimistic-mutation.ts index 73a125ac984..3fc2e99d060 100644 --- a/apps/sim/hooks/queries/utils/optimistic-mutation.ts +++ b/apps/sim/hooks/queries/utils/optimistic-mutation.ts @@ -3,45 +3,23 @@ import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('OptimisticMutation') -/** - * Configuration for creating an optimistic mutation - */ export interface OptimisticMutationConfig { - /** Name for logging purposes */ name: string - /** Query keys to cancel and invalidate */ getQueryKey: (variables: TVariables) => readonly unknown[] - /** Get current state snapshot for rollback */ getSnapshot: () => Record - /** Generate a temporary ID for the optimistic entry */ generateTempId: () => string - /** Create the optimistic item to insert */ createOptimisticItem: (variables: TVariables, tempId: string) => TItem - /** Apply optimistic update to state */ applyOptimisticUpdate: (tempId: string, item: TItem) => void - /** Replace temp entry with real data on success */ replaceOptimisticEntry: (tempId: string, data: TData) => void - /** Rollback state on error */ rollback: (snapshot: Record) => void - /** Optional additional success handler */ onSuccessExtra?: (data: TData, variables: TVariables) => void } -/** - * Context returned by onMutate for use in onSuccess/onError - */ export interface OptimisticMutationContext { tempId: string previousState: Record } -/** - * Creates mutation lifecycle handlers for optimistic updates - * - * @param queryClient - React Query client - * @param config - Configuration for the optimistic mutation - * @returns Object with onMutate, onSuccess, onError, and onSettled handlers - */ export function createOptimisticMutationHandlers( queryClient: QueryClient, config: OptimisticMutationConfig> @@ -61,31 +39,18 @@ export function createOptimisticMutationHandlers( return { onMutate: async (variables: TVariables): Promise> => { const queryKey = getQueryKey(variables) - - // Cancel any outgoing refetches to prevent race conditions await queryClient.cancelQueries({ queryKey }) - - // Snapshot previous state for rollback const previousState = getSnapshot() - - // Generate temp ID and create optimistic item const tempId = generateTempId() const optimisticItem = createOptimisticItem(variables, tempId) - - // Apply optimistic update applyOptimisticUpdate(tempId, optimisticItem) - logger.info(`[${name}] Added optimistic entry: ${tempId}`) return { tempId, previousState } }, onSuccess: (data: TData, variables: TVariables, context: OptimisticMutationContext) => { logger.info(`[${name}] Success, replacing temp entry ${context.tempId}`) - - // Replace optimistic entry with real data replaceOptimisticEntry(context.tempId, data) - - // Call extra success handler if provided onSuccessExtra?.(data, variables) }, @@ -95,8 +60,6 @@ export function createOptimisticMutationHandlers( context: OptimisticMutationContext | undefined ) => { logger.error(`[${name}] Failed:`, error) - - // Rollback to previous state if (context?.previousState) { rollback(context.previousState) logger.info(`[${name}] Rolled back to previous state`) @@ -104,15 +67,11 @@ export function createOptimisticMutationHandlers( }, onSettled: (_data: TData | undefined, _error: Error | null, variables: TVariables) => { - // Always invalidate to sync with server state queryClient.invalidateQueries({ queryKey: getQueryKey(variables) }) }, } } -/** - * Generates a temporary ID with a given prefix - */ export function generateTempId(prefix: string): string { return `${prefix}-${Date.now()}` }